# Channel Insights · Zoom (meetings + webinars)

**Context:** Manage · **UI:** Lead → Channel → Zoom → **Insights** (a segmented switch: **Meetings | Webinars**; inside Meetings, **Overall + one tab per meeting**) · **Routes:** `manage.leads.zoom-meeting-insights.*`, `manage.leads.zoom-webinar-insights.*` · Built 2026-09-18 at the founder's request, "duplicate the WhatsApp insight function to Zoom — one analysis from meetings, one from webinars".

## What it does

Zoom carries two very different signals about a person, so it gets two readings:

- **Meetings** are usually one-to-one consultations — where budget, loans, family and property reactions actually come out. The meeting reading is the WhatsApp reading and more: the same brief / do now / still owed / seven-area readiness, plus **a digest per meeting**, **the properties discussed** (with the customer's reaction) and **the advice given**.
- **Webinars** show what kind of content a person keeps coming back for, how many properties they told a poll they own, and how **loyal** they are to the platform. The loyalty is **counted, not AI**: webinars attended, total and average minutes, % watched, registered vs no-shows vs walk-ins, months active, last attended — and a **rule-based level** (below). The AI reading interprets those facts (interests, how they attend, what their poll answers and questions reveal, what to invite them to next) and adds readiness evidence from their own answers.

The webinar panel opens with two things that are **not AI**: the **properties-owned card** (their own poll answer, stored as the lead variable `lead.webinar.property_count` — see below) and the counted loyalty.

Both readings are one per lead, stored in `lead_channel_insights` (`CHANNEL_ZOOM_MEETINGS = 2`, `CHANNEL_ZOOM_WEBINARS = 3`), analysed only on an explicit click, reused on an exact fingerprint match, with Prompt / JSON / View-in-context exactly like WhatsApp.

- **Client avatar** (added the same day, founder: "the Zoom meeting avatar analysis needs to show as intelligence too") is not a new reading: it is the persona already MINED from this lead's own consultations in the Client Avatars library (`ZoomClientAvatar` — DISC and how to sell to them, budget / cash / financing position, primary fear, objections with their words, the advisor review, CRS and journey scores when scored). The lead page shows the library's own `AvatarDossier` for it, fed by `ZoomClientAvatar::dossier()` — the same array the library's index uses — so the two pages can never describe the client differently. Read-only: mining stays in the library.
  - **It is the INDIVIDUAL meeting's analysis, and it is ZOOM-mined only.** Founder, 2026-09-17, twice: first that a phone-call avatar must not appear under Zoom, then *"the idea is always have one overall and also show the individual meetings analytics"*. Both follow from one fact — an avatar is mined from ONE recording, of one channel. So the Meetings view is now **`ChannelInsightsViews`**: a quiet chip row **Overall · 17 Aug 2026 · 2 Jul 2026 …**, where Overall is the combined reading of every meeting (`ChannelInsightsPanel`) and each date is that meeting's own dossier. A person can have two avatars — lead 3584 has one from Zoom meeting 385 and one from call recording 114 — and the call-mined one belongs under **Channel → Phone Call**, which mounts the SAME component against its own endpoint. `ZoomClientAvatar::forLead($leadId, SOURCE_ZOOM | SOURCE_CALL | null)` is the one place that split is decided; the component only knows an `endpoint`.
  - **The meeting reading is TOLD the avatar** (`meta.client_avatar` → the `client_avatar` THREAD FACT, trimmed by `ZoomClientAvatar::promptFacts()`: DISC + `how_to_sell` / `what_backfires`, speech style, purpose, why now, timeline, budget, cash, financing, decision maker, primary fear, hidden objections, last outcome, open objections). The prompt is explicit that it is **a read of the person, not evidence** — no quote and no `readiness` item may come from it, and the transcript wins any disagreement. The avatar ids + `mined_at` are part of the reading's **fingerprint**, so a newly mined avatar re-analyses instead of reusing a reading that never saw it.

## How it works

### Meetings — `Src\Zoom\Support\MeetingTranscript`

- **Source:** the lead's meetings that actually happened (`ZoomMeeting::applyHappened`), oldest first; the text is `zoom_meetings.transcript` (Zoom's "Speaker Name: text" lines). Every line is read; a long history splits into parts (`TranscriptParts`, 150k characters) and the `zoom_meeting_insights_part` prompt reads earlier parts into notes.
- **Lines:** each meeting opens with a header `[zm:{meeting}-0 · date · meeting i of n · 43 min] (meeting "topic")`, then `[zm:{meeting}-{line}] customer (Label): …` / `other (Label): …`. A line with no label continues the previous speaker. A meeting without a transcript still appears (header only) — it happened.
- **Who is the customer:** a speaker label whose every word is a word of the lead's name or email local part (`isCustomer()`: "Ryan Lee" ⊂ "Ryan Lee Cheen Honng"; a Chinese name without spaces may match as a substring). Everyone else is `other` — usually our advisor, sometimes family; the prompt says so. **Only `customer` lines can back readiness evidence**, and `verifyEvidence()` checks quotes against exactly those lines. A device label ("iPhone") never becomes the customer: the rule errs toward saying less. This is a heuristic, not the Customer Journey's staff speaker mapping — it is good enough to read, not to confirm.
- **Stale:** a meeting added or removed since the reading, or a transcript fetched after it.
- **View:** `GET …/zoom-meeting-insights/messages/zm-{meeting}-{line}` — that line ± 3, only for this lead's meetings.

### Webinars — `Src\Zoom\Support\WebinarHistory`

- **Source:** the lead's attendance segments (minutes summed from `duration_seconds`, the same source as the Webinars tab and the Leads index), event registrations backed by a webinar, poll/quiz answers and Q&A questions (`zoom_webinar_responses` + questions), and chat lines. A poll answer or chat line **proves presence** even when the attendance report never matched the person ("present, watch time not recorded").
- **Rendered for the model:** one line per webinar (`[wz:{id} · date · webinar i of n] "title" · watched 25 min of 199 scheduled (13%) · joined without registering`), then `[wr:{id}] poll "…" — customer answered: …`, `[wr:{id}] customer asked: …`, `[wc:{id}] customer wrote in chat: …`.
- **The counted facts** (`metrics()`), averaged only over webinars whose watch time is recorded, are passed to the model as THREAD FACTS "as given" — the prompt must not restate different numbers.
- **Loyalty level** (`WebinarHistory::loyalty()`, `LOYALTY_LEVELS`):

  | Level | Rule |
  |---|---|
  | Loyal | 8+ webinars across 3+ months |
  | Regular | 4+ webinars across 2+ months |
  | Occasional | 2–3 webinars |
  | New | 1 webinar |
  | Lapsed | was Regular or Loyal, none in the last 90 days |
  | No attendance | never attended |

- **Schema:** `Src\Conversation\WebinarInsights` (`webinar-insights-v1`): `headline`, `summary`, `interests` (≤5, strength + the `wz:` webinars behind each), `engagement_pattern`, `loyalty_reading`, `signals` (≤5, cited `wr:`/`wc:`), `recommended_topics` (≤3), `next_best_actions` (≤3), `suggested_message`, `readiness`. A model-made loyalty score is never rendered.
- **Stale:** a webinar added to the history since the reading.
- **View:** `GET …/zoom-webinar-insights/messages/{wz|wr|wc}-{id}` — the webinar line, plus the answer / question (+ host reply) / chat line, only this lead's.

### Properties owned — the lead variable `lead.webinar.property_count`

Founder, 2026-09-17: *"webinar insight, I think need to read the poll to answer the question on how many properties he owns, and show as a score card at the webinar. Make this a lead variable like `lead.webinar.propertycount` so that we store this in the database."*

- **Where it lives:** `leads.webinar_property_count` (+ `webinar_property_count_at`, `webinar_property_response_id`), migration `2026_09_17_200000`. Values are `Lead::WEBINAR_PROPERTIES_NONE / _ONE / _TWO_PLUS` with `Lead::WEBINAR_PROPERTY_COUNTS` for the UI. `NULL` = never answered such a poll, which is NOT the same as owning none.
- **It is NOT `leads.properties_owned`**, and must not be written into it. That column is an EXACT declared count (a legacy CRM export, an import); the poll has three buckets, so `TWO_PLUS` means *at least* two and can never be an exact figure. They also disagree in the real data: of the leads holding both, **745 have `properties_owned = 0` while the poll says they own one or more** — the imported 0 is mostly a default, not a declaration. Two facts, two columns, each with its own provenance.
- **Which answer wins:** the LATEST recognised one. Holdings grow: someone who answered "没有房产" in January and "买了1间" in June owns one now (48 leads have given more than one distinct answer). The card says how many times they were asked and whether the answer ever moved.
- **What counts as an answer** — `Src\Zoom\Support\PollPropertyCount::QUESTIONS`, a registry of exact prompts and their exact choices: `请问你目前是？` (没有房产 / 买了1间 / 买了2间或以上, with the 90% loan quota each implies) and its English twin `How many property investment had you started?` (Zero / 1 / 2 and above). Matching forgives case and spacing and nothing else, so a poll like *"Which one you want?"* answered *"5"* is never read as five properties. **A new poll wording is one entry in that registry** — never a regex over free text.
- **Who writes it:** `Src\Lead\Services\WebinarPropertyCountSync` → `LeadRepository::setWebinarPropertyCount()` (writes only what changed, and **without touching `leads.updated_at`** — a derived re-read is not someone editing the lead). Three roads reach it: the ingestion job `SyncWebinarResponses` (the leads that webinar just touched), the hourly `leads:sync-webinar-property-counts` sweep (a respondent linked days later, an account merge, a CSV report imported by hand), and the panel's own `show` (one lead, so the screen and the variable can never disagree). First backfill, 2026-09-17: **1,005 leads** — 253 own none, 296 one, 456 two or more.
- **On screen:** the first card in the webinar panel — the bucket, what it means for financing, the person's own words, when and in which webinar, and **View answer** (opens the poll line in context, `wr:{id}`).
- **In the prompt:** handed to the model as the THREAD FACT `properties_owned_poll` and part of the reading's fingerprint, so a changed answer re-analyses instead of reusing a reading that contradicts it. The prompt says to treat it as fact and never to turn "2 or more" into an exact number.

### Client avatar — `ZoomClientAvatar::dossier()`

- `GET …/zoom-meeting-insights/avatars` (`.avatars`) answers the whole view in one call: **`recordings`** — the same meetings the overall reading covers (`resolve()`, so the tab list can never be longer or shorter than the reading), newest first, each `{key: meeting uuid, title, date, minutes, advisor, avatar_uuid}` with `avatar_uuid` **null when nothing has been mined from it** — and **`avatars`**, `ZoomClientAvatar::forLead($lead->id, SOURCE_ZOOM)` (ready, zoom-mined only) through `ZoomClientAvatar::dossier()`, plus `OUTCOMES` and a library link searched by the lead's name. Same gate (`view-zoom` + lead visibility); no provider call. **A channel adding this view implements this same shape.**
- `ChannelInsightsViews.vue` owns the chip row and both bodies: Overall stays MOUNTED behind a `v-show` (switching back must not re-read it), and a recording renders its header (topic · date · minutes · advisor, outcome badge, "Open in Client Avatars" in a new tab) over `AvatarDossier` — the library's own component, whose Transcript tab calls `avatars/{uuid}/transcript` (also `view-zoom`). A recording with no avatar says so and points at Overall, which still read it. Props: `endpoint`, `labels`, `inbox`, `recordingNoun` (meeting / call / visit) — **every channel mounts this one component; do not add a second dossier or a second switch.**

### Shared pieces

- **Analyzer:** `ChannelInsightsAnalyzer::usingPrompts($main, $part)` returns a copy under other prompt keys (webinars have no part prompt — the history is small, so it is read in one request). The **model** resolves: this prompt's own pin on Manage → AI Prompts → else **Channel Insights' pin** (so one model choice covers all three readings until someone pins one separately) → config → catalog default. A reading's schema is passed as `context['schema']` (default `ChannelInsights`).
- **Controllers:** `LeadZoomMeetingInsightsController`, `LeadZoomWebinarInsightsController`, sharing `App\Http\Controllers\Concerns\ServesChannelInsights` with WhatsApp (fingerprint, Prompt view, JSON download, stored reading, the Work page's journey states). All take `ChannelInsightsRequest` (no input). Gate: **`view-zoom`**, then object-level `LeadVisibility`.
- **Frontend:** `ZoomTab.vue` adds an **Insights** tab (not in the read-only lead modal; in the AE suite's meetings-only mode it shows Meetings only) with `ChannelSubTabs` switching between the Meetings view (`ChannelInsightsViews`, above — its Overall body is `ChannelInsightsPanel`, driven by `endpoint`, `labels` and `inbox: false`, rendering the meeting digest / properties / advice) and `WebinarInsightsPanel`. Three levels of identical pills would read as a maze, so the chip row is deliberately quieter than the segmented control above it (GUIDELINES §15). Both use `composables/useChannelInsights.js` (read, generate, "still finishing" after a proxy timeout).

## Reference usage

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

$analyzer = app(ChannelInsightsAnalyzer::class)
    ->usingPrompts(AiRequest::PROMPT_ZOOM_MEETING_INSIGHTS, AiRequest::PROMPT_ZOOM_MEETING_INSIGHTS_PART);

$transcript = MeetingTranscript::renderMany($meetings, $lead);          // every line of every meeting
$result = $analyzer->analyze($transcript['parts'], ['subject' => $lead, 'lead_id' => $lead->id, 'channel' => 'Zoom meetings']);
$insights = ChannelInsights::verifyEvidence($result['insights'], $transcript['customer_messages']);
```

For webinars: `WebinarHistory::build($lead)` → `usingPrompts(AiRequest::PROMPT_ZOOM_WEBINAR_INSIGHTS)` → `analyze($history['parts'], [..., 'schema' => WebinarInsights::class, 'meta' => $history['metrics']])`.

## Related files

- [src/Zoom/Support/MeetingTranscript.php](/src/Zoom/Support/MeetingTranscript.php), [src/Zoom/Support/WebinarHistory.php](/src/Zoom/Support/WebinarHistory.php), [src/Conversation/Support/TranscriptParts.php](/src/Conversation/Support/TranscriptParts.php)
- [src/Conversation/WebinarInsights.php](/src/Conversation/WebinarInsights.php); meeting sections in [src/Conversation/ChannelInsights.php](/src/Conversation/ChannelInsights.php) (`sessions`, `properties_discussed`, `advice_given`)
- [app/Http/Controllers/Manage/Leads/LeadZoomMeetingInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadZoomMeetingInsightsController.php), [LeadZoomWebinarInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadZoomWebinarInsightsController.php), [app/Http/Controllers/Concerns/ServesChannelInsights.php](/app/Http/Controllers/Concerns/ServesChannelInsights.php)
- Prompts: [zoom_meeting_insights.md](/resources/prompts/zoom_meeting_insights.md), [zoom_meeting_insights_part.md](/resources/prompts/zoom_meeting_insights_part.md), [zoom_webinar_insights.md](/resources/prompts/zoom_webinar_insights.md) — registered in `config/ai_prompts.php`
- Properties owned: [src/Zoom/Support/PollPropertyCount.php](/src/Zoom/Support/PollPropertyCount.php), [src/Lead/Services/WebinarPropertyCountSync.php](/src/Lead/Services/WebinarPropertyCountSync.php), [app/Console/Commands/SyncWebinarPropertyCounts.php](/app/Console/Commands/SyncWebinarPropertyCounts.php), `LeadRepository::setWebinarPropertyCount()`, migration [2026_09_17_200000](/database/migrations/2026_09_17_200000_add_webinar_property_count_to_leads_table.php)
- Overall + individual: [src/Zoom/ZoomClientAvatar.php](/src/Zoom/ZoomClientAvatar.php) (`forLead()`, `dossier()`, `promptFacts()`), [ChannelInsightsViews.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsViews.vue) reusing [AvatarDossier.vue](/resources/js/Pages/Manage/Zoom/Avatars/Partials/AvatarDossier.vue); the library's [ClientAvatarsController](/app/Http/Controllers/Manage/Zoom/ClientAvatarsController.php) maps its rows through the same `dossier()`
- Frontend: [ZoomTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/ZoomTab.vue), [WebinarInsightsPanel.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/WebinarInsightsPanel.vue), [composables/useChannelInsights.js](/resources/js/composables/useChannelInsights.js)
- Tests: [tests/Feature/Lead/LeadZoomInsightsTest.php](/tests/Feature/Lead/LeadZoomInsightsTest.php) (speaker rule, reading spends nothing, every meeting read + quotes verified + reuse, no-transcript 422, scoped line context, view-zoom gate, counted loyalty, webinar facts handed over + answers verified + scoped, loyalty rules, the lead's avatars exactly as the library shows them + a tab per meeting, the property poll becoming `lead.webinar.property_count`); [tests/Unit/Zoom/PollPropertyCountTest.php](/tests/Unit/Zoom/PollPropertyCountTest.php) (what the registry refuses); [ChannelInsightsViews.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/ChannelInsightsViews.test.js) (Overall opens first, one tab per recording, an un-mined recording still listed, Overall not re-read on switch back); `ChannelInsightsSchemaTest` (meeting sections, webinar clamp); `ChannelInsightsReadinessDriftTest` (all five prompts teach every field); [WebinarInsightsPanel.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/WebinarInsightsPanel.test.js) and the meeting cases in `ChannelInsightsPanel.test.js`.
