# Conversation Analysis (Shared · `Src\Conversation`)

**Context:** Shared library (not a portal module) · **Routes / UI:** a translate endpoint per consumer, PLUS its own write routes for the human review layer below (`manage.conversation.recording-reviews.*` — `store`/`update`/`destroy`, served by `RecordingReviewsController`) · **Used by:** Phone Call (phone calls), Showroom F2F (smart-badge), Zoom Meetings — the single sales-conversation analyzer all three share.

## What it does
After a recording is transcribed (by the shared [`Src\Transcription`](/docs/modules_handbook/shared/transcription/readMe.md) service), this is the **one place** the transcript is turned into a structured analysis. Every consumer runs the **same prompt** through the **same service** and stores the **same schema** — so a phone call, a showroom conversation and a Zoom meeting all produce identical analysis output (summary, conversation type, sentiment, customer profile, sales performance, and a meeting report). It replaced the three previously-bespoke paths (the old `App\Helpers\Calls\GeminiService` used by Calls + F2f, and the Zoom-only `AiClient` call).

It is an **`AiClient` consumer** (unlike transcription): analysis is plain LLM text-generation, so it runs through the shared [`Src\Ai\AiClient`](/docs/modules_handbook/shared/ai/readMe.md) — which means every analysis call is **logged to `ai_requests`** (provider, model, tokens, cost, duration, the recording as `subject`), with the resilient `ai` queue lane, rate-limit and circuit-breaker for free.

## How it works
> **Its chat sibling: [Channel Insights](/docs/modules_handbook/shared/channel-insights/readMe.md).** That one reads a WhatsApp **thread** (Lead → Channel → WhatsApp → Insights) and is a separate analyzer, schema and prompt on purpose — this schema scores an agent across ONE bounded conversation, and a months-long asynchronous chat run through it produces a sales score and a "meeting report" for a meeting that never happened. If you are adding analysis for a text channel, extend that one, not this.


- **Three pieces:**
  - `Src\Conversation\ConversationAnalysis` — the **schema** + `normalize()`. Defines the canonical shape and the enum vocabularies (`CONVERSATION_TYPES`, `SENTIMENTS`, `INTEREST_LEVELS`, `BUYING_STAGES`); `normalize()` guarantees every core key with a safe default and clamps the enums (AI output is non-deterministic), so downstream code + the renderer never hit a missing/invalid key.
  - `Src\Conversation\ConversationAnalyzer` — the **service**. `analyze(transcript, [subject, lead_id])` → the normalised analysis array; `translate(analysis)` → the Chinese copy; `isConfigured()`. Resolves the provider + model from the admin settings and runs the call through `AiClient`.
  - `resources/prompts/conversation_analysis.md` + `conversation_translate.md` — the **prompt bodies**, under the registry keys `AiRequest::PROMPT_CONVERSATION_ANALYSIS` / `PROMPT_CONVERSATION_TRANSLATE`.

- **The schema is prompt-driven, not hardcoded** (conversation-analysis-v2, flipped live in Phase 6). `Src\Conversation\ConversationAnalysis` no longer reconstructs a fixed shape — it is a **bounded passthrough**: whatever top-level structure the prompt's JSON skeleton declares is stored and rendered as-is (see `normalize()`), and PHP reads back only **five pinned data points** by path (score, action items, typed action rows, follow-up date, summary — see `PATHS`/`pinnedPaths()`), each with a v2-primary and v1-legacy candidate so historical analyses keep working unchanged. The **shipped v2 default** (`resources/prompts/conversation_analysis.md`) currently declares:

  ```jsonc
  {
    "headline": "one sentence, the single most important insight",
    "summary": "2-4 sentence overview",
    "conversation_type": "sales_pitch | follow_up | cold_call | negotiation | closing | customer_service | internal_meeting | non_sales | other",
    "sentiment": "positive | neutral | negative — customer's TONE",
    "commitment_level": "positive | neutral | negative — readiness to commit (separate from sentiment)",
    "kpis": { "sales_score": { "score": "0-10|null", "reason": "string|null" }, "customer_interest": "high|medium|low|none", "buying_stage": "awareness|interest|consideration|decision|unknown", "budget_stated": "string|null", "follow_up_date": "YYYY-MM-DD|null" },
    "customer_profile": { "demographics": { "location": "string|null", "occupation": "string|null", "relationship_to_area": "string|null", "discovery_channel": "string|null" }, "needs": [], "concerns": [] },
    "tactical_threats": { "competing_projects": [], "competing_agents": { "present": false, "channel": "string|null", "showroom_booked": false, "urgency": "high|medium|low|none" } },
    "sales_performance": { "capability_radar": { "rapport_building": "0-10", "product_knowledge": "0-10", "qualification": "0-10", "objection_handling": "0-10", "closing_initiative": "0-10" }, "strengths": [], "coaching_points": [] },
    "meeting_report": { "key_points": [] },
    "follow_up_workspace": { "action_items": [{ "body": "string", "priority": "high|medium|low", "action_type": "call|whatsapp|send_information|schedule|internal|other", "reason": "string|null", "suggested_scheduled_for": "YYYY-MM-DD|null" }], "whatsapp_draft": "string", "evidence_pack": [], "crm_update": { "stage": "string", "product_shortlist": [], "key_risks": [], "next_action": "string", "follow_up_deadline": "YYYY-MM-DD|null" } },
    "manager_coaching_note": "string"
  }
  ```

  A **stored v1 analysis** (`summary`, `conversation_type`, `sentiment`, `customer.*`, `sales_performance.score/strengths/improvements`, `meeting_report.*`) still renders and still feeds the three performance dashboards unchanged — no data migration was run (decision 2); the pinned-path accessors resolve either shape.

- **Extensible by design (backend + frontend).** `normalize()` always applies its structural guards (depth/node/leaf-length caps) and the pinned-path clamps, but **preserves any other top-level key** the model returns (non-empty), key order included — so extending the prompt with a new section needs **no schema/migration change**. The shared renderer mirrors this: `Src\Conversation\Support\PromptSkeleton` parses the prompt's own JSON skeleton into an ordered list of section keys (an object or list-of-objects is a tab candidate; a scalar or list-of-scalars renders as chrome on the AI analysis tab), so a prompt edit on the Manage AI Prompts page grows the recording-detail tab strip with **no code change and no deploy**. Add a field to the prompt → it flows through storage → it renders.

- **The v1/v2 rubric blend (decision 7).** The three performance dashboards average `sales_performance.score`/`kpis.sales_score.score` across every recording regardless of which schema produced it — v1's and v2's scoring guidance differ in emphasis (v2 adds an explicit `reason` and a five-axis `capability_radar` alongside the single score), so a blended average is a deliberate simplification rather than an oversight. The rubric change is documented here rather than engineered around; splitting dashboard averages by schema was considered and rejected (Phase 7, "not building it") because it costs a per-recording schema stamp and breaks the "tab appears the moment you save" property new sections otherwise get.

- **Configurable provider + model.** The analyzer reads the admin **pin on the `conversation_analysis` prompt key** (`AiPrompt::pinFor()`, set in the Model card on the Manage **AI Prompts** page), falling back to `config('ai.conversation_analysis.*')` (default Gemini / `gemini-3.5-flash` — moved off `gemini-2.5-flash`, which Google shuts down 2026-10-16), then to the provider's catalog `default_model`. Translation runs on the same pair. The key resolves UI-first with the `GEMINI_API_KEY` env fallback (parity with the transcription path).

- **Failure model.** `analyze()` throws the same `AiTransientFailure` / `AiPermanentFailure` the `AiJob` base recognises. The Zoom job (an `AiJob`) lets those drive its resilient retry / fail. The Calls + F2f jobs (plain `ShouldQueue`) **fail soft** — record the failure on the row and stop (the per-module "Retry" action re-runs the whole pipeline). When no analysis provider is configured, `isConfigured()` is false and the pipeline finalises without analysis (never marks failed).

- **Chinese translation (on-demand, cached).** `translate()` runs the analysis JSON through `PROMPT_CONVERSATION_TRANSLATE` (translates string values only; leaves enum codes / dates intact) and re-normalises the result. Each module exposes a translate endpoint via the shared `App\Http\Controllers\Concerns\TranslatesAnalysis` trait, which caches the result into `ai_analysis_zh` (idempotent — a second call is a no-op). The shared frontend (`RecordingDetail`'s analysis tabs) shows an EN/中文 toggle once `ai_analysis_zh` exists, else a "译中文" button.

### Reference usage
The canonical consumer is **Phone Call** — read its analysis job [`app/Jobs/Calls/AnalyzeCallRecording.php`](/app/Jobs/Calls/AnalyzeCallRecording.php): it injects `ConversationAnalyzer`, guards the pipeline stage + `isConfigured()`, calls `analyze($transcript, ['subject' => $recording, 'lead_id' => $recording->lead_id])`, and persists the returned array via the repository into `ai_analysis`. Showroom F2F ([`AnalyzeF2fRecording`](/app/Jobs/F2f/AnalyzeF2fRecording.php)) and Zoom ([`AnalyzeZoomMeeting`](/app/Jobs/Ai/AnalyzeZoomMeeting.php)) are the same call — the only differences are the model/repo they persist to and the queue lane (Zoom uses the resilient `AiJob`). On the frontend, all three render the result through the shared tabbed [`RecordingDetail`](/resources/js/Components/RecordingDetail/RecordingDetail.vue) (its analysis tabs use [`AnalysisSections.vue`](/resources/js/Components/RecordingDetail/AnalysisSections.vue)); the translate action is the shared `TranslatesAnalysis` trait — see [`CallRecordingsController@translateAnalysis`](/app/Http/Controllers/Manage/Calls/CallRecordingsController.php).

## Human review (the layer below the AI score)

The AI's `sales_performance.score` is a machine judgement. **`Src\Conversation\ConversationReview`** adds the human one directly below it in the same tab — a senior admin's own score (0–10, matching the AI scale on purpose) + an optional comment, on the SAME recording. It is deliberately **append-only** (many reviewers, each row independently editable by its own author) rather than a flat column on the three recording tables, because multiple reviewers per recording rules out flat columns outright. A **webinar recording gets none of this** — it is a broadcast, not a sales conversation with one agent to rate.

- **One polymorphic table, one model, one repository, one controller, one Vue panel** serve all three recording types — the same shape `ConversationAnalyzer` already uses. `recording_reviews.reviewable_type`/`reviewable_id` point at `ZoomMeeting` / `CallRecording` / `F2fRecording`; `reviewer_id` is a **`users.id`** (who wrote the review), deliberately distinct from a recording's own `admin_id` (who is being rated). `score` is **NOT NULL at the column level** (bounds enforced by the Form Request, built from `ConversationReview::SCORE_MIN`/`SCORE_MAX` — never a hardcoded `0`/`10`) — every row is guaranteed to carry a real score, so there is no empty-review case to special-case downstream. A review is **soft-deleted with `deleted_by` blame** (`RecordsBlame`), which is why `ConversationReviewRepository::delete()` must `refresh()` the model before returning it — `deleted_by` is written by a raw query the in-memory instance never observes.
- **`Src\Conversation\Support\RecordingReviewGate`** is the single source of truth for "may THIS user review THIS recording" — a three-clause check (holds `review-sales-performance`, can view that recording's own module via `view-zoom`/`view-calls`/`view-f2f`, and the target is not a webinar). It is called from BOTH the write endpoint (`RecordingReviewsController`, enforcement) and every detail payload (`can_review`, display) — one definition, so a button that renders can never be one the controller then rejects. It also carries the whitelisted type-key → model-class map (`zoom` / `call` / `f2f`) a review's target is resolved through — an unknown key 404s rather than ever reaching an arbitrary class.
- **The webinar clause looks redundant but is load-bearing.** `ZoomMeeting`'s `meetingsOnly` global scope already hides webinar rows from a plain `where('uuid', …)` lookup, so the WRITE endpoint refuses a webinar "for free" (a 404, before `RecordingReviewGate::allows()` is ever consulted — the gate class itself IS reached first, for `modelFor()`'s whitelist lookup). But `RecordingsController::resolveDetail()` deliberately loads `->withWebinars()` (so the recordings dashboard can show a webinar row at all) — which means the gate's own webinar check is the ONLY thing keeping `can_review` false on that path. Remove it and a webinar's detail payload would read `can_review: true` for an action the write endpoint still refuses.
- **`toDetailArray()`, both presenters' `detail()`, AND `ZoomRecordingDetailBuilder::build()` take `canReview` and `viewerId` as REQUIRED parameters, not optional ones.** They are precomputed by the caller via `RecordingReviewGate::allows()` and never re-derived inside these presentation methods (kept pure — GUIDELINES §4's general rule that a model/presenter holds only relationship methods, static helpers and basic configuration, never a decision about who may act). Making them required was a deliberate hardening: an optional `false`/`null` default is exactly how a caller that forgets to pass them would silently render every review as "not mine" and every module as "not reviewable", instead of the missing argument fataling immediately at the call site.
- **`ConversationReview::toShowArray(int $viewerId)`** — the presentation method every consumer renders from (mirrors `Src\Lead\LeadComment::toShowArray()`'s convention of taking the viewer id as a plain argument). `is_mine`/`can_edit`/`can_delete` are author-only DISPLAY flags (`reviewer_id === $viewerId`) — and the identical rule is enforced server-side, independently, in `RecordingReviewsController::update()`/`destroy()` (`abort_unless((int) $review->reviewer_id === (int) $request->user()->id, 403)`). That author check is deliberately the ONLY gate on edit/delete: `update`/`destroy` do **not** re-run `RecordingReviewGate::allows()`'s module-view/webinar clauses. This is a decision, not an oversight — a reviewer who later loses `view-calls` (a role change, a revoked grant) can still edit or delete a row they already wrote; only creating a NEW review re-checks the full three-clause gate.
- **Never a truthiness check on score — within this review layer.** `0` is the harshest, and a perfectly legitimate, rating — `humanAverageScore()` distinguishes "no reviews" (`null`) from "reviewed, averaged zero" (`0.0`) with a single query (`avg('score') === null`), never a `doesntExist()` + `avg()` pair (a review deleted between the two calls would turn a real `0.0` into a false `null`). This guarantee is scoped to the review-layer code, NOT repo-wide: `CopilotController::agentsPanel()`'s pre-existing `blended_avg` tile (`$rows->pluck('avg_score')->filter()->avg()`) drops a `0.0` **AI** average via `->filter()`, in the very method this feature extends. That is a known, pre-existing exception on the AI side, out of scope for this feature.
- **Dashboards add a PARALLEL metric, never redefine the AI one.** Each of the three performance dashboards' `agentRows()` gains `human_avg_score` / `human_review_count` / `human_reviewed_recordings` — ONE flat `SELECT reviewable_id, score … WHERE reviewable_type = … AND reviewable_id IN (…)` query per dashboard, with no SQL `GROUP BY`: the grouping (`->groupBy('reviewable_id')`) happens in PHP, over the small set of rows the query returns. The point is avoiding an N+1 (never a per-row `humanAverageScore()`/`reviewCount()` walk — those are not eager-load-aware and always query), not a literal `GROUP BY`. The AI Copilot's Agents panel (`CopilotController::agentsPanel()`) needs **three** such queries, not one — one per model class, because a `CallRecording` id and a `ZoomMeeting` id are not the same id-space and would cross-attribute if queried together. Its `avg_score` drives a coaching flag, a coverage flag, the `best` performer pick and the `blended_avg` tile, so it stays 100% AI-derived — the human number is added alongside it under its own name, never blended in.

### Reference usage (human review)

Four consumers render this layer through the same gate: the **Zoom Recordings dashboard**, **Phone Call History**, **Showroom F2F** — and a fourth, easy to miss: the **Lead Show page**. `LeadsController@show` ([app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php), its `callDetail`/`f2fDetail`/`zoomDetail` on-demand blocks) calls the identical presenters/builder with the identical `RecordingReviewGate::allows()` call, so a review posted from a dashboard modal also renders in that lead's own Channel tabs. A future change to a presenter's or the builder's signature must update this fourth call site too.

The canonical read path is **`RecordingsController::resolveDetail()`** ([app/Http/Controllers/Manage/Zoom/RecordingsController.php](/app/Http/Controllers/Manage/Zoom/RecordingsController.php)): it computes `RecordingReviewGate::allows($user, $meeting)` once and calls `app(ZoomRecordingDetailBuilder::class)->build($meeting, $canReview, $user?->id)` — the **builder**, not `toDetailArray()` directly, is the actual Zoom call site. `ZoomRecordingDetailBuilder::build()` forwards those same two values into `$meeting->toDetailArray($canReview, $viewerId)` internally, then layers the webinar engagement payload on top. Phone Call ([`HistoryController::detail()`](/app/Http/Controllers/Manage/Calls/HistoryController.php)) and Showroom F2F ([`ShowroomController::detail()`](/app/Http/Controllers/Manage/F2f/ShowroomController.php)) call their own presenter's `detail()` the identical way.

The write path is the shared **[`RecordingReviewsController`](/app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php)** — `store`/`update`/`destroy` under `/manage/recording-reviews` (`manage.conversation.recording-reviews.*`), gated by `permission:review-sales-performance` at the route AND `RecordingReviewGate::allows()` again in `store()` for the cross-module check. The request shape is the spec's most-emphasised decision: **`reviewable_type` is the whitelist KEY** (`zoom`/`call`/`f2f` — `RecordingReviewGate::TARGETS`, never a raw model class), and the target field is **`reviewable_uuid`, never `reviewable_id`** — the latter is the polymorphic morph column's own name, holding the resolved model's INTERNAL id server-side only; a request field sharing that name is exactly how a client-supplied numeric id would end up addressable. `update`/`destroy` route on the review's OWN uuid and the Form Request drops the target fields entirely (immutable after create).

On the frontend, [`SalesPerformanceTab.vue`](/resources/js/Components/RecordingDetail/Tabs/SalesPerformanceTab.vue) stacks the unchanged AI block (with an "AI generated" badge added in the TAB, not inside `AnalysisSections.vue`, which stays pure) above [`ReviewList.vue`](/resources/js/Components/RecordingDetail/Tabs/ReviewList.vue) + [`ReviewForm.vue`](/resources/js/Components/RecordingDetail/Tabs/ReviewForm.vue).

> **Two `Reference usage` sections, deliberately.** This doc now has one for the AI analysis path (above) and one for the human review layer (here) — a conscious split, not drift: they are two genuinely different consumption paths (a queued analysis job vs. a synchronous request/response CRUD) with different canonical examples. Keep them separate rather than merging; update whichever's canonical consumer changes independently of the other.

### Notify — the recording's owner is pushed a heads-up (Phase 6)

`RecordingReviewsController::store()` — never `update()` — pushes the reviewed recording's **owner** (the agent being rated, not the reviewer) a Telegram heads-up through the shared [Notify](/docs/modules_handbook/shared/notify/readMe.md) service, via `Notifier::sendToUsers('conversation.review_received', …)` — the second `sendToUsers()` consumer after `leads.action_item_assigned`. Full mechanics live in the Notify handbook; the review-specific pieces:

- **The recipient is resolved through an extra hop.** `sendToUsers()` takes **user** ids, but a recording's owner is `admin_id` → an `Admin` row → its `user_id` — the *same* admin/user distinction that governs `reviewer_id` (see above), and the two must never be confused. A null `admin_id` (an untagged recording) skips silently; the acting reviewer is always excluded via `array_diff`, so rating your own recording notifies nobody.
- **Score only, never the comment.** The Telegram transport sends `parse_mode => 'HTML'`; a comment is free text an admin typed, so it is left out of the message entirely rather than escaped — deletes that bug class instead of merely guarding it. The push is fixed text plus the score (`"{reviewer} reviewed your recording — {score}/10. Open to read the full feedback."` — `%d`, never a truthiness check, so a score of `0` still reads correctly) and a deep link.
- **The deep link lands on the Sales performance tab, not Overview.** Since the push withholds the comment, the link is the *only* path to it. `RecordingReviewGate::deepLinkFor($typeKey, $uuid)` builds `?detail={uuid}&tab=sales` on the owning module's own index route, off the SAME `TARGETS` whitelist map `modelFor()`/`typeKeyFor()` use (now also carrying each type's index route name) — `SALES_PERFORMANCE_TAB` is the one place the tab key `'sales'` is named, matching `RecordingDetail.vue`'s own tab key.
  - Getting the modal to actually open on that tab needed one prop threaded through three layers, all while keeping `syncTabUrl: false` everywhere (`ShowTabs.vue`'s `modelValue` resolves before it ever consults `syncUrl`, so this needed no change to the sync behaviour any existing consumer relies on): each Index page reads the sibling `?tab=` param itself → passes `initial-tab` into its own `ZoomRecordingDetailModal` / `CallDetailDrawer` / `F2fDetailModal` → which forward it into `RecordingDetail` → passed as `:model-value` on `ShowTabs`. An ordinary "View" row click clears the captured tab first, so an unrelated recording never inherits a stale tab left over from an earlier deep link — and closing the modal strips both `?detail` and `?tab` from the URL together.
  - **Zoom Recordings Index.vue did not open its modal on a bare page load with `?detail=` present** (`showDetail` started at a hardcoded `ref(false)`, unlike Calls History / F2f Showroom's `ref(!!props.detail)`) — a pre-existing gap, fixed here because the deep link cannot work without it.
- **The subscription backfill is a one-off data migration, never a repeatable command** — see the Notify handbook's Consumer section for the full "why" (in short: unticking an event deletes the row rather than flipping `is_active`, so "opted out" and "never offered the choice" are the same fact, and only a migration can guarantee it never replays).

## Related files

**Backend — service + schema + prompts**
- [src/Conversation/ConversationAnalyzer.php](/src/Conversation/ConversationAnalyzer.php) — the shared service (`analyze` / `translate` / `isConfigured`; provider+model resolution; AiClient-backed).
- [src/Conversation/ConversationAnalysis.php](/src/Conversation/ConversationAnalysis.php) — the schema: enum vocabularies + `normalize()` (defaults, clamping, extra-key passthrough).
- [resources/prompts/conversation_analysis.md](/resources/prompts/conversation_analysis.md) · [resources/prompts/conversation_translate.md](/resources/prompts/conversation_translate.md) — prompt bodies.
- [config/ai_prompts.php](/config/ai_prompts.php) — registers `conversation_analysis` + `conversation_translate` (`AiRequest::PROMPT_*`).

**Backend — consumers (analysis jobs)**
- [app/Jobs/Calls/AnalyzeCallRecording.php](/app/Jobs/Calls/AnalyzeCallRecording.php) · [app/Jobs/F2f/AnalyzeF2fRecording.php](/app/Jobs/F2f/AnalyzeF2fRecording.php) (plain queue, fail-soft) · [app/Jobs/Ai/AnalyzeZoomMeeting.php](/app/Jobs/Ai/AnalyzeZoomMeeting.php) (`AiJob`, resilient lane).
- Persistence: each repository's `recordAnalysis()` (Calls/F2f) / `saveAnalysis()` (Zoom) writes the whole schema to `ai_analysis`; `recordAnalysisTranslation()` writes `ai_analysis_zh`.

**Backend — translate endpoint (shared)**
- [app/Http/Controllers/Concerns/TranslatesAnalysis.php](/app/Http/Controllers/Concerns/TranslatesAnalysis.php) — the shared `respondWithAnalysisTranslation()` flow, used by `CallRecordingsController` / `ShowroomController` / `Zoom\RecordingsController` (each exposes the same `…recordings/{id}/translate-analysis` route → `translateAnalysis()`).

**Frontend — one shared tabbed detail** — all three modules render the analysis through the shared **[`RecordingDetail`](/resources/js/Components/RecordingDetail/RecordingDetail.vue)** (Zoom Show page; Phone Call + F2f inside their detail modal), which splits the schema across tabs: **AI analysis** (summary + type/sentiment/interest badges + extra fields), **Customer**, **Sales performance**, **Meeting report** (+ Overview + Transcript).
- [resources/js/Components/RecordingDetail/RecordingPlayer.vue](/resources/js/Components/RecordingDetail/RecordingPlayer.vue) — the media player, pinned **above the tab strip** (not inside a tab) so it stays mounted across tab switches and playback keeps going while the admin reads the Transcript / AI analysis. Zoom: proxy-streamed `<video>`/`<audio>` (`/manage/zoom-recordings/{id}/stream`) + a source switcher when >1 streamable file; Phone Call / F2f: a single `<audio>` fed by `audio_url`. `RecordingDetail` shows it only when there's playable media (`hasPlayer`); the **Overview** tab ([BasicInfoTab.vue](/resources/js/Components/RecordingDetail/Tabs/BasicInfoTab.vue)) keeps metadata, the petaV2 source lineage, and a Zoom **files** reference list (type/size + external "Open" link).
- [resources/js/Components/RecordingDetail/AnalysisSections.vue](/resources/js/Components/RecordingDetail/AnalysisSections.vue) — the pure section renderer (receives a resolved analysis object + an `only` filter; "Additional details" catch-all); one source of truth for every analysis tab.
- [resources/js/Components/RecordingDetail/AnalysisLangToggle.vue](/resources/js/Components/RecordingDetail/AnalysisLangToggle.vue) — the EN/中文 toggle / "译中文" button; the shared language state + translate action are owned by `RecordingDetail` and shared across the analysis tabs.
- [resources/js/Components/AiValue.vue](/resources/js/Components/AiValue.vue) — generic recursive renderer the catch-all (and the imported petaV2 meeting-report fallback) uses.

**Config + settings**
- [config/ai.php](/config/ai.php) — `conversation_analysis` defaults (provider / model) + `pricing`.
- The `conversation_analysis` model pin — admin-set on the AI Prompts page ([AiPromptsController@updateModel](/app/Http/Controllers/Manage/Integrations/AiPromptsController.php) + [Prompts.vue](/resources/js/Pages/Manage/Integrations/Ai/Prompts.vue)); read via [AiPrompt::pinFor()](/src/Ai/AiPrompt.php). The prompt bodies themselves are also admin-editable + versioned there.

**Backend — human review layer (`Src\Conversation`) — new files**
- [src/Conversation/ConversationReview.php](/src/Conversation/ConversationReview.php) — the polymorphic review row (`reviewable_type`/`reviewable_id`, `reviewer_id` a `users.id`, `score` 0–10 NOT NULL, `comment`); `toShowArray(int $viewerId)` is the presentation method every consumer renders from.
- [src/Conversation/Concerns/HasConversationPerformance.php](/src/Conversation/Concerns/HasConversationPerformance.php) — `reviews()` / `humanAverageScore()` / `reviewCount()`, shared by `ZoomMeeting`, `CallRecording` and `F2fRecording`.
- [src/Conversation/Repositories/ConversationReviewRepository.php](/src/Conversation/Repositories/ConversationReviewRepository.php) — `create`/`update`/`delete`, each transactional, each returning the refreshed model (`delete()`'s `refresh()` is load-bearing — see above).
- [src/Conversation/Support/RecordingReviewGate.php](/src/Conversation/Support/RecordingReviewGate.php) — the whitelisted type-key → model map + the three-clause `allows()` gate shared by the write endpoint and every detail payload.
- [app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php](/app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php) — `store`/`update`/`destroy` under `/manage/recording-reviews` (`manage.conversation.recording-reviews.*`); `update`/`destroy` are author-only ONLY (see above — the module-view/webinar clauses are not re-checked there).
- [app/Http/Requests/Manage/Conversation/RecordingReviews/StoreRequest.php](/app/Http/Requests/Manage/Conversation/RecordingReviews/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Manage/Conversation/RecordingReviews/UpdateRequest.php) — score `between:{SCORE_MIN},{SCORE_MAX}` + `comment` `max:5000`; `UpdateRequest` merges from `StoreRequest` rather than re-declaring.
- [src/Auth/Permission.php](/src/Auth/Permission.php) — `REVIEW_SALES_PERFORMANCE` (`review-sales-performance`), its own single-entry Roles-page group.

**Backend — human review layer — modified by this feature**
- [src/Zoom/Services/ZoomRecordingDetailBuilder.php](/src/Zoom/Services/ZoomRecordingDetailBuilder.php) — `build()` gained the same required `$canReview`/`$viewerId` pair as `toDetailArray()`, which it forwards to it — the ACTUAL Zoom call site (see Reference usage above).
- [src/Zoom/ZoomMeeting.php](/src/Zoom/ZoomMeeting.php) — `toDetailArray()` now requires `$canReview`/`$viewerId` and emits `can_review`/`reviews`.
- [src/Call/Support/CallRecordingPresenter.php](/src/Call/Support/CallRecordingPresenter.php) · [src/F2f/Support/F2fRecordingPresenter.php](/src/F2f/Support/F2fRecordingPresenter.php) — `detail()` on both gained the identical required pair + the identical two keys.
- [app/Http/Controllers/Manage/Zoom/RecordingsController.php](/app/Http/Controllers/Manage/Zoom/RecordingsController.php) · [app/Http/Controllers/Manage/Calls/HistoryController.php](/app/Http/Controllers/Manage/Calls/HistoryController.php) · [app/Http/Controllers/Manage/F2f/ShowroomController.php](/app/Http/Controllers/Manage/F2f/ShowroomController.php) · [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — each computes `RecordingReviewGate::allows()` and threads it into the builder/presenter (the fourth, Lead-page, consumer — see Reference usage above).
- [app/Http/Controllers/Manage/People/RolesController.php](/app/Http/Controllers/Manage/People/RolesController.php) — `permissionSections()`'s `$layout` gained a `'Sales Performance Review'` entry (else the new permission group would land in "Other").
- [app/Http/Controllers/Manage/F2f/F2fDashboardController.php](/app/Http/Controllers/Manage/F2f/F2fDashboardController.php) · [app/Http/Controllers/Manage/Calls/DashboardController.php](/app/Http/Controllers/Manage/Calls/DashboardController.php) · [app/Http/Controllers/Manage/Zoom/ZoomDashboardController.php](/app/Http/Controllers/Manage/Zoom/ZoomDashboardController.php) — each `agentRows()` gained the parallel human-review keys (see above).
- [app/Http/Controllers/Manage/Ai/CopilotController.php](/app/Http/Controllers/Manage/Ai/CopilotController.php) — `agentsPanel()` gained `human_avg_score`/`human_review_count` alongside (never inside) `avg_score`.

**Frontend — human review layer — new files**
- [resources/js/Components/RecordingDetail/Tabs/SalesPerformanceTab.vue](/resources/js/Components/RecordingDetail/Tabs/SalesPerformanceTab.vue) — replaces the generic `AnalysisTab` at the sales slot; stacks the AI block (with the "AI generated" badge) above the human block.
- [resources/js/Components/RecordingDetail/Tabs/ReviewList.vue](/resources/js/Components/RecordingDetail/Tabs/ReviewList.vue) — reviewer/timestamp/score badge/comment; inline edit + `ConfirmModal` delete, author-only. Its local `scoreTone()` deliberately mirrors `AnalysisSections`' bands (≥7/≥4), NOT `AgentPerformanceTable`'s (≥8/≥5) — the two badges stack in one tab, so they must agree; the repo has two diverged `scoreTone` families on purpose and unifying them is a separate decision.
- [resources/js/Components/RecordingDetail/Tabs/ReviewForm.vue](/resources/js/Components/RecordingDetail/Tabs/ReviewForm.vue) — rendered only when `can_review`; a 0–10 button-row picker (never a truthiness-prone control) + optional comment.

**Frontend — human review layer — modified by this feature**
- [resources/js/Components/RecordingDetail/RecordingDetail.vue](/resources/js/Components/RecordingDetail/RecordingDetail.vue) — new `reviews`/`canReview`/`reviewsUrl` props; the tab-visibility rule for `sales` is now `hasAnalysis || canReview || hasReviews` (was `hasAnalysis` alone).
- [resources/js/Components/Recordings/AgentPerformanceTable.vue](/resources/js/Components/Recordings/AgentPerformanceTable.vue) — a new "Review score" column beside "AI score", reusing the file's own existing `scoreTone()` as-is (no new copy).
- [resources/js/Components/ZoomRecordingDetailModal.vue](/resources/js/Components/ZoomRecordingDetailModal.vue) · [resources/js/Pages/Manage/Calls/History/Partials/CallDetailDrawer.vue](/resources/js/Pages/Manage/Calls/History/Partials/CallDetailDrawer.vue) · [resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue](/resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue) — each threads `reviews`/`can_review` from its own detail payload into `RecordingDetail`.

**Migrations**
- [database/migrations/2026_08_04_000001_create_recording_reviews_table.php](/database/migrations/2026_08_04_000001_create_recording_reviews_table.php) — the table (no schema-level FKs); `score` is NOT NULL.
- [database/migrations/2026_08_04_100002_backfill_review_sales_performance_permission.php](/database/migrations/2026_08_04_100002_backfill_review_sales_performance_permission.php) — backfills the `review-sales-performance` permission row + grants on already-migrated installs (`RolesSeeder` only creates it on a fresh one).
- [database/migrations/2026_08_06_100001_backfill_conversation_review_received_subscriptions.php](/database/migrations/2026_08_06_100001_backfill_conversation_review_received_subscriptions.php) — Phase 6's subscription backfill (see the Notify section above + handbook).

**Routes**
- [routes/web.php](/routes/web.php) (the `recording-reviews` group, ~line 411) — `/manage/recording-reviews`, `permission:review-sales-performance` middleware, `manage.conversation.recording-reviews.{store,update,destroy}`.

**Backend — Notify push (Phase 6) — modified/new files**
- [config/notify.php](/config/notify.php) — the `conversation.review_received` registry entry (`group: 'Sales performance'`, `default: true`, `throttle: 0` — the throttle SCOPE is set on the message, `->throttleScope('review:' . $review->id)`, never in the registry).
- [app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php](/app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php) — `notifyOwner()`, called from `store()` only, after the repository write, outside any transaction.
- [src/Conversation/Support/RecordingReviewGate.php](/src/Conversation/Support/RecordingReviewGate.php) — `TARGETS` gained a `route` entry per type key; new `SALES_PERFORMANCE_TAB` const + `deepLinkFor($typeKey, $uuid)`.
- [resources/js/Components/RecordingDetail/RecordingDetail.vue](/resources/js/Components/RecordingDetail/RecordingDetail.vue) — new `initialTab` prop, passed as `:model-value` on `ShowTabs` (still `:sync-url="syncTabUrl"`, unchanged).
- [resources/js/Components/ZoomRecordingDetailModal.vue](/resources/js/Components/ZoomRecordingDetailModal.vue) · [resources/js/Pages/Manage/Calls/History/Partials/CallDetailDrawer.vue](/resources/js/Pages/Manage/Calls/History/Partials/CallDetailDrawer.vue) · [resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue](/resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue) — each gained an `initialTab` prop, forwarded into `RecordingDetail`.
- [resources/js/Pages/Manage/Zoom/Recordings/Index.vue](/resources/js/Pages/Manage/Zoom/Recordings/Index.vue) · [resources/js/Pages/Manage/Calls/History/Index.vue](/resources/js/Pages/Manage/Calls/History/Index.vue) · [resources/js/Pages/Manage/F2f/Showroom/Index.vue](/resources/js/Pages/Manage/F2f/Showroom/Index.vue) — each reads `?tab=` into an `initialTab` ref (cleared on an ordinary row click) and passes it to its own detail modal; the close handler now strips `?tab=` alongside `?detail=`. Zoom's `showDetail` also switched from a hardcoded `ref(false)` to `ref(!!props.detail)` (matching Calls/F2f), the fix that makes the deep link work there at all.

**Tests (Phase 6)**
- [tests/Feature/Manage/Conversation/RecordingReviewNotifyTest.php](/tests/Feature/Manage/Conversation/RecordingReviewNotifyTest.php) — the notify consumer through the real endpoint.
- [tests/Feature/Database/ConversationReviewReceivedSubscriptionsBackfillTest.php](/tests/Feature/Database/ConversationReviewReceivedSubscriptionsBackfillTest.php) — the subscription backfill migration in isolation.

**See also:** [Transcription](/docs/modules_handbook/shared/transcription/readMe.md) (runs before analysis) · [AI Integration](/docs/modules_handbook/shared/ai/readMe.md) (`AiClient` + `ai_requests`) · the four consumers: [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md) · [Showroom F2F](/docs/modules_handbook/manage/f2f/readMe.md) · [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) · Leads (`LeadsController@show`, no dedicated handbook entry yet for the Show page).
