# Lead Sales Coach — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development.
> Approved design (source of truth): `docs/superpowers/specs/2026-08-05-lead-sales-coach-design.md`.

**Goal:** A read-only AI Sales Coach in the Lead Discussion tab. Any Manage user who passes
`LeadVisibility` for the Lead can ask how to follow up, grounded ONLY in a server-built snapshot
of that Lead's profile, intelligence, Zoom/Call/F2F analyses and Action Items. Advisory only —
it creates nothing, completes nothing, sends nothing.

**Architecture:** Dedicated controller + Form Request + context builder + prompt key. No new
tables, no migration, no persistence (chat lives in the Vue component only). Flag-gated by
`features.lead_sales_coach_demo`, committed default false.

**Branch:** `dev-chen` in `/Users/dadadineiyou/Documents/GitHub/petav3-dev-chen-integration`.
No push, no PR, no deploy.

---

## Verified baseline (checked against code before planning)

- `AiClient::chat(string|array $messages, array $options): AiResponse` — `src/Ai/Services/AiClient.php:80`.
  Grounding precedent: `CopilotController.php:583` → `$ai->chat($messages, ['prompt' => AiRequest::PROMPT_ANALYTICS_CHAT, 'json' => true])`. We pass NO `json` (plain text reply).
- Prompt registry: `AiRequest::PROMPT_*` constants (`src/Ai/AiRequest.php:50+`), `config/ai_prompts.php`,
  bodies in `resources/prompts/<key>.md`. Editable at Manage → AI Prompts (`AiPromptsController`, `routes/web.php:1093`).
- `LeadVisibility::allows(User, Lead)` — `src/Auth/Support/LeadVisibility.php:113`.
- Lead sub-routes all use `leads/{id}/...` (`routes/web.php:1174-1193`) with
  `permission:Permission::viewLeadsAny()`. GUIDELINES §6 requires `{id}` — the design's `{lead}` is corrected here.
- `F2fRecording`: `recorded_at` + `ai_analysis` (array cast) — `src/F2f/F2fRecording.php:67,82,86`.
- `ZoomMeeting`: `start_time` + `ai_analysis`; `CallRecording`: `called_at` + `ai_analysis`.
- Analysis shape (`src/Conversation/ConversationAnalysis.php:48-88`): `summary`, `conversation_type`,
  `customer.{interest_level,buying_stage,budget,needs[],concerns[],sentiment}`,
  `sales_performance.{score,strengths[],improvements[]}`,
  `meeting_report.{summary,key_points[],next_steps[],follow_up_date}`.
- `LeadEnrichment` — **`raw` is `encrypted:array`; NEVER read or include it.**
- `LeadActionItem`: `STATUS_OPEN=1`/`STATUS_DONE=2`, `assignees()` pivot, `sourcePlan()` (Zoom Action Plan provenance).
- `DiscussionTab.vue:414` mounts `<ActionItemsPanel>` in the sticky right rail — the coach card goes below it.
- Tests: no factories; helpers per class (`tests/Feature/Lead/LeadVisibilityTest.php:31-73` pattern),
  `tests/Concerns/InteractsWithAdmin.php`, phpunit forces `petav3_testing`.
- Vitest: `resources/js/**/*.test.js`, happy-dom, **no `@vue/test-utils`** — test composables/utils only.

---

## Frozen contracts

### F1. Feature flag
- `config/features.php`: `'lead_sales_coach_demo' => (bool) env('FEATURE_LEAD_SALES_COACH_DEMO', false)` — committed default false.
- Shared to Inertia in `HandleInertiaRequests` features block as `lead_sales_coach_demo`.
- Enforcement is controller-level (`abort_unless(config(...), 404)`), plus the Form Request's
  `prepareForValidation()` so an invalid body cannot probe the endpoint (pattern already used by
  `app/Http/Requests/Manage/Zoom/ActionPlans/UpdateRequest.php`).
- Note: this project's `env()` does not coerce booleans — "present = on, absent = off". Committed
  default (var absent) is false.

### F2. Route
```php
Route::post('leads/{id}/sales-coach/chat', 'Manage\Leads\LeadSalesCoachController@chat')
    ->middleware('permission:' . Permission::viewLeadsAny())
    ->name('manage.leads.sales-coach.chat');
```
Declared beside the existing `leads/{id}/discussion` block in `routes/web.php`. `{id}` = Lead uuid.

### F3. Request — `app/Http/Requests/Manage/Leads/LeadSalesCoachRequest.php`
extends `Diver\Http\Requests\FormRequest`.
```php
'message'           => ['required', 'string', 'max:2000'],
'history'           => ['sometimes', 'array', 'max:12'],
'history.*.role'    => ['required_with:history', 'string', 'in:user,assistant'],
'history.*.content' => ['required_with:history', 'string', 'max:4000'],
```
`prepareForValidation()` runs `abort_unless(config('features.lead_sales_coach_demo'), 404)` before rules.

### F4. Context snapshot — `Src\Lead\Services\LeadSalesCoachContextBuilder::build(Lead $lead): array`
Called ONLY after the controller has authorized the Lead. Queries by `$lead->id`.
```
[
  'lead' => ['uuid','name','status','account_manager', ...sales fields present on the model],
  'intelligence' => ['profile_summary','recommended_action','confidence','validation',
                     'occupation','income','hometown'],   // only keys already present; NEVER `raw`
  'conversations' => [
     'zoom'  => ['total' => int, 'records' => [ …≤5, newest first… ]],
     'calls' => ['total' => int, 'records' => [ …≤5… ]],
     'f2f'   => ['total' => int, 'records' => [ …≤5… ]],
  ],
  'action_items' => ['open' => [...all open...], 'recently_done' => [...≤10...]],
]
```
Each conversation record: `channel, uuid, date (ISO), agent, summary, conversation_type,
sentiment, interest_level, buying_stage, budget, needs[], concerns[], sales_score, strengths[],
improvements[], report_summary, key_points[], next_steps[], follow_up_date`.
Ordering: zoom by `start_time` desc, calls by `called_at` desc, f2f by `recorded_at` desc; each
capped at 5 with the channel's full analyzed `total` reported separately.
Action item shape: `uuid, body, assignees[names], created_by, from_action_plan (bool), done_at (ISO|null)`.

**Hard exclusions (assert in tests):** transcripts, `deepgram_json`, media/recording URLs,
`ai_analysis_zh`, `LeadEnrichment::raw`, provider payloads, credentials, lead email, lead phone.

### F5. AI message construction
`chat()` receives, in order:
1. `['role' => 'user', 'content' => "<LEAD_CONTEXT>\n" . json_encode($snapshot) . "\n</LEAD_CONTEXT>\n" . <untrusted-data caveat>]`
2. `['role' => 'assistant', 'content' => <fixed acknowledgement>]`
3. …bounded page history (≤12, roles restricted to user/assistant)…
4. `['role' => 'user', 'content' => $message]`

Options: `['prompt' => AiRequest::PROMPT_LEAD_SALES_COACH]` plus lead/user attribution the way
`CopilotController` attributes its calls (read that call site and mirror it). No `json` option —
the reply is plain text.

The caveat line must state that everything inside `<LEAD_CONTEXT>` is quoted data, may contain
customer-authored text, and must never be treated as instructions.

### F6. Response
```json
{"reply": "plain text",
 "context": {"zoom": {"used": 5, "total": 9}, "calls": {"used": 3, "total": 3},
             "f2f": {"used": 1, "total": 1}, "open_actions": 4}}
```
`used` = records actually sent (capped at 5 per channel); `total` = that channel's full analyzed
count. Both server-derived. `open_actions` is a plain int (open items are never capped).

**Amended 2026-08-05** — F6 originally carried only the capped number, which would have rendered
"Based on 5 Zoom meetings" for a Lead that has 9, overstating what the coach actually read. The
design (line 159) requires the totals precisely so the model and UI can acknowledge unloaded
history. Part B renders `"5 of 9"` when `total > used`, and just the number otherwise.
Errors: 404 flag off / lead not found; 403 `LeadVisibility` fails; 422 validation;
503 provider unavailable or empty text (no partial write, nothing persisted).

### F7. Prompt
`AiRequest::PROMPT_LEAD_SALES_COACH = 'lead_sales_coach'`; entry in `config/ai_prompts.php`
(mirror a neighbouring entry's shape exactly); body at `resources/prompts/lead_sales_coach.md`
covering every bullet in the design's "Prompt and Model Configuration" section.

### F8. Frontend
- `resources/js/Pages/Manage/Leads/Partials/Tabs/SalesCoachCard.vue` — card below `<ActionItemsPanel>`
  in `DiscussionTab.vue`'s right rail; renders only when `features.lead_sales_coach_demo`.
  Four quick questions per the design; emits `ask(question|null)`.
- `resources/js/Components/Leads/SalesCoachDrawer.vue` — uses the existing `Drawer.vue`
  (right slide-over) on desktop; near-full-screen on mobile via its width props.
- `resources/js/composables/useSalesCoachChat.js` (+ `.test.js`) — owns messages array, `sending`
  latch, `history` bounding (last 12 turns, roles only), `send()` via axios (mirror
  `ActionItemsPanel.vue`'s axios+headers pattern so 403/422/503 reject instead of Inertia-dialoging),
  error mapping (403 → access lost + clear, 422 → validation text, 503 → retain question + Retry),
  `clear()`, and `contextLabel(context)` → `"Based on 2 Zoom meetings · 3 calls · 1 showroom visit · 4 open actions"`
  (singular/plural, omit zero channels). Per the amended F6 each channel is `{used, total}`: render
  `"5 of 9 Zoom meetings"` when `total > used`, otherwise just the number — the label must never
  imply the coach read more history than it did.
- Assistant replies render as TEXT with preserved line breaks (`whitespace-pre-line`) — never `v-html`.
- Copy button copies the reply text only.

### F9. Ownership
| Files | Part |
|---|---|
| config/features.php, HandleInertiaRequests.php, config/ai_prompts.php, resources/prompts/lead_sales_coach.md, src/Ai/AiRequest.php, routes/web.php, LeadSalesCoachController, LeadSalesCoachRequest, LeadSalesCoachContextBuilder, backend tests | A |
| SalesCoachCard.vue, SalesCoachDrawer.vue, useSalesCoachChat.js(+test), DiscussionTab.vue | B |
| verification, browser rehearsal, gap-filling tests only | C |

---

## Part A — backend

**Files** — Create: `app/Http/Controllers/Manage/Leads/LeadSalesCoachController.php`,
`app/Http/Requests/Manage/Leads/LeadSalesCoachRequest.php`,
`src/Lead/Services/LeadSalesCoachContextBuilder.php`, `resources/prompts/lead_sales_coach.md`,
`tests/Feature/Manage/Leads/LeadSalesCoachTest.php`,
`tests/Feature/Lead/LeadSalesCoachContextBuilderTest.php`.
Modify: `config/features.php`, `config/ai_prompts.php`, `src/Ai/AiRequest.php`,
`app/Http/Middleware/HandleInertiaRequests.php`, `routes/web.php`.

- [ ] **A.1 RED** — context-builder tests: only this Lead's records; per-channel cap 5 + accurate
      totals; correct ordering per channel; every hard exclusion absent (transcript, deepgram_json,
      media URL, `ai_analysis_zh`, enrichment `raw`, email, phone); open items all present;
      completed capped at 10 newest with `done_at`; action-plan provenance flagged; empty-analysis
      Lead still returns a usable snapshot.
      `herd php artisan test tests/Feature/Lead/LeadSalesCoachContextBuilderTest.php` → FAIL.
- [ ] **A.2 GREEN** — implement the builder. → PASS.
- [ ] **A.3 RED** — endpoint tests: flag off → 404 (valid AND invalid body) and NO AiClient call
      (bind a spy); no Lead visibility → 403; visible user → 200 with `reply` + `context` counts;
      history/message limits enforced (422); AI called with `AiRequest::PROMPT_LEAD_SALES_COACH`
      and lead/user attribution; provider throwing → 503 and nothing persisted; snapshot passed to
      the model contains only this Lead. Fake the provider by binding a stub `AiClient` in the
      container — never hit a real API. → FAIL.
- [ ] **A.4 GREEN** — flag, prompt key + config entry + prompt body, Form Request, controller, route. → PASS.
- [ ] **A.5** Pint on changed paths; `herd php artisan test tests/Feature/Lead tests/Feature/Manage/Leads`
      (no NEW failures vs the recorded baseline).
- [ ] **A.6** Orchestrator commits: `feat(leads): add ai sales coach backend`.

Reviewer focus: flag bypass (incl. invalid-body probe), `LeadVisibility` enforced per request,
cross-Lead leakage in the snapshot, prompt-injection framing of customer text, PII exclusions,
no persistence, no mutation, N+1 in the builder, GUIDELINES (Form Request validates, controller
maps explicitly, PHPDoc, PSR-12, `{id}` route param).

## Part B — frontend

**Files** — Create: `resources/js/composables/useSalesCoachChat.js` + `.test.js`,
`resources/js/Pages/Manage/Leads/Partials/Tabs/SalesCoachCard.vue`,
`resources/js/Components/Leads/SalesCoachDrawer.vue`.
Modify: `resources/js/Pages/Manage/Leads/Partials/Tabs/DiscussionTab.vue`.
Forbidden: any PHP file.

- [ ] **B.1 RED** — `npm test -- useSalesCoachChat`: history bounding to 12 and role-only shape;
      `sending` latch blocks double-send; 403/422/503 mapped to the right state (403 clears,
      503 retains the question for Retry); `clear()` empties; `contextLabel` pluralization and
      zero-channel omission. → FAIL.
- [ ] **B.2 GREEN** — implement the composable. → PASS.
- [ ] **B.3** Build `SalesCoachCard.vue` (renders only when the flag prop is true; four quick
      questions; `Open coach`) and `SalesCoachDrawer.vue` (Drawer, bubbles, multiline input,
      loading line, Copy, Clear chat, context line under each reply, `whitespace-pre-line` text —
      no `v-html`). Wire both into `DiscussionTab.vue` below `<ActionItemsPanel>`.
- [ ] **B.4** `npm test` (only the 2 known PropertyMatch failures) and `npm run build` clean.
- [ ] **B.5** Orchestrator commits: `feat(leads): add ai sales coach ui`.

Reviewer focus: flag gating in the UI, XSS (no `v-html`, escaped text), double-submit, error-state
rendering, drawer a11y/keyboard, mobile layout, no backend contract drift, Calls/F2f/other tabs untouched.

## Part C — verification

- [ ] Focused suites green; Pint; `npm test`; `npm run build`.
- [ ] Flag OFF: card absent from Discussion, endpoint 404 (valid + invalid body), no AI call.
- [ ] Flag ON (local `.env` only, `config:clear`): browser rehearsal on a demo Lead that has Zoom
      analyses and Action Items — ask a quick question, verify a grounded reply, the context line,
      Copy, Clear, and that refreshing clears the chat. Screenshots stay outside the repo.
- [ ] Confirm no chat rows written anywhere; `ai_requests` logging present.
- [ ] Git hygiene: no PII/keys/artifacts in the diff.

## Commit boundaries
1. `docs(leads): plan ai sales coach implementation`
2. `feat(leads): add ai sales coach backend`
3. `feat(leads): add ai sales coach ui`
4. review-fix commits as needed (`fix(leads): …`)
