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

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

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

## 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`.
- **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, or the **spoken-line total** differs from the one the reading was made on. 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.

### Endpoints — `LeadPhoneCallInsightsController`

`show` / `generate` / `prompt` / `download` / `messages/{id}` under `/manage/leads/{id}/phone-call-insights`, JSON not Inertia props, sharing `App\Http\Controllers\Concerns\ServesChannelInsights` with WhatsApp and Zoom (fingerprint, Prompt view, JSON download, stored reading, the Work page's journey states). 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 resolves 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 **Overall** — the combined reading of every transcript — 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=`), 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 Overall/per-call chip row over `ChannelInsightsPanel` and `AvatarDossier`. The sub-tabs are hidden in the read-only lead modal and for a viewer without `view-calls` — a sub-tab whose endpoint answers 403 is a bug, not a hint.

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