# Channel Insights · Summary (Profile + Overall)

**Context:** Manage · **UI:** Lead → Intelligence → **Summary** (the first and default Intelligence sub-tab, `?itab=summary`) · **Routes:** `manage.leads.summary-insights.*` · Built 2026-09-23 at the founder's request: *"i need to add one more sub tab next to profile and insight called summary, this summary need to provide a summary for all insights and profile."*

## What it does

The one screen a salesperson reads in the thirty seconds before a call. It puts the two halves of Intelligence side by side:

- **What we FOUND** — the Profile: the enrichment report (estimated occupation and income, approximate location, the business listed on their number, the AI sales report's recommended action, the suspected-fake / property-professional flags) and the funnel they came in through.
- **What they TOLD us** — the Overall reading's "who they are", stage and momentum, what is still owed and the message to send.

…and adds the one thing neither can produce alone: **where the Profile confirms or conflicts with what the person said** ("IP and carrier place him in Johor Bahru" vs "told the agent he lives in PJ"), plus a headline, a 2–3 sentence brief, how to approach this person, and one next move.

Screen, top to bottom (`Partials/Tabs/SummaryTab.vue`): provenance line (Profile enriched / Overall read / Summary written, each with its age) → the brief (+ Overall's stage and momentum, + "How to approach them") → *What we found* | *What they told us* → **Profile vs what they said** (conflicts first) → Readiness (seven tiles, the `ReadinessVerdict` the Leads list prints) → Next move + Still owed | Send now.

**Only the brief, the cross-checks, the approach and the next move are this tab's own AI reading.** Everything else is the Profile and the Overall reading rendered as they are stored, so nothing on the Summary can disagree with the tab it came from. Every source chip opens the tab that holds it (`profile` → Intelligence → Profile; a channel key → Intelligence → Insight on that channel's reading, via `AllInsightsTab`'s `initialTab`).

## The record — the Leads list's row, as a page (added 2026-09-23)

Founder: *"is the summary tab got mention all those informations such as status, value membership and clv (total and breakdown), property closing columns, portfolio columns, readiness all the columns, REP, Last engaged, engaged all columns? … whatever you see in lead table, we shall also see at here … make it world class design."*

- **One builder, two callers.** `LeadsController::index()` was split into `withListColumns(Builder)` (eager loads, counts, sums, correlated subselects) and `listRows(Collection, User, $attention)` (the batched lookups — CLV, readiness, entitlements, reps, action items, owner archive… — merged into `transform()`). The list calls both for its page; **`GET /manage/leads/{uuid}/record`** (`LeadsController::record`, same visibility rule) calls them for one lead and returns `{row, meta}`. So the Summary can never print a figure the list does not — `LeadRecordTest` asserts the record equals the list row key for key. Verified at the refactor against the pre-refactor controller on live data: identical rows, same order, for the default page, CLV sort, a Pay Attention filter and a readiness sort (the only differences were a fractional "Nothing for N days" readiness fact that changes on every request, pre-existing).
- **`Summary/RecordOverview.vue`** lays the row out: Status strip (LeadStages badge + why + next, pipeline status, quality, last engaged + channel, reply owed / never replied, action items) → **CLV** (`ClvBreakdown.vue`: figure, one stacked bar, a legend line per part — MLTA / Management say "not recorded yet", never RM 0) · **Membership** (total, joined, each product, what the member is still owed) · **Property closing** (Booked / Converted / Dropped + every deal with unit, price, date, closer; open pipelines) → **Portfolio** (declared, public record when permitted, loan eligibility, 90% loans left, report link) · **Readiness** (seven tiles with the verdict's sentence) → **Relationship** (Rep with time per channel, Follow Up roles, account manager) · **Engagements** (Zoom meeting, webinar, phone, WhatsApp in/out, portal, showroom, AI call; "Last" on the tile the Engaged column points at — the list's `zoom` channel covers meetings AND webinars, so it lands on the meeting tile only when a meeting happened).
- **Compact by rule (founder, 2026-09-23: "these two section height is too big … property closing … can do like scroll down"):** the VALUE row does not stretch its cards to the tallest one (`items-start`); CLV lists only the parts that hold money and folds the rest into two muted lines ("None yet …", "Not recorded yet …"); Membership lists products in one line each (scrolls past ~4) with the two entitlements as single lines; Property closing's deal list scrolls inside the card (`max-h-36`) with "All N deals · scroll the list" under it — the counts above already say how many.
- **Design system of the page:** every block is a `Summary/SummaryCard.vue` (one radius, border, header rhythm) tagged **Record** (what the CRM holds) or **AI** (a model's reading) — the reader must never mistake one for the other. The brief is the one navy hero. CLV's four part colours (#2563eb membership, #0d9488 commission, #9333ea rental, #ea580c renovation) were checked with the dataviz skill's `validate_palette.js` (light surface: all checks pass), follow the part not its rank, and stay clear of the status greens / ambers / reds. Values use text colours, never the series colour.

## 客户档案 — the editable client dossier (added 2026-09-23)

Founder, same day: *"i also need to have this information [the Zoom avatar's 客户档案] … this summary table is editable, so that if sales team wanna edit anything, they can edit. anything manual edit cannot be override by AI in case if there is new update … when i click this summary, i can see the whole profile with all the intelligence."*

- **Format:** exactly the Zoom client-avatar dossier (`Zoom/Avatars/Partials/AvatarDossier.vue`), so a salesperson reads one format everywhere — 姓名 · 地点 · 年龄 · 职业 · 手上房产数 · 为什么现在 · Budget / Cash · 手上资产 · Financing · 决定方式 · 最怕 · 讲话风格, the DISC panel (主 + 辅, 判型证据, 怎么打, 切忌) and Objections（客户原话）with 当场化解 / 未完全化解 and 顾问处理. Rendered by `Partials/Tabs/Summary/DossierCard.vue`, directly under the brief, ALWAYS in full — the format is the form (founder, 2026-09-23: *"where is the original form which can allow to edit"*; an earlier cut collapsed an unwritten dossier to one line and hid the edit pencils behind hover, and the form could not be found). Two ways to edit: the pencil on any row (always visible), or **编辑档案**, which opens every field as one form under a sticky save bar and saves only the fields that changed.
- **Where the AI gets it:** the Summary reading now also returns `dossier` (schema `lead-summary-v2`, one key per `LeadDossierEdit::FIELDS`), merged from the Profile, Overall AND the **client avatars** — up to 3 newest `ZoomClientAvatar` rows for the lead (Zoom meetings and phone calls): persona, summary, outcome and objections, never the advisor scorecard / coaching / CRS (those are about the advisor). Not-known is `null` (the normaliser also turns "unknown" / "未提到" into null) and the card prints its own 未提到 / unknown. Written in Chinese, quotes verbatim.
- **Edits:** `lead_dossier_edits` (`Src\Lead\LeadDossierEdit`, one row per lead + field, `RecordsBlame`), written by `LeadDossierEditRepository`. `PUT summary-insights/fields/{field}` saves, `DELETE` hands the field back to the AI — both `manage-leads`, both refused when the viewer is blocked from a quoted channel. `UpdateRequest` validates by field type (text ≤ 1000, DISC ∈ D/I/S/C, objections list with a required `label`). Clearing a field IS an edit ("we checked; not known") and is stored as null.
- **Never overwritten:** `LeadDossierEdit::merge()` shows the edit's value whatever the AI now says. The edit also stores `ai_value` — what the AI said at the moment of editing — and when the AI's current value differs from it, the card shows **AI 最新：… [采用 AI]** under the field (sky tint). 采用 AI = DELETE. Nothing else replaces an edit.
- **The AI is told the edits.** They go into the prompt as `=== staff edits ===` — "the truth, copy unchanged, never contradict" — so the brief, cross-checks and next move are built on the corrected facts. For the same reason an edit changes the fingerprint and marks the Summary stale (Refresh to rebuild the rest around it).
- **姓名 always has a value:** the CRM record's name (`user_profiles.full_name`, never the email) is handed to the model as `crm_name` and is the AI value's fallback in `aiDossier()`, so the card never prints 未提到 for a person the CRM already knows. (The first live Summary did exactly that: the Profile facts deliberately omit PII and no reading had quoted a name.) The model adds what they are called elsewhere — "Kelly Tan（WhatsApp 自称 Kel）".
- A row that holds two fields (年龄 · 职业, Budget / Cash) saves ONLY the field that changed, so an untouched neighbour stays the AI's.
- **Access:** avatars quote Zoom / phone calls, so their channels join Overall's in `sourceChannels()`; a viewer without `view-zoom` (or `view-calls`) sees `dossier: null` and cannot edit.
- **Merge:** `LeadRepository` keeps the winner's edit per field and moves the loser's edits of other fields; registered in `IdentityChildMap` as MERGE_SPECIAL.

## How it works

- **Storage:** `lead_channel_insights` with `CHANNEL_SUMMARY = 10`, beside every other reading — fingerprint reuse, the Prompt view and the JSON download come from `ServesChannelInsights` unchanged.
- **Schema:** `Src\Conversation\LeadSummary` (`lead-summary-v2`; v2 added `dossier`, see above): `headline`, `brief`, `cross_checks[{topic, kind: conflicts|confirms, profile, said, channel, reading}]` (max 5, conflicts sorted first, a check missing either side is dropped), `approach[{text, source}]` (max 3), `next_move{action, channel, why}`. Source keys are `profile` plus `MasterInsights::CHANNEL_KEYS`. `action_items` is stripped before saving — tasks are the Overall reading's to propose, and one screen must not offer the same step twice.
- **Input:** `LeadSummaryInsightsController::part()` renders `=== profile ===` (`LeadEnrichment::promptFacts()` + up to 5 funnel registrations) and `=== overall · read … ===` (the Overall reading WITHOUT its per-area evidence and proposed tasks — those would triple the prompt and add nothing a summary may say). Never a channel reading, never a transcript.
- **What the Profile sends — and does not.** `LeadEnrichment::promptFacts()` sends conclusions only: never the IP, coordinates, the phone number, raw provider payloads or scraped pages. **The Owner Archive is NOT an input**: it is gated on `view-owner-listing`, and a stored reading is shown to everyone who can open the lead, so an AI sentence built on it would leak it. It stays on the Profile tab only.
- **One model: the pinned OpenAI gpt-6-astra.** `lead_summary` is NOT in `ai.channel_insights.ensemble.prompt_keys`. It was moved onto the routed GLM-5.3 Flash with the channel readings on 2026-09-24 (founder: cost first) and moved straight back the same day (*"then use back gpt 6 astra"*): the same Summary took **153 s on Flash against 29 s on gpt-6-astra** — Flash writes ~14k thinking tokens before answering. Cost on Flash was $0.0079; gpt-6-astra's calls log no cost because the price table does not list it. The screen design is unchanged: render what exists, one AI call for the rest.
- **Fingerprint / staleness:** the source is the Overall reading's own fingerprint + the Profile facts. Re-read Overall, re-run enrichment, or gain a funnel registration and the Summary shows "The Overall reading or the Profile has changed since this summary". Same model / prompt stale reasons as every other reading.
- **Generate is manual** (founder's choice, 2026-09-23 — no auto-queue). When channels have been read but Overall has not, **Generate runs Overall first** (its own POST, ensemble, minutes), then the Summary. If Overall outlasts Cloudflare's ~100 s cut-off the Summary step does not run; the panel says so and the reader presses Generate again. It is refused (422) only when there is neither an Overall reading nor an ENRICHED Profile — attribution alone ("came in via Facebook") would make it a paid paraphrase of one row.
- **Analyse all** runs it as **phase 3**, after Overall (`useAnalyseAll.js`), and lands the page on Intelligence → Summary. It also **re-enriches the Profile** (founder, 2026-09-23: *"when i run analyze all, by right include run enrichment for the profile too"* — the first live Summary, on a never-enriched lead, had no Profile to read). The Profile step runs beside the channels: it POSTs `/manage/leads/{uuid}/enrich` as JSON (`LeadsController::enrich` answers `{queued, attempts}` to an XHR instead of redirecting), then polls this endpoint's `profile` block (`attempts`, `running`, `last_error`) every 5 s for up to 8 minutes until a run NEWER than the one before is no longer pending/processing. Only Summary waits for it; Overall does not read the Profile. Enrichment has no reuse fingerprint, so every Analyse all spends one (web search + AI report). It needs `manage-leads`; without it the step is skipped with the reason. A failed or timed-out enrichment does not stop the Summary — it is written on the Profile as it stands. The page then reloads the `enrichment` prop with `insightReadings`, so *What we found* shows the fresh report.
- **Team discussion (added 2026-09-23 — "summary also need to consider points key in by user at discussion tab"):** the newest 40 `lead_comments` (all topics) go in as `=== team discussion ===`, oldest first, each `{at, by, topic, text}` (plain text, whitespace-collapsed, 800 chars; never `strip_tags` — "budget <500k" would lose everything after the "<"). The prompt treats a dated staff note as a real source (it may beat an OLDER channel statement), cites it as `discussion`, and never quotes a note as the customer's words. A comment written / edited / deleted changes the fingerprint, so the Summary shows stale. A team note alone is enough to generate. The provenance line counts the notes and links to the Discussion tab; a `discussion` source opens it (`openFromSummary({tab})`).
- **Access:** the Summary quotes Overall, which quotes channels. The channels Overall read (its `conversation_ids`) are checked against `LeadChannelInsight::READ_PERMISSIONS`; a viewer missing any gets `blocked`, the Summary AND the Overall half withheld, a 403 on generate and on download — the same rule as Overall's own panel.

## Related files

- [app/Http/Controllers/Manage/Leads/LeadSummaryInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadSummaryInsightsController.php) — show / generate / prompt / download.
- [src/Conversation/LeadSummary.php](/src/Conversation/LeadSummary.php) — the schema.
- [src/Lead/LeadEnrichment.php](/src/Lead/LeadEnrichment.php) — `promptFacts()`.
- [src/Lead/LeadDossierEdit.php](/src/Lead/LeadDossierEdit.php) + [Repositories/LeadDossierEditRepository.php](/src/Lead/Repositories/LeadDossierEditRepository.php) — the hand edits and `merge()`.
- [app/Http/Requests/Manage/Leads/SummaryFields/UpdateRequest.php](/app/Http/Requests/Manage/Leads/SummaryFields/UpdateRequest.php) — per-type validation.
- [resources/js/Pages/Manage/Leads/Partials/Tabs/Summary/DossierCard.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Summary/DossierCard.vue) — the 客户档案 card (+ `DossierCard.test.js`).
- [resources/prompts/lead_summary.md](/resources/prompts/lead_summary.md) — the prompt body.
- [resources/js/Pages/Manage/Leads/Partials/Tabs/SummaryTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/SummaryTab.vue) — the screen (+ `SummaryTab.test.js`).
- [resources/js/Pages/Manage/Leads/Partials/Tabs/Summary/RecordOverview.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Summary/RecordOverview.vue), [ClvBreakdown.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Summary/ClvBreakdown.vue), [SummaryCard.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Summary/SummaryCard.vue) — the record and the page's card.
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `record()`, `withListColumns()`, `listRows()`; [tests/Feature/Lead/LeadRecordTest.php](/tests/Feature/Lead/LeadRecordTest.php).
- [resources/js/composables/useLeadTabs.js](/resources/js/composables/useLeadTabs.js) — `intelligenceTabs`: Summary | Profile | Insight.
- [tests/Feature/Lead/LeadSummaryInsightsTest.php](/tests/Feature/Lead/LeadSummaryInsightsTest.php).
