# Investment Prompt (Main · User Portal)

**Portal:** Main · **Routes:** `main.portal.investment-prompt.*` at `/ai/investment-prompt` · **Nav:** sidebar → **AI Coach** → the **Investment Prompt** tab · **Gated by:** `['auth','main']`

## What it does
The **Master Playbook** — 66 git-versioned AI-prompt "plays" across 6 asset classes (Property ·
Stocks · Crypto · Gold · Options · Forex). Each play turns a paid-course worth of methodology into
a guided flow: a 3-part intro (what it replaces / what AI teaches / what AI cannot know) → an
intake form ("your situation") → a **streamed analyst-grade brief** with agent-style thinking
steps → open-ended follow-up chat as the same expert. Property plays additionally **inject live
asking-listing stats** from the master catalogue when the member names a building the system
knows (the "Database Gap" moat).

> **Every run is an AI Chatbot thread.** A run is stored as a normal `ai_conversations` row owned
> by the member's lead, linked to its play by slug (`playbook_key`) — the same pattern as PETA
> Scout (`property_analysis_id`) and the wealth assistant (`wealth_plan_id`). So past runs appear
> BOTH on this page's 历史记录 tab AND in [AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md)
> history (and on the admin's Leads → Show → Portal Engagement → AI Conversations tab), with
> nothing extra to wire. Runs do **not** spend the member's AI credits.

## How it works
- **Content is code, not DB.** `PlaybookCatalog` holds the 34 Property plays (each: intro copy,
  `form[]` schema of text/rm/chips fields, the verbatim master `prompt` with `{{key}}`
  placeholders + a `{{LIVE_DATA}}` slot, and `protips[]` follow-up chips); the 32 Wealth plays
  live in the auto-generated `PlaybookWealthPlays.php` data file (regenerate from the source
  docx — do not hand-edit). **The master prompt bodies never reach the browser**: the `plays`
  Inertia prop is the catalog stripped of them.
- **`index`** renders the shell: stripped catalog + `groups()` + `categories()` (Property
  sub-categories), the lead's past runs (`playbook_key` not null, latest 50), and — via
  `?run={uuid}` — one reopened run with its messages, injected stats and protips.
- **`run` (SSE).** Validation is `RunRequest` (play exists + its own required fields, checked
  against the catalog in `withValidator`). The controller then: emits the source playbook's
  `step` events (read → match → prompt → ai) · matches live data
  (`PlaybookService::injectLiveData` — reverse-LIKE then shingle search over `catalog_projects`
  on the **catalogue connection**; failure just skips the block) · creates the conversation
  (`AiConversationRepository::createForPlaybook`) opening with a **human-readable run summary**
  as the first user message (so the thread replays validly and reads correctly in AI Chatbot —
  the master prompt itself is never persisted as a message) · streams
  `AiClient::stream()` under the `investment_playbook` prompt key (`max_tokens` 8192,
  `thinking: minimal`) · persists the brief (or a failed assistant message) · emits `done` with
  title/injected/protips.
- **`chat` (SSE).** Follow-ups on an owned run replay the thread's **anchor** (run summary +
  brief, always) plus the last 12 turns; the system prompt is the registry persona **plus**
  `PlaybookService::chatContext()` (play title, consistency rules, the same live-data block).
  Continuing the thread from inside AI Chatbot instead works like any other thread there
  (generic persona, member credits) — same trade-off as the wealth threads.
- **Who pays + budget.** The **company key** (the AI handbook's rule of thumb: only AI
  Conversations bills the member). The route throttles (`run` 6/min, `chat` 20/min) and the
  controller's daily caps (20 runs, 60 questions per user) are the budget.
- **Frontend.** One Inertia page (`InvestmentPrompt/Index.vue`) holding the four views
  (home catalog + history / intro / form / chat). SSE consumed by the shared `useAiStream`
  composable (extended with `body` + `onStep` for the run call); briefs rendered by the shared
  `utils/markdown.js` (extended with pipe tables, blockquotes and rules — briefs use them).
  Enter-to-send guards IME composition (Chinese typing).
- **A brief can DRAW (2026-08-26).** The numbers are the point of a brief and a wall of prose
  buries them, so the model may emit a ```viz fenced block — a small JSON spec in one of five
  shapes (`kpi` · `bar` · `line` · `waterfall` · `gauge`) — which the portal renders as an
  animated SVG card between the paragraphs. Three things make this work:
    - **The model never draws.** It emits data, not markup. `utils/vizSpec.js` coerces, clamps
      and truncates every field and returns `null` for anything it cannot vouch for; the
      components only ever receive numbers and short strings. Raw model SVG/HTML would undo
      exactly what `markdown.js` exists to prevent, so it is not an option — not even "just for
      charts". A spec that fails validation degrades to a visible code block, never to nothing.
    - **`v-html` cannot mount a component**, so the brief could not have a chart *inside* it
      while it was one HTML string. `renderSegments()` splits the source into prose segments
      (still `v-html`, same escaped-first renderer) and viz segments (real components), and
      `Components/Brief/BriefBody.vue` renders the list. Every AI message on the page goes
      through it — the streaming bubble included.
    - **Streaming is the constraint that shapes the rest.** A ```viz block is not parsed until
      its closing fence lands (before that it is a skeleton), and completed segments are keyed
      by their contents — which are append-only — so a chart mounts once and never re-runs its
      entry animation as the remaining 3,000 tokens arrive. Animate-on-every-render is the
      failure mode here, and it looks like a twitch, not like a bug.
- **Animation: no animation library.** The reveals are CSS transitions plus SVG geometry — a
  `scaleX` bar, a `pathLength="1"` stroke-draw for the line and the gauge arc (the only way to
  animate a non-uniformly scaled path correctly), the shared `AnimatedNumber` for figures. A
  timeline engine (GSAP) would sequence many elements against one clock, but each visual here
  appears at a different moment in the stream, so there is no shared clock to conduct. Every
  reveal runs through `composables/useVizReveal.js`, which starts in the final state under
  `prefers-reduced-motion`.
- **Charts never carry a conclusion alone.** The prompt contract (`VISUAL_STYLE`) requires prose
  around every block, caps a brief at 1–3 charts and a follow-up at one, and forbids charting a
  number the model had to invent to fill it. A thread read back in AI Chatbot renders as plain
  Markdown, so a conclusion that lived only in a chart would be lost there.
- **The hero band is SHARED with Vibe Coding (2026-09-02).** The navy masthead — grid, glows, eyebrow,
  counted-up figures and the glass mode toggle — moved into
  [`Components/Portal/PortalHero.vue`](/resources/js/Components/Portal/PortalHero.vue) +
  [`HeroStats`](/resources/js/Components/Portal/HeroStats.vue) /
  [`HeroStat`](/resources/js/Components/Portal/HeroStat.vue) (the count-up) +
  [`HeroToggle`](/resources/js/Components/Portal/HeroToggle.vue), and
  [Vibe Coding](/docs/modules_handbook/manage/ai-elearning/readMe.md) now wears the same one. They are
  two tabs of ONE hub, so the band IS the thing a member reads as "same product" — and a band copied by
  eye drifts on the first change, with nothing to notice it except flipping between the two tabs.
  `HeroStat` also fixed a bug the inline version had: a figure now **snaps** when its prop changes and
  only counts up on ENTRY, so a partial reload elsewhere on the page cannot re-run the animation as a
  twitch in the corner of the band. The catalogue figure is still dropped rather than shown as zero — it
  is the Database Gap claim, and `实时楼盘库 0` makes it a claim we cannot back.
- **Design: portal-native, not the source package's.** The supplied design shipped its own
  identity — a Fraunces display serif, a 36-48px editorial hero and a private ink palette
  (`#0A0F1C`…), with near-black filled active states. That was **harmonized to the portal**
  (2026-08-26, on request): **Inter only** (no second webfont, no `<Head>` font link),
  **app-scale type** (`text-2xl` page title, `text-sm` body — not a landing page inside an
  app), and the **`gray` / `navy` / `brand` tokens** every other portal page uses, so active
  states are brand-blue like the rest of the product. The mode tabs deliberately copy
  `PortalAiTabs`' segmented language (gray track, brand-filled pill) and the intake fields copy
  the `SettingsModal` input convention. **The flow, layout and copy are unchanged** — only
  typography and color moved. The chat surface is still page-local (its own bubbles + composer)
  rather than the shared `Conversation/` components; converging them is the remaining step if
  this should look identical to AI Chatbot.
- **The view lives in the URL, and that is a bug fix.** Which view you are on used to be pure
  component state, so any FULL page load dropped the member on the catalog with the brief gone
  from the screen — reported as "sometimes would revert back to main page automatically". The
  trigger is not rare and is not this page's fault: after every deploy Inertia answers a stale
  tab with **409 + `X-Inertia-Location`**, which is a genuine `window.location.reload()` (and it
  is right to — it is what stops a stale JS bundle talking to new PHP). A finished run now names
  itself in the URL (`?run={uuid}`, `replace`), so that same reload re-opens the brief. The
  request that does it is the one that was already refreshing the run list, so it costs nothing
  extra; a one-shot `suppressHydrate` flag stops the returning `active` prop from re-rendering
  the thread and dropping the thinking card. The flag is deliberately NOT a ref — a remount must
  forget it, because a remount is exactly when re-hydrating is correct.
- **A run that loses its browser still finishes.** Both SSE endpoints set `ignore_user_abort`:
  the provider call is already paid for when the connection drops (deploy reload, sleeping
  phone, closed tab), so the brief is written to the thread and waits under 过往记录 instead of
  becoming a failed message.
- **Follow-up chips wrap.** They sat in a horizontal scroller with the scrollbar hidden, so on a
  normal portal column the last suggestions were off-screen with nothing indicating they
  existed ("i cant see all the options to click"). They wrap under a small label now.

- **Getting back out.** Every view past the catalog carries a real back button (the portal's
  `ArrowLeft` + label, as `PageHeader` uses) — replacing the original design's easily-missed
  breadcrumb. Back moves **one step, not one leap**: the intake form returns to that play's
  intro (and **keeps what was already typed** — the form seeds only on a first visit, keyed by
  slug), while intro and chat return to the catalog. Leaving a run never destroys it: the
  thread is already in history.

## Related files

**Backend — Content & Service (`src/InvestmentPrompt`)**
- [src/InvestmentPrompt/Services/PlaybookCatalog.php](/src/InvestmentPrompt/Services/PlaybookCatalog.php) — the 34 Property plays + `all()` / `find()` / `groups()` / `categories()` + `OUTPUT_STYLE` + `VISUAL_STYLE` (the ```viz chart contract).
- [src/InvestmentPrompt/Services/PlaybookWealthPlays.php](/src/InvestmentPrompt/Services/PlaybookWealthPlays.php) — the 32 Wealth plays (auto-generated data file, `require`d by the catalog).
- [src/InvestmentPrompt/Services/PlaybookService.php](/src/InvestmentPrompt/Services/PlaybookService.php) — `injectLiveData()` (catalogue match) · `liveDataBlock()` · `buildPrompt()` · `runSummary()` / `runTitle()` · `chatContext()`.

**Backend — Conversation storage (shared AI module)**
- [src/Ai/AiConversation.php](/src/Ai/AiConversation.php) — `playbook_key` / `playbook_inputs` / `playbook_injected` + `isPlaybook()`.
- [src/Ai/Repositories/AiConversationRepository.php](/src/Ai/Repositories/AiConversationRepository.php) — `createForPlaybook()`.
- [src/Analysis/Reference/CatalogProject.php](/src/Analysis/Reference/CatalogProject.php) — the master-catalogue rows the live-data match reads (catalogue connection).

**Backend — Controller & Form Requests**
- [app/Http/Controllers/Main/Portal/InvestmentPromptController.php](/app/Http/Controllers/Main/Portal/InvestmentPromptController.php) — `index` + the SSE `run` / `chat` (daily caps live here).
- [app/Http/Requests/Main/Portal/InvestmentPrompt/RunRequest.php](/app/Http/Requests/Main/Portal/InvestmentPrompt/RunRequest.php) · [ChatRequest.php](/app/Http/Requests/Main/Portal/InvestmentPrompt/ChatRequest.php)

**Frontend (Vue)**
- [resources/js/Pages/Main/Portal/InvestmentPrompt/Index.vue](/resources/js/Pages/Main/Portal/InvestmentPrompt/Index.vue) — the orchestrator (home / intro / form / chat + SSE wiring).
- Partials: [PlayIcon.vue](/resources/js/Pages/Main/Portal/InvestmentPrompt/Partials/PlayIcon.vue) (the custom line-icons) · [PlayHead.vue](/resources/js/Pages/Main/Portal/InvestmentPrompt/Partials/PlayHead.vue) (title + 3-step rail) · [PlayIntro.vue](/resources/js/Pages/Main/Portal/InvestmentPrompt/Partials/PlayIntro.vue) · [PlayForm.vue](/resources/js/Pages/Main/Portal/InvestmentPrompt/Partials/PlayForm.vue) · [ThinkingCard.vue](/resources/js/Pages/Main/Portal/InvestmentPrompt/Partials/ThinkingCard.vue).
- [resources/js/composables/useAiStream.js](/resources/js/composables/useAiStream.js) — shared SSE reader (`body` + `onStep` added for this page).
- [resources/js/utils/markdown.js](/resources/js/utils/markdown.js) — shared safe renderer (tables/blockquotes/rules added for briefs) + `renderSegments()`, the prose/chart splitter.
- [resources/js/utils/vizSpec.js](/resources/js/utils/vizSpec.js) — validates + normalizes a ```viz spec, and owns the shared tone palette / number formatting. Tested in [vizSpec.test.js](/resources/js/utils/vizSpec.test.js).
- [resources/js/Components/Brief/](/resources/js/Components/Brief/) — `BriefBody.vue` (segment renderer, used for every AI message on the page) · `BriefViz.vue` (card + dispatcher) · `VizKpi` / `VizBar` / `VizLine` / `VizWaterfall` / `VizGauge`.
- [resources/js/composables/useVizReveal.js](/resources/js/composables/useVizReveal.js) — the one entry-animation clock every brief visual runs on (reduced-motion aware).
- The hero band, shared with Vibe Coding: [`Components/Portal/PortalHero.vue`](/resources/js/Components/Portal/PortalHero.vue) · [`HeroStats.vue`](/resources/js/Components/Portal/HeroStats.vue) / [`HeroStat.vue`](/resources/js/Components/Portal/HeroStat.vue) · [`HeroToggle.vue`](/resources/js/Components/Portal/HeroToggle.vue).
- Nav entry: the shared [`Components/Portal/AiCoachTabs.vue`](/resources/js/Components/Portal/AiCoachTabs.vue) strip (the sidebar has ONE "AI Coach" entry, not one per page).

**Prompt registry**
- [config/ai_prompts.php](/config/ai_prompts.php) (`investment_playbook` entry) · [resources/prompts/investment_playbook.md](/resources/prompts/investment_playbook.md) (the shared persona; editable + model-pinnable on the Manage AI Prompts page) · `AiRequest::PROMPT_INVESTMENT_PLAYBOOK`.

**Migrations**
- [database/migrations/2026_08_26_100000_add_playbook_to_ai_conversations_table.php](/database/migrations/2026_08_26_100000_add_playbook_to_ai_conversations_table.php)

**Routes**
- [routes/main.php](/routes/main.php) — `GET ai/investment-prompt` · `POST ai/investment-prompt/run` (throttle 6/min) · `POST ai/investment-prompt/{id}/chat` (throttle 20/min).

**See also:** [AI Integration](/docs/modules_handbook/shared/ai/readMe.md) (AiClient, the prompt registry, the `ai_requests` log — every run/chat call is logged there under `investment_playbook` with the conversation as `subject`).
