# 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.

## 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 yet.** Nothing here writes Journey evidence. The next step is: `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`, `journey` | 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.
- **`journey`** — the Customer Journey's current state per area (`ready` / `developing` / `unknown` / …, plus confirmed and awaiting-review counts) read from the latest `revenue_queue_snapshots` row, and the Work page URL for the lead's intake/opportunity. Fail-soft and optional: null when the table or the `Permission::VIEW_JOURNEY` constant does not exist (that module was uncommitted when this shipped — checked with `defined()`, never referenced directly), when the viewer lacks that permission, or when there is no snapshot.

## 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 Work page's state when `journey` is present, "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 read-only **Lead detail modal** renders the WhatsApp tab without a `leadUuid` and therefore without the sub-tabs — it has no page of its own to hang the endpoints off, so there it stays exactly what it was.

## 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, journey states.
- [zoom.md](zoom.md) — the Zoom meeting and webinar readings.
- [overall.md](overall.md) — the master reading, whose input is all the others.
- [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.
