# Channel Insights (Shared · `Src\Conversation\ChannelInsightsAnalyzer`)

**Context:** Manage · **Routes / UI:** `manage.leads.whatsapp-insights.*`, rendered at Lead → **Channel → WhatsApp → Insights** · **Used by:** the Lead Show page (WhatsApp today; the shape is built for every chat channel)

> **Zoom has its own two readings — meetings and webinars.** See [zoom.md](zoom.md): same loop, storage and panel, with the meeting transcript renderer, the counted webinar loyalty and the webinar schema.
>
> **Phone Call has its own reading too.** See [phone-call.md](phone-call.md): same loop, storage and panel, over a DIARIZED recording — its transcript labels speakers only `Speaker A` / `Speaker B`, so no line can be proved to be the customer's and every readiness item is stamped `speaker_unresolved`. The renderer behind it (`Src\Conversation\Support\RecordingTranscript`) is shared by every recording channel.
>
> **AI Caller** ([ai-caller.md](ai-caller.md)) is the one recording channel where who spoke IS known — the voice provider stores the agent's turns apart from the person's — so its quotes are checked against the customer's own turns and nothing is flagged. **Showroom** ([showroom.md](showroom.md)) is the opposite extreme: one microphone in a room, `Speaker A/B/C`, often with family in the conversation, so it behaves exactly like Phone Call and shares its renderer.

> **And there is now a reading of the readings.** See [overall.md](overall.md): Lead → Intelligence → Insight → **Overall** takes every stored reading below as its INPUT (never the transcripts again) and returns what no single channel can see — the cross-channel timeline, where the channels contradict each other, one deduped list of what is still owed, one merged seven-area readiness, and which channel to act on next. Same table (id 9), same trait, same panel grammar. If you add a channel, add it to `MasterInsights::CHANNEL_SOURCES` and its permission to the controller's map, or the master will neither read it nor protect it.
>
> **The Sales tab has a records reading, not a conversation one.** See [Lead → Sales → Insights](/docs/modules_handbook/manage/leads/sales-insights.md): the same loop, storage (`lead_channel_insights`, id 7) and controller trait over the six Sales sub-tabs' RECORDS — it cites records instead of messages, collects no readiness evidence (a record is not a statement), and takes its figures from the server rather than the model.
>
> **So does the Property Portal.** See [Lead → Property Portal → Insights](/docs/modules_handbook/manage/leads/portal-insights.md): the same loop and storage (id 8) over what the person did in the portal BY THEMSELVES — the developments they kept opening, the layouts they priced, the analyses they ran with their own price. Its rule is that every line declares whether it is `behaviour`, `entered` or `typed`, because turning a page view into a preference is how a reading invents the customer's mind.

> **Every reading proposes action items, and a person approves them (2026-09-19).** See [action-proposals.md](action-proposals.md): all nine readings return up to 3 `action_items` (owner, priority, due date), filed as PENDING proposals when the reading is saved and shown on its panel; only an approved one becomes a task on the lead's Action Items tab. A new channel must save through `LeadChannelInsightRepository::saveForLead()` and carry the prompt section, or it proposes nothing.

> **And a Summary above both (2026-09-23).** See [summary.md](summary.md): Lead → Intelligence → **Summary** (first and default) reads the Overall reading together with the PROFILE (enrichment + attribution) — the one input no channel reading has — and returns a headline, a brief, where the Profile confirms or conflicts with what the person said, one next move, and the editable 客户档案 (also reads the mined client avatars; hand edits in `lead_dossier_edits` always win). Everything else on that screen is the Profile and Overall rendered as stored. Same table (id 10), same trait, ONE model (not in the ensemble list). Analyse all runs it as phase 3, after Overall, and re-enriches the Profile beside the channels first so the Summary has one to read.

> **📖 The pipeline, explained for the team: [/manage/leads/analysis-guide](/manage/leads/analysis-guide)** (2026-09-24 — founder: *"produce a page to explain how this analysis work … so that my software team can understand full process"*). Every step of "Analyse all" — objective, what it reads and returns, where it is stored, when it is skipped, the model, the full prompt text, the code, and 30 days of calls / time / cost — with the model, prompts and cost read LIVE (`Src\Lead\Support\AnalysisPipeline::forGuide()`), so it cannot go stale. Linked from the Analyse all dialog ("How this works →") and the Summary tab ("How it works"). **When you add or change a step, update `AnalysisPipeline::STEPS`** — it is the page's narrative.
>
> **⚠️ The ensemble is OFF — every reading is GLM-5.3 Flash alone (2026-09-24).** Founder: *"my biggest concern is cost. so what if we only use flash? no need hybrid anymore. check which analysis use hybrid, do the same."* (Intelligence → Summary is NOT among them: it tried Flash and went back to gpt-6-astra — 153 s against 29 s; see summary.md.) All nine readings that ran as the ensemble (WhatsApp, Zoom meetings, Zoom webinars, phone, AI Caller, showroom, Sales, Portal, Overall — `ensemble.prompt_keys`) now read on ONE model: `config('ai.channel_insights.ensemble')` keeps a single member (Flash on OpenRouter, BaseTen→Modal fp8, `reasoning.enabled`) and `merger => null`. Measured on one Overall reading: GLM-5.3 $0.083 + Flash $0.017 + FlashX review $0.038 = **$0.138 / 399 s** → Flash alone **$0.017 / 158 s** (≈8× cheaper, 2.5× faster). It stays on the member path rather than a plain prompt pin because only a member carries host routing and the thinking switch (`ChannelInsightsAnalyzer::ensembleAvailable()` accepts one member with no reviewer; two members without a reviewer are refused). The stored model now reads "GLM-5.3 Flash", so readings made by the ensemble show as stale on model until re-read. The ensemble code is untouched and `InsightEnsembleTest` pins it with its own config — to switch back, restore the second member + reviewer (the config comment lists both specs). The ensemble paragraph below describes how it works when on.
>
> **Analyse all: 8 at once, with a clock (2026-09-24).** CONCURRENCY went 3 → 8, so every channel reading starts at once (they never read each other); with 3, an ensemble reading held its slot 5–10 min and the rest sat "Waiting" — the founder read it as "not all is running". Each running step shows its elapsed time and what it is doing; the dialog says the run is driven from the page and unstarted steps stop if it is left.
>
> **"Analyse all" runs every reading of a lead in one click (2026-09-19).** A violet button in the lead page header ([`Partials/AnalyseAllButton.vue`](/resources/js/Pages/Manage/Leads/Partials/AnalyseAllButton.vue), rules in [`composables/useAnalyseAll.js`](/resources/js/composables/useAnalyseAll.js)). Founder: *"when click this button, means now run ai for all channels … but need to have sequence … overall insight shall be last."* Phase 1 calls every channel/section reading the viewer can open (the tabs `useLeadInsightTabs` returns) through its OWN generate endpoint, 3 at a time: they read different records and never each other. Phase 2 is Overall, only once every phase-1 step has SETTLED. Each step ends one of five ways: analysed; up to date (the server reused an unchanged fingerprint, no charge); skipped (404/422 means nothing to read, with the endpoint's own sentence); failed (a JSON error); or, after a Cloudflare cut-off (~100 s, no JSON), it re-reads the stored reading every 20 s for up to 8 minutes until its `generated_at` moves. That polling is what makes "Overall waits" true: without it Overall would read a channel still being written. It runs in the browser, so the page must stay open. On finish the page opens Intelligence → Insight and remounts it so every panel shows the fresh reading. No server change: nothing here decides anything an Analyse button does not.

> **A sub-tab that already holds a reading says so (2026-09-20).** Founder: *"if there is analysis insight, the sub tab shall show something like 1"*. The Intelligence → Insight strip ([`ChannelSubTabs.vue`](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelSubTabs.vue)) draws a small green **1** on every tab whose reading is stored, with the date it was read on hover — so nobody opens seven tabs to find out which were analysed. It is one reading per (lead, tab) by design, so the figure is never anything else; it is a has-reading mark, NOT `count` (which stays the RECORDS behind a reading). Data: the `insightReadings` page prop (`tab key => ISO read time`) from `LeadChannelInsight::readTabsFor()` — one query, never the `ai_insights` json — sent by both `LeadsController@show` and `@quick`; `useLeadInsightTabs` turns it into each tab's `analysedAt`. After *Analyse all* finishes, `Show.vue` reloads just that prop. A reading generated from inside ONE panel does not refresh the mark until the next page load.

> **EVERY channel reads as an ensemble since 2026-09-19 (later the same day)** — founder: *"now implement all like this for all channels to use synergy method"*. `ensemble.prompt_keys` lists all nine readings (WhatsApp, Zoom meetings, Zoom webinars, phone, AI Caller, showroom, Sales, Portal, Overall); every controller calls `usingEnsemble()` and sets its PHP limit from `timeBudget()`. For Overall the "source" the reviewer checks against is the channel readings it combines. Measured cost per reading (USD, ensemble): Zoom ≈ 0.22, phone ≈ 0.09, WhatsApp ≈ 0.06–0.11, webinar / portal / AI Caller ≈ 0.03–0.04, Sales ≈ 0.02, Overall ≈ 0.10. Verified readiness quotes now also feed the Leads list's READINESS band as amber (see the Leads handbook).
>
> **Phone and showroom readings now settle WHO SPOKE each readiness quote (2026-09-19).** Those two channels are transcribed from one microphone, so their speakers are only "Speaker A"/"B" and `verifyEvidence` checks a quote against every line — our own agent's words included. After verification the controller calls [`SpeakerAttribution`](/src/Conversation/SpeakerAttribution.php), which asks **Jev** (a decision model — typed questions, calibrated probabilities, no prose; {@see \Src\Ai\Services\DecisionClient}) one question per quote in a single call, with the surrounding turns as context. ≥ 0.8 → `speaker_ai_customer`, ≤ 0.2 → `speaker_ai_not_customer`, the unsure middle keeps `speaker_unresolved`. Only the first may colour a readiness cell on the Leads list. Fail-soft everywhere: no key or a provider error leaves the reading untouched. See the Leads handbook's READINESS section.
>
> **Zoom meetings and phone calls read as an ENSEMBLE (2026-09-19).** Founder: *"zoom meeting and phone call, need to use synergy method: 5.3 (parasail) + flash > flashx to combine."* Two independent readings of the same records — **GLM-5.3** on OpenRouter pinned to Parasail→Fireworks with `reasoning.effort: high`, and **GLM-5.3 Flash** on OpenRouter pinned to BaseTen→Modal (fp8 only, no other fallbacks) with `reasoning.enabled` — moved off Z.ai's own endpoint 2026-09-24: replaying the same two slow readings, Z.ai direct took 344 s / 556 s and BaseTen 69–88 s / 128 s with the same schema and evidence (Z.ai's endpoint runs ~23 tok/s, BaseTen ~109); BaseTen 429'd one of two concurrent calls once, hence Modal — then **GLM-5.3 FlashX** reviews both against the source under `channel_insights_merge` (appended to that channel's own prompt, so the schema is the channel's) and returns one. Configured in `config('ai.channel_insights.ensemble')` (members, reviewer, per-call timeouts, which prompt keys); `ChannelInsightsAnalyzer::usingEnsemble()` turns it on only when every model has a key and never in gateway client mode, so a missing key falls back to the single pinned model. Degrades instead of failing: one member failing → the other's reading; an unreadable review → the first member's. Members run one after another (6–12 min for a long Zoom), so the per-call ceiling is raised for that process only and `timeBudget()` sets the PHP limit; the panel and "Analyse all" re-read the stored reading every 30/20 s for up to 20 min after the proxy's ~100 s cut-off. The stored model reads "GLM-5.3 + GLM-5.3 Flash → GLM-5.3 FlashX"; the Prompt modal shows the ensemble instead of a picker. Why these three: a 25-model comparison on real Zoom / call / WhatsApp histories (the review kept 24/24 verified quotes on the Zoom test and dropped a fabrication one member made). Pinned by `tests/Feature/Ai/InsightEnsembleTest.php`.
>
> **Identity numbers are masked before ANY insight reading is sent** (every channel): twelve-digit ICs via the Customer Journey's `ExtractionRedactor`, except numbers in international format (`+60 …`), which are phone lines — every WhatsApp line carries ours.

## What it does
Reads **a whole messaging thread** and tells the agent handling that person what is going on in it: where they stand, what they asked for, what was promised and not delivered, what is at risk, and the message to send next.

It is the **chat** counterpart of [Conversation Analysis](/docs/modules_handbook/shared/conversation-analysis/readMe.md), which reads **recordings** (Zoom, phone calls, showroom visits). The two are deliberately separate, and the reason is not tidiness:

- A recording is ONE bounded conversation with a beginning and an end, and the `conversation_analysis` schema scores the agent's performance across it, fills a capability radar and writes a meeting report.
- A WhatsApp thread is months of asynchronous exchanges, often four messages long, frequently with our side silent for weeks. Run through the meeting schema it produces a sales score and a "meeting report" for something that was never a meeting — confident output about an object that does not exist.

So Channel Insights asks only what a chat can answer, and **does not score anybody**.

## How it works

**One reading per lead — every number, read together (2026-09-17).** A person often talks to us on more than one company WhatsApp number (Support Team, Client Success, …), and each is its own thread. Insights reads ALL of them merged into ONE conversation in time order, every line tagged with its number (`ConversationTranscript::renderMany()`), and stores one reading per lead. It is one relationship: a question asked on one line and answered on another only makes sense read together, and the merged reading already contains everything a per-number reading would. The first version also analysed each number separately — the founder called that out as duplicate cost (four astra runs where one reads everything), so the panel's **number chips now FILTER the one reading** by the number each cited message came from; they never analyse again.

**The transcript — `Src\Whatsapp\Support\ConversationTranscript`.** Walks **every** message in the thread (chunked), oldest first, rendering `[date time] Customer: …` / `[date time] Agent (Name): …`. This is NOT `LeadConversationPresenter`, which returns the last 30 messages for a screen: an analysis built on the last 30 messages of a two-year thread is confidently wrong about how the relationship started, which is the thing an admin opens Insights to learn. **Nothing is ever left out.** A thread longer than one prompt should carry (`PART_CHAR_BUDGET`, 150k characters ≈ 1,500 ordinary lines) is split into consecutive **parts** on message boundaries — every message in exactly one part, and a single message longer than the budget becomes its own part rather than being cut. `render()` returns `parts` (`{text, messages, first_at, last_at}`) beside the full `text`. The budget is about latency and reading quality, not the context window: one request over a huge thread is slow, and a model skims the middle of a very long input.

> **Why not trim?** The first version kept the start and the end of a long thread and dropped the middle. The founder rejected it on 2026-09-17: the middle is where a deal is usually won or lost (the viewing, the objection, the promise nobody kept), and an analysis that has not read it is not an analysis of the conversation. Do not reintroduce a head/tail cut here. A message whose meaning is not text (a voice note, an image, a call) renders as a bracketed note, so a thread of photos does not analyse as silence.

**The call — `Src\Conversation\ChannelInsightsAnalyzer`.** `analyze($parts, $context)` makes **one request per part**. A one-part thread (almost all of them) is a single request under the registered prompt key **`channel_insights`**. For N parts, parts 1…N-1 are each read under **`channel_insights_part`** (body: [`resources/prompts/channel_insights_part.md`](/resources/prompts/channel_insights_part.md)) into dated **notes** — events, customer facts, requests, objections, promises and their status, what was left unanswered — and the final `channel_insights` call reads `<EARLIER_CONVERSATION_NOTES>` plus part N **verbatim**. The latest messages stay raw because they decide the reply (its language, its tone, whether the customer is still waiting on us). Every request of one reading runs on the SAME provider/model/key, resolved from the `channel_insights` pin; a part that fails **fails the whole analysis** (nothing is written) rather than producing a reading that silently skipped it. The largest thread on wk (9,786 messages, ~416k characters of text) is about 5–6 parts. The main call's body is [`resources/prompts/channel_insights.md`](/resources/prompts/channel_insights.md); both prompts are admin-editable on Manage → AI Prompts. Every request — each part too, with `meta.part` / `meta.parts` — lands in `ai_requests` with its lead, provider, model, tokens and cost like any other AI call here. Provider/model resolve pin → `config('ai.channel_insights.*')` → catalog default.

> **`config('ai.channel_insights.model')` is null on purpose.** Null means "the provider's current catalog default", so the feature follows Gemini's newest model as `config/ai.php` is updated, instead of freezing on whatever was newest the day it shipped.

Unlike its recording sibling it **fails soft** (an `ok`/`error` array, never an exception): it runs inside the request an admin is waiting on, not in a retrying queue job.

**The transcript is untrusted input.** Every line was typed by a customer or an agent. It is quoted inside `<CONVERSATION_TRANSCRIPT>` tags, the delimiters are neutralised in the content (so a message containing the closing tag cannot forge the end of the quoted block), and the turn states that everything inside is data. Same boundary the [AI Sales Coach](/app/Http/Controllers/Manage/Leads/LeadSalesCoachController.php) uses, for the same reason.

**Decision readiness — evidence for the Customer Journey's seven areas, never a score (2026-09-17).** The founder asked for the reading to feed the readiness view on Manage → AI Copilot → Work (Need, Relationship, Understanding, Loan, Cash, Decision-maker, Property fit). That view decides readiness with deterministic rules over **staff-confirmed evidence** (`src/RevenueJourney`, `config/readiness.php`); an AI score would be exactly the invented number that module refuses. So `readiness` carries its INPUT instead — per area `{evidence, observations, missing, ask_next}`:

- **`evidence`** items are shaped like the Journey's own extraction proposals — `field_key`, typed `value` (per the registry's value contract), a **verbatim `quote`**, the **`message_id`** it came from, `basis` / `polarity` / `modality` / `subject_role`, `confidence`, `interpretation_flags`, `superseded` — for only the fields in `ChannelInsights::READINESS_FIELDS`: the registry's AI-proposable fields that need **no system reference** (Need directly; `reported_*` for every purchase-specific area — no funding pool, party, route or property record to cite from a chat). That is the same set the Journey's whole-source analysis allows.
- **`observations`** are notes, never values, for what only a system measurement or an advisor can establish (a real two-way exchange, a live session, a dated next step; teach-backs).
- **`missing`** may name only the area's `required_for_ready` fields (`READINESS_REQUIRED`); **`ask_next`** is the question that fills the most important one, and it also steers `next_best_actions` / `suggested_reply`.
- The clamp in `ChannelInsights::readiness()` keeps the seven areas in order, drops any other key (scores, levels) and any item without a proposable field, a quote, a message id and an object value. `verifyEvidence()` then marks each item `quote_verified` — the cited message must be one the customer personally wrote (not staff/automation, not forwarded, not deleted) and contain the quote, case and whitespace aside. The panel flags an unverified quote.
- **Not wired into the Journey — and as of 2026-09-20 not going to be.** Nothing here writes Journey evidence, and the founder has since ruled the Work page out as a source or a destination for anything on a lead page (see `readiness_states` below). What follows is the abandoned plan, kept so nobody re-derives it: `message_id` → the Journey source event → `SourcePacketLoader` → `ExtractionValidator` → pending evidence (≤12 per request) → staff confirmation. The Journey module was uncommitted work when this shipped, so Insights MIRRORS its registry (`journey-fields-v2-2026-09-17`) rather than reading `config/readiness.php`; `ChannelInsightsReadinessDriftTest` fails when they drift and skips where that config is absent.
- **Transcript lines changed for this:** `[wa:{id} · date time · number] customer|staff (Name)|automation: …`. `automation` = outbound with an AI/broadcast/flow/campaign/template/appointment/funnel/system `meta` flag or a template message (the Journey adapter's rule); `[forwarded]` and `[deleted by the sender]` are marked. `render()` also returns `customer_messages` (id → text the customer wrote), the set quotes are checked against. For a long thread, each part's notes carry `readiness_evidence` extracted from the RAW part — a summary cannot give back a verbatim quote — and the final call carries it forward unchanged.

**The schema — `Src\Conversation\ChannelInsights` (`channel-insights-v3`).** A **bounded passthrough**: the prompt is admin-editable, so a section it adds survives (the panel folds it into "More from this reading"), while every block the panel renders by name is clamped here and can never arrive in a shape the page cannot render. **v3 is short by design** — the v2 reading of Ryan listed 37 commitments (14 long kept), 8 risks and 9 requirements, and the founder found it unreadable:

| Key | Shape | Cap |
|---|---|---|
| `headline`, `summary`, `sentiment`, `engagement`, `buying_stage`, `suggested_reply` | as before | — |
| `next_best_actions` | `{action, why, priority, message_id}` | 3 |
| `open_commitments` | `{who: us\|customer, what, since, due, status: open\|missed, message_id}` — only what is STILL owed | 5 |
| `commitment_history` | the same, `status: kept\|missed` — the most recent closed ones | 10 |
| `customer_profile` | only `PROFILE_FIELDS` (purpose, budget, timing, financing, locations, property_type, occupation, family, language, other), each a short string | — |
| `risks`, `opportunities` | `{text, message_id}` | 3 each |
| `objections` | unresolved only, `{objection, evidence, message_id}` | 3 |
| `requirements` | short strings | 5 |
| `readiness` | seven areas, below | per area |

Every item that can cite a message carries `message_id` (`wa:{id}`, validated), which is what makes "View message" and the number filter possible. `citedMessageIds()` collects them. Depth, node count and leaf length are bounded too.

**Storage — per lead.** The reading lives in **`lead_channel_insights`** (`Src\Lead\LeadChannelInsight`, one row per lead + `CHANNEL_WHATSAPP`, written only by `LeadChannelInsightRepository::saveForLead()`), with **`conversation_ids`** recording which threads it read. Thread visibility is per LEAD, not per thread (`LeadVisibility::applyToConversations`), so everyone who passes the gate sees the same set — which is what makes one shared row safe. (`whatsapp_conversations.ai_insights*` held per-number readings in the first version; those columns are **retired** — nothing writes them, and `WhatsappRepository::saveInsights()` is gone. Drop them in a later migration.) Caching is not an optimisation detail: a generation is a paid call over the entire thread, and two admins opening the same lead an hour apart are asking the same question.

- **`ai_insights_hash`** fingerprints what the reading was produced FROM: the transcript, the provider + model, both system prompts as they resolve now, and the schema version (`fingerprint()` in the controller). Re-analyse returns the stored reading (`reused: true`) only on an exact match — which is what makes the button safe to press twice — and runs again when ANY of those changed: a new message, a re-pinned model, an admin's prompt edit, a new schema. (It first covered only transcript + model; after the v3 prompt shipped, Re-analyse kept handing back the v2 reading.)
- **`stale`** is computed the cheap way (any thread's `last_activity_at > ai_insights_at`, or *the set of threads changed* — a reading of two numbers is not a reading of three), because the panel asks on every open and the exact answer costs a full walk of the thread. When it is true the panel says so above the result rather than presenting an old reading as today's.

**The endpoints — `LeadWhatsappInsightsController`**, all under `/manage/leads/{id}/whatsapp-insights`, JSON not Inertia props (the panel sits three tab levels inside a very large `LeadsController@show`; same precedent as `LeadsController@quick` and the Sales Coach). They take no input (`ChannelInsightsRequest` has no rules; shared with the Zoom readings) and re-authorize every request: `view-whatsapp`, then object-level `LeadVisibility`, then the thread list comes from `LeadConversationPresenter::baseQuery()` — the **same** query the tab uses, so the group-thread and sandbox exclusions cannot drift apart from it.

| Route | What | Provider call? |
|---|---|---|
| `GET /` (`.show`) | threads, the stored reading, `stale`, `message_numbers` (cited `wa:id` → thread uuid, for the filter), `contact`, `readiness_states` (`journey` is still sent, always `null`, for the bundle deployed before 2026-09-20) | never |
| `POST /` (`.generate`) | reads every message on every number and analyses it; reuse when transcript hash AND model match | **yes** |
| `GET /prompt` (`.prompt`) | the full prompt as it resolves now | never |
| `GET /download` (`.download`) | the stored reading as a `.json` attachment | never |
| `GET /messages/{messageId}` (`.message`) | a cited message ± 3 messages, rendered by `ConversationTranscript::describe()` exactly as the model read them; only messages in this lead's scoped threads (else 404) | never |

- **`contact`** — who is waiting on whom, computed from the messages, not asked of the model: the customer's latest inbound message against the latest reply a PERSON on our side sent (automation, templates and failed sends are not replies). `{waiting_on: us|customer, since, days, number}`. Free, exact, and true even when the reading is stale.
- **`readiness_states`** — `{ areas: { need: { state, why, source, read_at }, … }, states: ReadinessVerdict::STATES }`: this lead's seven READINESS states and the fact that decided each, from `ServesChannelInsights::readinessStates()` → **`ReadinessVerdict::forPage()`, the SAME call the Leads list's READINESS band makes** (the VERDICT — the Overall reading's final state per area, "Not analysed" without one; see [manage/leads/readiness-verdict.md](../../manage/leads/readiness-verdict.md)) — so a tile can never show a colour the list does not. **The Customer Journey / Work page is no longer read anywhere on a lead page (founder, 2026-09-20: *"both has to be sync … dun connect anything from here to the customer journey work page findings, that side might not be used"*).** Until then this key was `journey`: the state came from the latest `revenue_queue_snapshots` row, whose rules count only STAFF-CONFIRMED evidence — so a lead the list showed four greens for (1,398 webinar minutes, a financial profile, a booked unit) read "Unknown" on five tiles, simply because nobody had clicked Confirm on the Work page. `journeyStates()`, the *Confirm on Work page* link and `JOURNEY_STATES` are deleted; the tiles' link now opens the list's own rule book (`/manage/leads/readiness`). Do not reconnect it.

## The UI
Lead → Channel → WhatsApp has two sub-tabs: **Inbox** (the messages — the tab's original body, unchanged, and the default: it is the record) and **Insights** (a reading of it).

**The Insights panel, most urgent first (redesigned 2026-09-17** after the founder found the first layout — equal-weight sections, a generic JSON-style dump of commitments and profile — not professional):

1. **Toolbar** — provenance (analysed when, how many messages, all N numbers, model) and **Prompt · JSON · Analyse/Re-analyse**. Below it, when there are several numbers, the **number chips** (All + each line), which filter the reading and say so.
2. **Brief** — headline, stage / engagement / sentiment chips, the computed **waiting chip** ("Customer waiting on us · 32 days · Client Success"), the summary.
3. **Profile** (labelled facts + what they asked for) beside **Watch-outs** (risks, unresolved objections, opportunities) — right under the brief; it sat at the bottom first and the founder moved it up (2026-09-18).
4. **Do now** (≤3 actions, priority dot, why, View message) beside **Suggested reply** (Copy, Open Inbox — which switches the sub-tab).
5. **Still owed** — open commitments only, each with who (Us / Customer), raised/due, an Overdue or Open badge with days, View message; the recent history folds behind "Show recent history (k kept, m missed)".
6. **Decision readiness** — `ReadinessGrid.vue`: seven tiles (the LIST's own state — Ready / Signal / No signal — from `readiness_states`, with the deciding fact printed on the open area; "n quoted · m missing" always), the chosen area's evidence beneath with verbatim quotes, flags, unverified-quote warnings, still-needed fields and the question to ask, and "Confirm on Work page" (opens in a new tab, so the lead stays open).
7. **More from this reading** — collapsed; only sections an edited prompt adds.

Every cited item opens **`MessageContextModal.vue`**. A slow model (gpt-6-astra took ~120s on 427 messages) outlasts Cloudflare's ~100s: a generate that fails WITHOUT our JSON error is shown as "still finishing on the server" and the panel re-reads the stored state a minute later; a real failure (our 503 with a message) is shown as an error.

This is the page's **third** tab level, so the control is `Partials/Tabs/Channel/ChannelSubTabs.vue` — a **segmented control** (grey track, the active option a raised white tile, `text-sm px-5 py-2`) — rather than a third row of pills. It started as small text-only chips and the founder found them too small to notice (2026-09-17); the segmented shape keeps it distinct from the two levels above while being sized like a real control. Not a third row of pills under the Channel pills, which would leave the reader with three identical controls and no way to tell which is the parent (GUIDELINES §15). `ShowTabs` still owns the behaviour underneath via `hideStrip`: only the open panel is mounted, and the sub-tab syncs to **`?vtab=`**. WhatsApp declares `queryParams: ['vtab']` in `useLeadTabs`, so moving to a channel without that level drops the param instead of carrying it into a copied link.

**Prompt view (2026-09-17).** A **Prompt** button beside Analyse opens `InsightsPromptModal.vue`, which fetches `GET …/whatsapp-insights/prompt` only when opened: the provider + model in use, both system prompts **as they resolve right now** (`AiRequest::promptSystem()` — an admin's edit on Manage → AI Prompts, flagged as edited, else the code default), and the generated user turn built by `ChannelInsightsAnalyzer::previewUserMessage()` — the same `userMessage()` a real run uses, with a placeholder conversation, in both the one-part and long-conversation shape. Same gate as the panel (`view-whatsapp` + lead visibility); never calls the provider. Viewers with `view-integrations` also get an "Edit on AI Prompts" link.

**JSON download (2026-09-17).** A **JSON** link (shown once a reading exists) downloads `GET …/whatsapp-insights/download` — the lead's stored reading, exactly what the panel renders (after the clamp and quote check), wrapped with `lead`, `numbers`, `model`, `analysed_at`, `analysed_messages`, `stale` and `exported_at`, as `insights-{lead-name}-{date}.json`. A plain `<a href>` (GUIDELINES §13), same gate as the panel, 404 when nothing is stored, never calls the provider. The model's raw reply before the clamp lives on the `ai_requests` row (Manage → Integrations → AI).

The **Lead detail modal** renders the same two sub-tabs since 2026-09-27: the reading is a JSON read keyed on the lead's uuid, which the modal now passes (until then it withheld `leadUuid` and showed the Inbox alone). Its inner strip passes `:sync-url="!readonly"`, so `?vtab=` is never written onto the host page's address bar.

## Reference usage
The canonical consumer is **Lead → Channel → WhatsApp → Insights**, and the whole call is three steps — render, analyse, persist:

```php
use Src\Conversation\ChannelInsights;
use Src\Conversation\ChannelInsightsAnalyzer;
use Src\Lead\LeadChannelInsight;
use Src\Lead\Repositories\LeadChannelInsightRepository;
use Src\Whatsapp\Support\ConversationTranscript;

public function __construct(
    protected ChannelInsightsAnalyzer $analyzer,
    protected LeadChannelInsightRepository $leadInsights,
) {}

// Every thread of the lead, merged in time order and tagged by number; split into parts when long.
$transcript = ConversationTranscript::renderMany($threads);

set_time_limit(max(300, 250 * count($transcript['parts'])));   // one request per part, up to 240s each

$result = $this->analyzer->analyze($transcript['parts'], [   // EVERY part — never a trimmed text
    'subject' => $lead,              // ai_requests attribution
    'lead_id' => $lead->id,
    'channel' => 'WhatsApp',         // named in the prompt's first line
    'meta' => [                      // what the transcript itself cannot say
        'messages_total' => $transcript['message_count'],
        'read_in_parts' => count($transcript['parts']),
        'first_message_at' => $transcript['first_at'],
        'last_message_at' => $transcript['last_at'],
    ],
]);

if (! $result['ok']) {
    return response()->json(['message' => $result['error']], 503);   // fails soft — nothing was written
}

// One row per lead + channel, with the transcript hash and the threads it read.
$this->leadInsights->saveForLead($lead, LeadChannelInsight::CHANNEL_WHATSAPP, ['lead_channel_insight' => [
    'conversation_ids' => $threads->pluck('id')->sort()->values()->all(),
    'ai_insights' => ChannelInsights::verifyEvidence($result['insights'], $transcript['customer_messages']),
    'ai_insights_model' => $result['model'],
    'ai_insights_hash' => $transcript['hash'],
    'ai_insights_count' => $transcript['message_count'],
]]);
```

`isConfigured()` answers whether a key exists before you offer a button; `provider()` / `model()` say what a generation will run on. Read a stored result back through `ChannelInsights::headline()` / `actions()` / `suggestedReply()` rather than by array path — the pinned accessors are what survive a prompt edit.

Two things NOT to do: do not hand it `LeadConversationPresenter::forLead()` output (that is the last 30 messages, formatted for a screen), and do not call it on page load — a generation is an explicit, user-initiated action.

## Extending it to another channel
The analyzer, the schema, the prompt and the panel are channel-agnostic; only the transcript and the storage are WhatsApp-specific. A second channel needs: a transcript renderer for its messages, somewhere to persist the result, and a controller pair — then it mounts the same `ChannelInsightsPanel`.

Zoom meetings (2026-09-18), Phone Call, AI Caller and Showroom (all 2026-09-17) now have one; the question they raised first was whether a second reading is worth it, since each recording already carries a [Conversation Analysis](/docs/modules_handbook/shared/conversation-analysis/readMe.md). It is a different question: Conversation Analysis scores ONE conversation and reports on it, while Insights reads EVERY conversation with that person together and tells the agent where the relationship stands. Neither replaces the other.

**What a new channel actually has to decide is who spoke**, because that is what readiness evidence rests on, and the five channels answer it three different ways: a WhatsApp message and an [AI call](ai-caller.md) turn carry their author (the platform stores it), a [Zoom](zoom.md) line carries a display NAME that can be matched against the lead's own, and a [phone call](phone-call.md) or [showroom](showroom.md) recording carries nothing at all — there, the model attributes and every item is stamped `speaker_unresolved`. Pick the strongest answer the channel can actually support, and say so in the prompt; do not let a reading imply more than the source knows.

## Related files

**Backend**
- [src/Conversation/ChannelInsightsAnalyzer.php](/src/Conversation/ChannelInsightsAnalyzer.php) — the AiClient call, provider/model resolution, the untrusted-transcript boundary.
- [src/Conversation/ChannelInsights.php](/src/Conversation/ChannelInsights.php) — the schema: vocabularies, caps, `normalize()`, the named accessors, and the readiness mirror (`READINESS_FIELDS` / `READINESS_REQUIRED`, `readiness()`, `verifyEvidence()`).
- [src/Whatsapp/Support/ConversationTranscript.php](/src/Whatsapp/Support/ConversationTranscript.php) — the whole thread as text, split into consecutive parts when longer than one prompt (never trimmed).
- [app/Http/Controllers/Manage/Leads/LeadWhatsappInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadWhatsappInsightsController.php) — read / generate, with every gate.
- [app/Http/Requests/Manage/Leads/ChannelInsightsRequest.php](/app/Http/Requests/Manage/Leads/ChannelInsightsRequest.php) — no input; every Insights endpoint (WhatsApp, Zoom) takes none.
- [app/Http/Controllers/Concerns/ServesChannelInsights.php](/app/Http/Controllers/Concerns/ServesChannelInsights.php) — shared by the three controllers: fingerprint, Prompt view, JSON download, stored reading, the lead's READINESS states (`readinessStates()` — the list's own, never the Journey's).
- [zoom.md](zoom.md) — the Zoom meeting and webinar readings.
- [overall.md](overall.md) — the master reading, whose input is all the others.
- [summary.md](summary.md) — Intelligence → Summary: Overall + the Profile, read together.
- [revenue-intelligence-design.md](revenue-intelligence-design.md) — the 2026-09-24 design note for the CTO: what exists, the gaps (entity linking, SOP capture, playbook) and the A/B/C phases; the playbook rule table is for the sales team to fill.
- [action-proposals.md](action-proposals.md) — the action items every reading proposes, and their approval into tasks.
- [src/Lead/LeadChannelInsight.php](/src/Lead/LeadChannelInsight.php) + [src/Lead/Repositories/LeadChannelInsightRepository.php](/src/Lead/Repositories/LeadChannelInsightRepository.php) — the reading, one row per lead + channel; the repository is the only writer.

**Choosing the model, from the Prompt modal (2026-09-18).** Founder: *"I want the ai model to be available to choose so that I can choose any other ai model like Gemini Kimi Claude … make this option available under prompt setting pop out."* Every Insights panel's **Prompt** button now opens a picker over the same per-prompt PIN the AI Prompts page writes (`PUT manage/integrations/ai/prompts/{key}/model`, which answers JSON to an XHR so the reader is not walked off the lead) — one mechanism, two doors. It has TWO SCOPES because the pin does: **this reading only** (its own prompt key) or **every channel reading** (the shared `channel_insights` key that every unpinned channel falls back to, per `ChannelInsightsAnalyzer::model()`), and the modal says which is currently in force. The server decides what is offered — `ServesChannelInsights::insightModelChoice()` returns no picker in gateway CLIENT mode (the Hub picks the model), for a non-pinnable key, or without `manage-integrations` — so no panel has to know those rules. Every chat provider with a catalog is listed, an unconfigured one flagged *"no API key yet"* rather than hidden, because "add the key" is a better answer than a provider that silently is not there. Changing the model changes the reading's **fingerprint**, so existing readings show as out of date — the modal says so, since they were read by a different model.

**"Out of date" has to name what changed (2026-09-18).** A reading is reused only on an exact fingerprint — records + provider + model + every prompt body + schema — so a re-pinned model makes the stored one a reading of something else. Two bugs came out of that on the same day: the channel panels checked only the RECORDS, so a model change left an old reading presented as current; and the warning said *"new messages have arrived"*, which is a lie when what changed is a model the reader just picked in the Prompt modal above it. `ServesChannelInsights::insightStaleReason()` now returns **`sources` | `model` | `prompt` | null** — the model from what the reading RECORDS it ran on (exact, and it needs no transcript re-render on every open), the prompt from an admin edit dated after the reading — and the panels print the matching sentence, naming the old model. A code-default prompt change (a deploy) is not caught: nothing on the row remembers which body was used; the fingerprint still refuses to reuse it on the next Analyse.

**Prompt & config**
- [resources/prompts/channel_insights.md](/resources/prompts/channel_insights.md) — the prompt body; registered in [config/ai_prompts.php](/config/ai_prompts.php).
- [resources/prompts/channel_insights_part.md](/resources/prompts/channel_insights_part.md) — `channel_insights_part`: reads one earlier part of a long thread into dated notes for the final reading.

**Frontend**
- [resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/) — `WhatsappTab.vue` (the host), `WhatsappInboxPane.vue` (the old body), `ChannelSubTabs.vue`, `ChannelInsightsPanel.vue` (the layout above), `ReadinessGrid.vue` (seven tiles + the chosen area's evidence), `MessageContextModal.vue` (View message), `InsightsPromptModal.vue` (the full prompt); display helpers in [resources/js/utils/readinessEvidence.js](/resources/js/utils/readinessEvidence.js).

**Migration**
- [database/migrations/2026_09_17_100000_add_ai_insights_to_whatsapp_conversations.php](/database/migrations/2026_09_17_100000_add_ai_insights_to_whatsapp_conversations.php) — the `ai_insights*` columns on `whatsapp_conversations`.
- [database/migrations/2026_09_17_150000_create_lead_channel_insights_table.php](/database/migrations/2026_09_17_150000_create_lead_channel_insights_table.php) — `lead_channel_insights`, the reading.

**Routes**
- `routes/web.php` — `GET|POST /manage/leads/{id}/whatsapp-insights` (`manage.leads.whatsapp-insights.show|generate`) `GET …/whatsapp-insights/prompt` (`.prompt`) and `GET …/whatsapp-insights/download` (`.download`).

**Tests**
- [tests/Feature/Lead/LeadWhatsappInsightsTest.php](/tests/Feature/Lead/LeadWhatsappInsightsTest.php) — reading spends nothing; one reading stored per lead; an unchanged thread on the same model is not paid for twice, a different model runs again; group/sandbox threads never offered (nor openable via View message); the transcript covers everything and splits without dropping a message; lines carry message id + role; quotes verified; who is waiting; cited messages mapped to their number; message context scoped to the lead; prompt + JSON download.
- [tests/Unit/Conversation/ChannelInsightsSchemaTest.php](/tests/Unit/Conversation/ChannelInsightsSchemaTest.php) — v3 caps and shapes: open commitments vs history, the fixed profile, capped cited lists, `citedMessageIds()`.
- [tests/Unit/Conversation/ChannelInsightsReadinessTest.php](/tests/Unit/Conversation/ChannelInsightsReadinessTest.php) — the readiness clamp (areas, fields, missing, no scores) and quote verification.
- [tests/Feature/Lead/ChannelInsightsReadinessDriftTest.php](/tests/Feature/Lead/ChannelInsightsReadinessDriftTest.php) — the mirror matches `config/readiness.php` and both prompts teach every field (skips where the registry is absent).
- [tests/Feature/Lead/ChannelInsightsAnalyzerTest.php](/tests/Feature/Lead/ChannelInsightsAnalyzerTest.php) — every part read in order on one model with a 240s timeout; a failed part stops the reading; notes cannot close their quoted block.
- [resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsPanel.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsPanel.test.js) + [ReadinessGrid.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ReadinessGrid.test.js) + [WhatsappTab.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/WhatsappTab.test.js) — opening never generates; brief / do now / still owed lead, history folds; the number chips filter without analysing; stale and still-finishing states; View message and Prompt fetch on demand; seven tiles with the Work state beside quoted evidence.
