# Channel Insights · Phone Call

**Context:** Manage · **UI:** Lead → Channel → Phone Call → **Insights** (sub-tabs: **Recordings | Insights**) · **Routes:** `manage.leads.phone-call-insights.*` · Built 2026-09-17 at the founder's request: "phone call channel also needs the same insight as Zoom and WhatsApp, same UI/UX and similar prompts."

> **Model: GLM-5.3 Flash alone since 2026-09-24.** Phone Call ran as a three-model ensemble (GLM-5.3 + GLM-5.3 Flash, reviewed by GLM-5.3 FlashX) from 2026-09-19; the founder cut it to Flash for cost. The controller still calls `usingEnsemble()`, and `config('ai.channel_insights.ensemble')` now holds ONE member (Flash on OpenRouter, BaseTen→Modal) with `merger => null`. See the ensemble notes in [readMe.md](readMe.md).

## What it does

Reads **every recorded phone call** with one person, in time order, and returns the same agent-first reading as [WhatsApp](readMe.md) and [Zoom meetings](zoom.md): brief, do now, still owed, profile, watch-outs and the seven-area decision-readiness evidence — plus a **digest per call**, the **properties discussed** and the **advice given**, the sections a recorded conversation can answer. One reading per lead in `lead_channel_insights` (`CHANNEL_PHONE_CALLS = 4`), analysed only on an explicit click, reused on an exact fingerprint match, with Prompt / JSON / View-in-context exactly like the other channels. Like every reading it also proposes up to 3 `action_items`, filed as pending approvals — see [action-proposals.md](action-proposals.md).

## The one thing that is different: nobody knows who spoke

A Zoom transcript labels a line with the speaker's on-screen NAME, so the customer's own lines can be identified. A phone call cannot do that: it is one mono recording split by voice alone, and the transcriber writes `Speaker A` / `Speaker B` — whoever it heard first. Across the call library on 2026-09-17: 10,796 labelled lines, all of that shape; 40 calls carry no labels at all; no channel, name or diarization id ties a label to the customer.

So this channel never claims a line is the customer's:

- **The prompt does the attribution, out loud.** It says the labels are anonymous, names **our agent** and the direction in the header, and tells the model to work out which voice is ours from what is said (our side introduces the company, asks the questions, explains loans; the customer answers about their own money and family) — and to **take nothing from a passage where it cannot tell**. Our agent's words recorded as the customer's would be worse than a gap.
- **`quote_verified` keeps its exact meaning.** Quotes are checked against `spoken_lines` — EVERY line of the call — so a true `quote_verified` means the words really are in the line it cites, which is checkable. The panel's "not found word for word" warning stays honest.
- **Every readiness item is stamped `speaker_unresolved`** by the server (`RecordingTranscript::flagUnresolvedSpeakers()`), the flag the Customer Journey already uses for evidence whose speaker nobody confirmed. `ReadinessGrid` renders it as a grey **"speaker not confirmed"** chip. Confirming who spoke stays where it belongs — the Journey's own staff speaker mapping, before anything becomes pending evidence.
- **…then the server ASKS who spoke each quote that matters (2026-09-19).** After the flag, `generate` calls [`SpeakerAttribution::resolve()`](/src/Conversation/SpeakerAttribution.php): every unresolved quote that could change a READINESS colour (at most `ai.decisions.speaker_max_items` = 12, each with `speaker_context_lines` = 8 lines around it) goes to **Jev** (`Src\Ai\Services\DecisionClient`, prompt key `speaker_check`) in ONE call. Probability ≥ `ai.decisions.speaker_threshold` (0.8) → the flag is REPLACED by `speaker_ai_customer`; ≤ 0.2 → `speaker_ai_not_customer`; the unsure middle, no Jev key, `AI_DECISIONS_ENABLED=false` or a provider error → it stays `speaker_unresolved`. Only `speaker_ai_customer` may colour a cell (`ReadinessColumns::allows()`). The flag is replaced, never cleared, so the Customer Journey still treats the item as unconfirmed. `ReadinessGrid` draws a chip only for `speaker_unresolved` — the two AI flags show no chip.

## How it works

### The transcript — `Src\Call\Support\CallTranscript` over `Src\Conversation\Support\RecordingTranscript`

`RecordingTranscript` is the **shared** renderer for diarized recordings (Showroom uses it too, with `sf` / "visit"); `CallTranscript` supplies this channel's prefix (`pc`), noun ("call") and the header facts a transcript cannot state:

```
[pc:587-0 · 2026-08-17 06:19 · call 1 of 3 · 4 min · outbound · agent Shawn Tan] (call)
[pc:587-1] Speaker A: Hello, is this Mr Lee?
[pc:587-2] Speaker B: Yes, speaking.
```

- **Source:** the lead's `call_recordings`, oldest first, **excluding `is_ignored`** (a wrong number or a test is not part of the relationship). A call with no transcript still gets its header — it happened.
- **Lines:** only `Speaker <letter/number>` counts as a label; anything else continues the previous speaker (a line starting "0:15 …" is content, not a speaker), and a call with no labels reads as `unknown`. A very long utterance (over 400 characters) is cut into addressable lines at sentence boundaries, keeping its speaker, so a citation points at a sentence.
- **Length** reads in seconds below a minute: many calls are 20-second voicemails, and "0 min" reads as a call that never happened.
- **Long histories** split into parts on line boundaries (`TranscriptParts`, 150k characters) and the `phone_call_insights_part` prompt reads earlier parts into notes. Nothing is trimmed.
- **View:** `GET …/phone-call-insights/messages/pc-{call}-{line}` — that line ± 3, only for this lead's calls, every line rendered with role `unknown`.

### Stale

The set of calls changed, the **spoken-line total** differs from the one the reading was made on, or a call-mined client avatar was mined after the reading. Line count, not `updated_at`: a call row also moves when someone flags a follow-up or an AI analysis lands, neither of which changes a word of what was said. The shared `insightStaleReason()` then says WHY (`stale_reason`: `sources` first, then `model` — the stored model differs from the one the channel runs on now — then `prompt`, an admin edit after the reading) plus `stale_model`, so the panel never tells someone who re-pinned a model that "new lines arrived".

`generate` answers **404** when the lead has no (non-ignored) call and **422** when none has a transcript yet; "Analyse all" reads both as *skipped*.

### Endpoints — `LeadPhoneCallInsightsController`

`show` / `generate` / `prompt` / `download` / `messages/{id}` / `avatars` under `/manage/leads/{id}/phone-call-insights` (the route group also sits behind `permission:` `Permission::viewLeadsAny()`), JSON not Inertia props, sharing `App\Http\Controllers\Concerns\ServesChannelInsights` with every other channel (fingerprint, Prompt view, stale reason, JSON download, stored reading, and `readiness_states` — the lead's READINESS verdict from `ReadinessVerdict::forPage()`, the same one the Leads list shows; `journey` is still sent, always `null`). Input: none (`ChannelInsightsRequest`). Gate: **`view-calls`**, then object-level `LeadVisibility`. Only `generate` calls the provider.

### Prompts

`phone_call_insights` + `phone_call_insights_part` (`resources/prompts/`, registered in `config/ai_prompts.php`, admin-editable on Manage → AI Prompts). They are the Zoom meeting prompts with the speaker section rewritten for anonymous labels, calls in place of meetings, and the readiness field tables **unchanged** — `ChannelInsightsReadinessDriftTest` fails if either prompt stops teaching a registry field. The model: while the ensemble path is available (`ai.channel_insights.ensemble.enabled`, every member has a key, not gateway client mode) the configured member decides — today GLM-5.3 Flash. Only when it is not does the model resolve from this prompt's own pin, else Channel Insights' pin, so one model choice still covers every channel.

### The client avatar, per call (2026-09-17)

"Always have one overall and also show the individual" (founder). Inside Insights, a quiet chip row switches between **All calls** — the combined reading of every transcript (labelled "Overall" until 2026-09-18; that word now means the master reading under Intelligence → Insight) — and **one chip per call**, each showing the client avatar the [Client Avatars](/docs/modules_handbook/manage/zoom/readMe.md) miner read out of THAT call (`AvatarDossier`, the library's own component). Only **call-mined** avatars appear here; the same person's Zoom-mined persona belongs under Channel → Zoom, which is the bug this split fixed — a phone call was reading as a consultation.

- `GET …/phone-call-insights/avatars` returns `recordings` (one per call, newest first: `key` = the call uuid, title, date, minutes, advisor, `avatar_uuid` or **null**), `avatars` (each `ZoomClientAvatar::dossier()`), `outcomes` and `library_url`. Read-only, no provider call, same `view-calls` gate.
- **The chip list is built from the same query the reading uses**, so it can never cover more or fewer calls than the reading did, and a call nobody has mined **keeps its chip** with `avatar_uuid: null` — the view then says it has not been mined instead of the call vanishing.
- The reading itself is told about the avatar: `client_avatar` in THREAD FACTS (`ZoomClientAvatar::promptFacts()` — DISC, how to sell, what backfires, budget, cash, financing, decision maker, primary fear, hidden objections, last outcome). It is **a read of the person, never evidence**: no quote and no readiness item may come from it, and the transcript wins any disagreement. On a call it earns one extra job — the avatar describes the customer, so a voice that matches it is *likely* the customer — but that is a hint for reading, never proof, and never makes a line quotable as theirs.
- **A newly mined avatar makes the reading out of date**: it is part of the fingerprint and of the staleness check, so a persona mined after a reading prompts a re-analyse rather than sitting unseen.

## The UI

`PhoneCallTab.vue` hosts **Recordings | Insights** (`ChannelSubTabs` over `ShowTabs hideStrip`, `?rtab=`, declared as the tab's `queryParams` in `useLeadTabs`), Recordings first and default — it is the record, Insights is a reading of it. The table moved to `Channel/PhoneCallRecordingsPane.vue` unchanged. Insights mounts the shared `ChannelInsightsViews` (`endpoint="phone-call-insights"`, `recording-noun="call"`, this channel's `labels`), which draws the All calls/per-call chip row over `ChannelInsightsPanel` (with its `InsightActionProposals` and `ReadinessGrid`) and `AvatarDossier`. The sub-tabs are hidden for a viewer without `view-calls` — a sub-tab whose endpoint answers 403 is a bug, not a hint. The read-only Lead detail modal shows them too since 2026-09-27 (it passes `leadUuid`; there the strip does not write the host page's URL).

The same reading is also offered under **Intelligence → Insight → Phone Call** (`AllInsightsTab.vue`), from the one list in `composables/useLeadInsightTabs.js` — same component, same endpoint, same `view-calls` gate.

## Related files

- [src/Conversation/Support/RecordingTranscript.php](/src/Conversation/Support/RecordingTranscript.php) — the shared diarized renderer + `flagUnresolvedSpeakers()`; [src/Call/Support/CallTranscript.php](/src/Call/Support/CallTranscript.php) — this channel's prefix, noun and header facts.
- [app/Http/Controllers/Manage/Leads/LeadPhoneCallInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadPhoneCallInsightsController.php)
- Who spoke: [src/Conversation/SpeakerAttribution.php](/src/Conversation/SpeakerAttribution.php), [src/Ai/Services/DecisionClient.php](/src/Ai/Services/DecisionClient.php), `config('ai.decisions')`, [src/Lead/Support/ReadinessColumns.php](/src/Lead/Support/ReadinessColumns.php) (`allows()`).
- Where the calls come from: the [Phone Call module](/docs/modules_handbook/manage/call-history/readMe.md) (`call_recordings` — petaV2 import + the `calls:poll-dowayai` poll, every minute when `DOWAYAI_POLL_ENABLED`; transcription and per-call analysis) and the [Device registry](/docs/modules_handbook/manage/devices/readMe.md) that ties a recording to our agent.
- Prompts: [phone_call_insights.md](/resources/prompts/phone_call_insights.md), [phone_call_insights_part.md](/resources/prompts/phone_call_insights_part.md)
- Frontend: [PhoneCallTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/PhoneCallTab.vue), [Channel/PhoneCallRecordingsPane.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/PhoneCallRecordingsPane.vue), [Channel/ChannelInsightsViews.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsViews.vue) (the Overall/per-call chip row, shared with Zoom), [Channel/ChannelInsightsPanel.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsPanel.vue), [Channel/ReadinessGrid.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ReadinessGrid.vue), [Zoom/Avatars/Partials/AvatarDossier.vue](/resources/js/Pages/Manage/Zoom/Avatars/Partials/AvatarDossier.vue)
- Tests: [tests/Feature/Lead/LeadPhoneCallInsightsTest.php](/tests/Feature/Lead/LeadPhoneCallInsightsTest.php) (reading spends nothing; ignored calls left out; every call rendered with its direction and agent; quotes checked against the cited line and always flagged; reuse and staleness; scoped line context; the view-calls gate; the chip row covering exactly the reading's calls with an un-mined one kept, and the avatar reaching the model as a fact and counting towards re-analysis), [tests/Unit/Conversation/RecordingTranscriptTest.php](/tests/Unit/Conversation/RecordingTranscriptTest.php), [PhoneCallTab.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/PhoneCallTab.test.js), [tests/Feature/Ai/SpeakerCheckTest.php](/tests/Feature/Ai/SpeakerCheckTest.php) (Jev's speaker decision), and the phone-call cases in [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).
