# Channel Insights · Showroom (face-to-face visits)

**Context:** Manage · **UI:** Lead → Channel → Showroom → **Insights** (sub-tabs: **Recordings | Insights**) · **Routes:** `manage.leads.showroom-insights.*` · Built 2026-09-17 with [AI Caller](ai-caller.md), 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 recorded face-to-face visit** with one person — the smart-badge recording our advisor makes in the showroom — 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 visit**, the **properties discussed** and the **advice given**. A visit is the richest conversation we ever have with a person — they came in, saw the models and talked about their money — so the reading is shaped like the meeting one, not like a chat.

One reading per lead in `lead_channel_insights` (`CHANNEL_SHOWROOM = 6`), 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 badge recording is **one microphone in a room**. The transcriber splits it by voice and writes `Speaker A` / `Speaker B` / `Speaker C` — the letter is the order it heard them, nothing more — and the visit often has a third voice (a spouse, a parent, a friend). The header names the advisor whose badge recorded it, but does not say which letter they are.

This channel therefore behaves exactly like [Phone Call](phone-call.md), and for the same reason:

- **The prompt does the attribution, out loud.** It states that the labels are anonymous, describes how to tell our advisor from the customer (ours asks the qualifying questions and explains projects, prices, loans and process; the customer asks about price, rental and loan eligibility and talks about their own family, job and money; a third voice is usually family, never the customer), and says to **take nothing from a passage where it cannot tell**. The visit digest opens by naming who it decided the customer was ("Speaker B is the customer."), so the reader can judge the reading.
- **`quote_verified` keeps its exact meaning.** Quotes are checked against `spoken_lines` — EVERY line of the visit — so a true `quote_verified` means the words really are in the line cited. That is checkable; who said them is not.
- **Every readiness item is stamped `speaker_unresolved`** by the server (`RecordingTranscript::flagUnresolvedSpeakers()`, applied AFTER `verifyEvidence()`), 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\Conversation\Support\RecordingTranscript` with `sf` / "visit"

The renderer is the shared one (Phone Call uses it with `pc` / "call"); this channel supplies the prefix, the noun and the header facts, in `LeadShowroomInsightsController::describe()`:

```
[sf:88-0 · 2026-09-04 14:56 · visit 1 of 2 · 14 min · agent Shawn Tan] (visit)
[sf:88-12] Speaker A: 这个 unit 的 rental 大概多少？
[sf:88-13] Speaker B: 两千五到两千八，看 furnish。
```

- **Source:** the lead's `f2f_recordings`, oldest first by `recorded_at`. A visit with no transcript yet still gets its header — it happened. (`F2fRecording` is soft-deletable, so a deleted recording is out by the model's own scope.)
- **Lines:** only `Speaker <letter/number>` counts as a label; any other line continues the previous speaker, and a recording with no labels at all reads as `unknown`. A very long unbroken utterance is cut into addressable lines at sentence boundaries by the shared renderer, so a citation points at a sentence rather than at a wall of text.
- **Long histories** split into parts on line boundaries (`TranscriptParts`, 150k characters); the `showroom_insights_part` prompt reads earlier parts into notes. Nothing is trimmed.
- **View:** `GET …/showroom-insights/messages/sf-{visit}-{line}` — that line ± 3, only for this lead's visits, every line rendered with role `unknown` so nobody is styled as the customer or as our side.

### Stale

The set of visits changed, or a visit that HAS a transcript was updated after the reading — the pipeline writes the transcript some minutes after the audio lands, so a reading taken in between is out of date the moment it arrives.

## The UI

`ShowroomF2fTab.vue` is now a host: `ChannelSubTabs` (**Recordings | Insights**) over `ShowTabs … hideStrip` on **`?ftab=`**, declared as the Showroom tab's `queryParams` in `useLeadTabs`. **Recordings** is first and default — it is the record; Insights is a reading of it. The record itself moved unchanged into `Channel/ShowroomRecordingsPane.vue` (the DataTable whose View opens the shared `F2fDetailModal`).

The Insights body is the shared `ChannelInsightsPanel` with `inbox: false` and this channel's words (`source`/`sources` = visit/visits, `item`/`items` = line/lines, `sessionsTitle`/`session` = Visits/Visit). Two cases get no reading at all, and neither is hidden client-side only: the read-only Lead detail modal (no `leadUuid`, no page to hang the endpoints off) and a viewer without **`view-f2f`** — the permission the endpoints enforce, checked in the tab too so the sub-tab can never 403 (GUIDELINES §15).

## Reference usage

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

$analyzer = app(ChannelInsightsAnalyzer::class)
    ->usingPrompts(AiRequest::PROMPT_SHOWROOM_INSIGHTS, AiRequest::PROMPT_SHOWROOM_INSIGHTS_PART);

// One row per visit: id, when, transcript, and the header's short facts.
$transcript = RecordingTranscript::renderMany($rows, 'sf', 'visit');
$result = $analyzer->analyze($transcript['parts'], ['subject' => $lead, 'lead_id' => $lead->id, 'channel' => 'Showroom visits']);

// Verify first (the quote must be in the line it cites), then flag the speaker as unconfirmed.
$insights = RecordingTranscript::flagUnresolvedSpeakers(
    ChannelInsights::verifyEvidence($result['insights'], $transcript['spoken_lines'])
);
```

## Related files

- [src/Conversation/Support/RecordingTranscript.php](/src/Conversation/Support/RecordingTranscript.php) — the shared diarized renderer (+ `flagUnresolvedSpeakers()`); [src/F2f/F2fRecording.php](/src/F2f/F2fRecording.php) — the recordings themselves.
- [app/Http/Controllers/Manage/Leads/LeadShowroomInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadShowroomInsightsController.php) — read / generate / prompt / download / message, with `view-f2f` + `LeadVisibility`; shares [ServesChannelInsights](/app/Http/Controllers/Concerns/ServesChannelInsights.php).
- Prompts: [showroom_insights.md](/resources/prompts/showroom_insights.md), [showroom_insights_part.md](/resources/prompts/showroom_insights_part.md) — registered in `config/ai_prompts.php`, admin-editable on Manage → AI Prompts.
- Frontend: [ShowroomF2fTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/ShowroomF2fTab.vue) (host), [Channel/ShowroomRecordingsPane.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ShowroomRecordingsPane.vue) (the record), the shared [ChannelInsightsPanel.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsPanel.vue) + [ReadinessGrid.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ReadinessGrid.vue).
- The recordings module itself: [Showroom F2F](/docs/modules_handbook/manage/f2f/readMe.md) — the pipeline that produces these transcripts, and the per-visit [Conversation Analysis](/docs/modules_handbook/shared/conversation-analysis/readMe.md) that scores ONE visit (Insights reads them all together; neither replaces the other).
- Tests: [tests/Feature/Lead/LeadShowroomInsightsTest.php](/tests/Feature/Lead/LeadShowroomInsightsTest.php) (every line rendered and nobody named; reading spends nothing; every visit read, quotes verified against any spoken line, every item flagged `speaker_unresolved`, reuse; no transcript → 422; line context scoped to the lead and role `unknown`; the `view-f2f` gate) · [ShowroomF2fTab.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/ShowroomF2fTab.test.js) · the shared stub analyzer [tests/Support/FakesChannelInsights.php](/tests/Support/FakesChannelInsights.php).
