# Portal Insights (Lead → Property Portal → Insights)

**Portal:** Manage · **UI:** Lead → **Property Portal → Insights**, first in the strip and its DEFAULT (`?ptab=portal-insights`) · **Routes:** `manage.leads.portal-insights.*` · **Gate:** the route's leads permission + object-level `LeadVisibility` · Built 2026-09-17 at the founder's request — after he pointed out that a first design had buried the two things that matter most: "why you didn't consider like the property he search (property interest and property analyse)? you should look at all angles".

## What it does

One AI reading of **everything the person did in the member portal by themselves** — the developments they kept opening and the layouts they priced, the analyses they ran with their own price, the wealth plan / FPA figures they entered, what they typed at the AI, the courses they watched, the DMAIC road.

This is the **unattended** record, and that is the whole point of it: nothing here was said to a salesperson, so nothing here was shaped by one. A person who opens the same development twenty times and prices three of its layouts has told you their budget and their family size without answering a single question — and would probably answer differently if asked.

So the reading is ordered by what a salesperson cannot get any other way:

1. **The shortlist they built for themselves** — which developments, where, which layouts, at what price, and **how far up the intent ladder** they went.
2. **What they think a home is worth** — their own analyses: the price THEY typed, their PSF against the market's, the engine's verdict.
3. Then the rest: what they entered about themselves, what they typed at the AI (verbatim), what they are learning, where the records disagree, what is missing, what to do now.

It is the third member of the family, beside [Channel Insights](/docs/modules_handbook/shared/channel-insights/readMe.md) (conversations) and [Sales Insights](sales-insights.md) (sales records), and shares their loop: read for free, analyse on an explicit click, reuse on an exact fingerprint, Prompt and JSON beside the reading, one row per lead in `lead_channel_insights` (`CHANNEL_PORTAL = 8`).

## The rule the whole reading rests on: three kinds of fact

Every rendered line carries its KIND as its second field, and the prompt is built around them:

| Kind | What it is | What the reading may say |
|---|---|---|
| **`behaviour`** | what the platform recorded them doing — views, ladder milestones, watch time, a plan opened and abandoned | what they **did**, and how often. Never what they "want", "prefer" or "decided" |
| **`entered`** | a number or choice they put in a form — an analysis price, a plan target, FPA income | their own figure, attributable to them — but a form field, never "they said" |
| **`typed`** | their own words — an AI question, an FPA goal, a concierge note, a road card note | quoted verbatim; the ONLY source for `questions_asked` |

Without that split, "read Cochrane 20 times" becomes "wants Cochrane" in one careless sentence, and the reading has invented the customer's mind. The corpus makes the danger concrete: on 2026-09-17 the whole library held **66 typed user messages across 29 leads** against **396 development-view rows** — the portal is overwhelmingly behaviour, so almost every sentence a reading writes is about something the person did, not something they said.

## The intent ladder

`lead_project_views` stamps five milestones per development: *Checked the area* → *Compared nearby supply* → *Ran the analysis* → *Priced a unit* → **Asked for an advisor**. Where a person STOPS says more than how often they visited, and the corpus fact that makes this worth a section of its own: across every lead, the top rung had been reached **zero** times (analysis 357, units 77, supply 44, amenities 101, contact **0**). People are doing our job for us and still not talking to us, and "priced a unit, never asked for an advisor" is usually the most actionable line in the reading. `counted.asked_for_an_advisor` is a first-class fact for exactly this reason.

## How it works

### The fact list — `Src\Lead\Support\PortalFacts`

Renders the ten portal sources into ONE chronology, oldest first, each line addressable:

```
[pi:4199 · 2026-09-13 00:32] development · Binastra Cochrane · behaviour · Kuala Lumpur · viewed 20× since 2026-08-28 · got as far as: Priced a unit · layouts priced: Type B 2-bed 763 sqft RM 794,520.00 (5×); Type C 3-bed 1008 sqft RM 1,150,000.00 (5×)
[pa:32 · 2026-08-28 17:09] own analysis · Analysis — 28 Aug · entered · New project · First-time buyer · RM 675,000.00 · 273 sqft · their PSF 2,472.53 vs market 2,014.00 · verdict Overpriced
[wp:70 · 2026-08-27 09:02] wealth plan · behaviour · STARTED AND LEFT EMPTY — nothing was filled in
[aim:512 · 2026-09-04 09:12] asked the AI · typed · "if I rent it out can it cover the instalment?"
[crs:2 · 2026-09-12 17:19] course · 房间出租大师班 · behaviour · 61 lessons opened · 58 completed · 329 min watched
```

Prefixes and the sub-tab each opens (`PortalFacts::TABS`): `pi` interest · `pa` analyses · `wp` wealth · `fpa` FPA · `cn` consents · `aic` an AI chat · `aim` one question they typed · `dbt` debates · `cq` concierge · `crs` courses · `rd` road.

Four decisions inside it are not cosmetic:

- **It reads the MODELS, never the Show page's props.** `propertyInterest` is capped at 20 rows for the screen while a lead can have 86, so a reading built on the props would silently be a reading of "the first 20" — and would change meaning the day someone tunes the cap. Same rule Channel Insights states about `LeadConversationPresenter::forLead()` (the last 30 messages).
- **The long tails are rolled up, not dumped.** A lead can touch 87 lessons; 87 lines of "watched 3 minutes" crowd out everything else, so courses render **per course** and the per-lesson totals live in `counted`. Developments cap at 25 and layouts at 4 per development.
- **Every figure the model may state is computed here** (`counted`): developments and views, areas, layouts, the ladder, analyses with their price range and verdict mix, plans (and how many were left empty), typed questions, lessons and watch minutes, days since the last thing happened.
- **What is ABSENT is rendered too.** A wealth plan with an empty `state` says `STARTED AND LEFT EMPTY`; an AI chat with no messages says `OPENED AND NEVER USED`. Those are facts about the person, and a reading that simply omitted them would lose the most human thing in the file.

Three free-text stores that the Show page does not render reach the reading here, because it reads the source: `concierge_requests.headline` / `description`, `ai_conversations.playbook_inputs`, and `wealth_plans.state.profile.job`.

### ⚠️ The FPA's credit-bureau figures are included

The owner decided on 2026-09-17 that the FPA reading may carry the **credit score, its rating and the CCRIS conduct string** into the prompt (`PortalFacts::build($lead, $withCredit)`, switched on by `LeadPortalInsightsController::WITH_CREDIT`). That is real bureau data leaving for the AI provider, which is why it is an explicit switch rather than a default buried in the renderer, why the flag is part of the fingerprint (flipping it re-runs every stored reading rather than mixing the two), and why `counted.fpa_credit_included` states which way a reading was produced. Only 2 leads in the library had an FPA when this shipped. If that decision is ever revisited, flip the constant — nothing else changes.

### The schema — `Src\Lead\PortalInsights` (`portal-insights-v1`)

A bounded passthrough like its siblings: the prompt is admin-editable, so a section it adds survives as text under "More from this reading", while every block the panel renders by name is clamped.

| Key | Shape | Cap |
|---|---|---|
| `headline`, `summary`, `momentum` | as Sales (`accelerating` … `dormant`) | — |
| `shortlist` | `{project, what_they_did, stance, record_id}`; `stance` ∈ `returning` / `priced_it` / `compared` / `glanced` / `dropped` | 6 |
| `price_expectation` | `{text, record_id}` | 4 |
| `research_depth`, `learning` | a sentence or two | — |
| `self_reported` | `{text, record_id}` | 5 |
| `questions_asked` | `{quote, why_it_matters, record_id}` — **dropped without a quote** | 5 |
| `areas` | one `{reading, state}` per portal sub-tab | 10 keys |
| `contradictions` | `{text, record_ids}` | 5 (×4 records) |
| `gaps` · `risks` · `opportunities` · `next_best_actions` | as Sales | 5 · 3 · 3 · 3 |
| `suggested_message` | ready to paste, in their language | — |

**No score of any kind**, and no figure of the model's own: a number here would read as a decision the records do not support.

### The panel — `Partials/Tabs/PortalInsightsTab.vue`

The same shell as Sales Insights (toolbar with provenance + Prompt / JSON / Analyse, the server's counted facts as chips that open a sub-tab, the reading, then "More"), in the portal's own order: brief → **shortlist** (+ research depth) → what they think it is worth, beside what they entered → what they asked → what doesn't add up → do now, beside the message → missing / learning / risks / opportunities → a reading per sub-tab. Citations emit `open-tab`, which the Show page uses to switch the Portal strip.

It is **first in the strip and the default** (the owner's call): the portal has ten sub-tabs, and one reading across all of them is the fastest way in. The read-only Lead detail modal has no page to hang the endpoints off, so there the tab is a line pointing at the full page.

## Related files

- [src/Lead/Support/PortalFacts.php](/src/Lead/Support/PortalFacts.php) — the fact list + `counted`, reading the models.
- [src/Lead/PortalInsights.php](/src/Lead/PortalInsights.php) — the schema (it reuses `SalesInsights`' record/contradiction/ref clamps rather than restating them).
- [app/Http/Controllers/Manage/Leads/LeadPortalInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadPortalInsightsController.php) — show / generate / prompt / download, on the shared [ServesChannelInsights](/app/Http/Controllers/Concerns/ServesChannelInsights.php).
- Prompt: [resources/prompts/portal_insights.md](/resources/prompts/portal_insights.md), registered in `config/ai_prompts.php`, admin-editable on Manage → AI Prompts.
- Frontend: [PortalInsightsTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/PortalInsightsTab.vue), mounted by [Show.vue](/resources/js/Pages/Manage/Leads/Show.vue); the tab is declared first in `portalTabs` in [useLeadTabs.js](/resources/js/composables/useLeadTabs.js).
- Tests: [tests/Feature/Lead/LeadPortalInsightsTest.php](/tests/Feature/Lead/LeadPortalInsightsTest.php) — every line declares its kind; the figures are the server's; reading spends nothing; one reading per lead, reused unchanged and stale after one more development; a lead who never opened the portal is not analysed; the lead's own visibility gates it. Shared stub: [tests/Support/FakesChannelInsights.php](/tests/Support/FakesChannelInsights.php).
