# Channel Insights · AI Caller

**Context:** Manage · **UI:** Lead → Channel → AI Caller → **Insights** (sub-tabs: **Calls | Insights**) · **Routes:** `manage.leads.ai-call-insights.*` · Built 2026-09-17 at the founder's request: "AI caller and showroom channel also need to have the same insight as Zoom and WhatsApp, make sure same UI/UX and similar prompts."

## What it does

Reads **every call the AI voice agent placed to 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 answered call**, the **properties discussed** and what our agent **told or offered** them. One reading per lead in `lead_channel_insights` (`CHANNEL_AI_CALLS = 5`), 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: who spoke IS known

This is the only recording channel where it is. Retell separates the agent's turns from the person's and stores them as rows (`ai_voice_call_turns.role` = `agent` / `user`), so a `customer` line really is the customer's own words — the same footing as a WhatsApp message, and unlike a [phone call](phone-call.md) or a [showroom visit](showroom.md), where a mono recording's diarization names nobody. Readiness quotes are therefore checked against the person's own turns only (`ChannelInsights::verifyEvidence()`), and nothing is stamped `speaker_unresolved`.

What the prompt has to be careful about instead is the **script**: an AI call is short and goal-driven, so the agent's line ("great, see you Saturday") is not evidence that the customer agreed. The prompt says to read what the customer actually said, to trust the words over the stored outcome when they disagree, and to catch the hard signals a call carries — a request not to be called again, a better time or language, "just send it on WhatsApp".

## How it works

### The transcript — `Src\VoiceAgent\Support\AiCallTranscript`

```
[ac:512-0 · 2026-09-16 10:35 · call 2 of 4 · 2m 18s · Completed · Appointment set] (AI call, profile "Armani")
[ac:512-1] ai agent: Hi, this is Wai Kit's AI assistant from PropertyLab. Is now a good time?
[ac:512-2] customer: Ok, but I am driving now.
```

- **Source:** the lead's `ai_voice_calls` with `source = SOURCE_LEAD` and **`refusal_reason IS NULL`**, oldest first by `coalesce(started_at, created_at)`. The two exclusions are not tidiness: a browser Test Audio row is an admin talking to themself, and a **refusal** (quiet hours, the daily budget fuse, the blocklist) never rang a phone — reading either as contact with the customer is a lie the reading would then reason from. A **human**-channel call on the same ledger (`channel = human`, an Appointment Engine staff call) is included and rendered as `staff`, because the tab shows it too.
- **An UNANSWERED call is part of the history.** It renders as its header plus `nothing was said on this call`, so a run of them reads as what it is — we keep calling and nobody picks up. It is the same rule Zoom uses for a meeting with no transcript.
- **Header facts** come from the row, not from the model: status (`Completed`, `No answer`, `Voicemail`, …), outcome (`Appointment set`, `Call-back asked`, `Do not call`, …), length, and the AI profile that called. `message_count` counts spoken TURNS, and a turn with empty content is skipped.
- **Long histories** split into parts on turn boundaries (`TranscriptParts`, 150k characters); the `ai_call_insights_part` prompt reads earlier parts into notes. Nothing is trimmed.
- **View:** `GET …/ai-call-insights/messages/ac-{call}-{turn}` — that turn ± 3, only for this lead's calls. A customer turn is toned as `customer` and ours as `staff`, since here that is known.

### Stale

The set of calls changed, or a call **ended or was analysed** after the reading — the transcript is written at those two moments. Deliberately not `updated_at`: a call row also moves when the WhatsApp follow-up's delivery ladder advances, which changes not one word of what was said.

### The chips

The panel's source chips are the calls **somebody spoke on** (label = date and time, detail = profile · outcome). An unanswered call is read but has no chip: a filter that narrows the reading to nothing is noise. `message_numbers` maps each cited turn id to its call's uuid, which is what makes the filter work.

## The UI

`AiCallerTab.vue` is now a host: `ChannelSubTabs` (**Calls | Insights**) over `ShowTabs … hideStrip` on **`?atab=`**, which `useLeadTabs` declares as the AI Caller tab's `queryParams` so the param is dropped when the reader moves to a channel without that level. **Calls** is first and default — it is the record; Insights is a reading of it. The record itself moved unchanged into `Channel/AiCallerCallsPane.vue`.

The Insights body is the shared `ChannelInsightsPanel` with `inbox: false` and this channel's words (`source`/`sources` = call/calls, `item`/`items` = turn/turns, `sessionsTitle`/`session` = Calls/Call). The read-only Lead detail modal passes no `leadUuid`, so there the tab stays exactly the pane it always was.

## Reference usage

```php
use Src\Ai\AiRequest;
use Src\Conversation\ChannelInsights;
use Src\Conversation\ChannelInsightsAnalyzer;
use Src\VoiceAgent\Support\AiCallTranscript;

$analyzer = app(ChannelInsightsAnalyzer::class)
    ->usingPrompts(AiRequest::PROMPT_AI_CALL_INSIGHTS, AiRequest::PROMPT_AI_CALL_INSIGHTS_PART);

$transcript = AiCallTranscript::renderMany($calls);                    // every turn of every call
$result = $analyzer->analyze($transcript['parts'], ['subject' => $lead, 'lead_id' => $lead->id, 'channel' => 'AI Caller']);
$insights = ChannelInsights::verifyEvidence($result['insights'], $transcript['customer_messages']);
```

## Related files

- [src/VoiceAgent/Support/AiCallTranscript.php](/src/VoiceAgent/Support/AiCallTranscript.php) — the transcript; [src/Conversation/Support/TranscriptParts.php](/src/Conversation/Support/TranscriptParts.php) — the split.
- [app/Http/Controllers/Manage/Leads/LeadAiCallInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadAiCallInsightsController.php) — read / generate / prompt / download / message, with `view-calls` + `LeadVisibility`; shares [ServesChannelInsights](/app/Http/Controllers/Concerns/ServesChannelInsights.php) with every other channel.
- Prompts: [ai_call_insights.md](/resources/prompts/ai_call_insights.md), [ai_call_insights_part.md](/resources/prompts/ai_call_insights_part.md) — registered in `config/ai_prompts.php`, admin-editable on Manage → AI Prompts.
- Frontend: [AiCallerTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/AiCallerTab.vue) (host), [AiCallerCallsPane.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/AiCallerCallsPane.vue) (the record), the shared [ChannelInsightsPanel.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsPanel.vue).
- Tests: [tests/Feature/Lead/LeadAiCallInsightsTest.php](/tests/Feature/Lead/LeadAiCallInsightsTest.php) (the phone system's roles decide whose words a quote may come from; reading spends nothing; every call read + quotes verified + reuse; a refusal and a browser test are never read; all-unanswered → 422; line context scoped to the lead; the `view-calls` gate) · [AiCallerTab.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/AiCallerTab.test.js) · the shared stub analyzer [tests/Support/FakesChannelInsights.php](/tests/Support/FakesChannelInsights.php).
