@rubytech/create-maxy-code 0.1.61 → 0.1.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/installer-specialist-registration.test.js +112 -0
- package/dist/index.js +22 -0
- package/dist/specialist-registration.js +78 -0
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +22 -0
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +50 -13
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +22 -21
- package/payload/platform/plugins/contacts/PLUGIN.md +9 -0
- package/payload/platform/plugins/docs/references/admin-session.md +55 -7
- package/payload/platform/plugins/email/PLUGIN.md +9 -0
- package/payload/platform/plugins/epc/PLUGIN.md +5 -0
- package/payload/platform/plugins/loop/PLUGIN.md +27 -0
- package/payload/platform/plugins/memory/PLUGIN.md +37 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +3 -3
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/live-schema-source.test.js +137 -75
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/live-schema-source.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.d.ts +0 -17
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.js +32 -4
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts +65 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js +182 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection.test.js +8 -8
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.js +241 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.js +9 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.d.ts +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.js +9 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.js +10 -2
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.d.ts +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.js +5 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.d.ts +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.js +5 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +5 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +5 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +6 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/vitest.config.ts +10 -3
- package/payload/platform/plugins/outlook/PLUGIN.md +7 -0
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +22 -12
- package/payload/platform/plugins/preval/hooks/preval-prompt-required.sh +140 -0
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +77 -19
- package/payload/platform/plugins/preval/skills/property-preval/references/__tests__/adherence.test.py +373 -0
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +390 -47
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +32 -21
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +0 -1
- package/payload/platform/plugins/property-data/PLUGIN.md +22 -0
- package/payload/platform/plugins/replicate/PLUGIN.md +3 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
- package/payload/platform/plugins/tasks/PLUGIN.md +14 -0
- package/payload/platform/plugins/telegram/PLUGIN.md +3 -0
- package/payload/platform/plugins/waitlist/PLUGIN.md +7 -0
- package/payload/platform/plugins/whatsapp/PLUGIN.md +12 -0
- package/payload/platform/plugins/workflows/PLUGIN.md +8 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +39 -1
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +148 -41
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +23 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +141 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +13 -2
- package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/tool-surface.js +135 -37
- package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
- package/payload/premium-plugins/real-agent/agents/compliance.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +5 -0
- package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +27 -0
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +22 -12
- package/payload/premium-plugins/real-agent/plugins/preval/hooks/preval-prompt-required.sh +140 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +77 -19
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/__tests__/adherence.test.py +373 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +390 -47
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +32 -21
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +0 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +22 -0
- package/payload/server/public/assets/{admin-DhN3G8W7.js → admin-ymReS1mN.js} +1 -1
- package/payload/server/public/assets/{data-B2ZVXOcE.js → data-D6U2e85B.js} +1 -1
- package/payload/server/public/assets/{graph-BAMGPHrK.js → graph-C274PN27.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D9eBbvxo.js → graph-labels-DQFzd1FY.js} +1 -1
- package/payload/server/public/assets/{page-DjdVMWCz.js → page-D-OLjort.js} +1 -1
- package/payload/server/public/assets/{page-CV27Al6Z.js → page-DFFt9_Q0.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +1 -1
|
@@ -26,7 +26,7 @@ Never substitute the agent's body or display fonts even if their `DESIGN.md` spe
|
|
|
26
26
|
- Optional `beds`, `type`, `tenure`, `condition` (well / average / needs work).
|
|
27
27
|
- `adjustments[]` — operator-curated array of `{label, delta_pct}` covering esoteric factors that shift the headline range (e.g. `{"label":"South-facing garden","delta_pct":3}`, `{"label":"Needs full reno","delta_pct":-8}`). Deltas sum linearly into the final range.
|
|
28
28
|
- `narrative` — one short paragraph the operator writes about the property.
|
|
29
|
-
- **
|
|
29
|
+
- **EPC certificate fields are pasted by the operator before invocation.** The three required fields are `subject.total_floor_area_m2`, `subject.current_energy_rating`, and `subject.lodgement_date` — copied verbatim from the EPC certificate. The renderer derives `sqft` from `total_floor_area_m2 × 10.7639`. There is no mid-run paste prompt and no benchmark-only render — a null in any of the three aborts the run with `reason=epc-certificate-not-supplied`.
|
|
30
30
|
- **`agent_listings`** — the agent's recent sold / under-offer / live listings to feature on the closing page. Up to 6 entries of `{url, address, price, beds, type, image, status, slug}` where `status ∈ {"Sold","Under offer","For sale"}`. Operator picks the comps that make the strongest pitch; this is *not* the PropertyData sample.
|
|
31
31
|
- **`market_summary`** — `headline` (one display-italic line), `paragraphs[]` (2–3 short paragraphs), `demand_gauge_position` (percentage string like `"62%"`), `price_growth_note`, `demand_note`. The operator drafts or accepts an LLM stub.
|
|
32
32
|
|
|
@@ -48,9 +48,7 @@ Parallel fan-out keyed on the derived postcode + the subject address:
|
|
|
48
48
|
11. `property-data-flood-risk` — area flood risk (full postcode only).
|
|
49
49
|
12. `property-data-council-tax` — band D + per-property bands (full postcode only).
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
13. `epc-lookup-by-address(address, postcode)` — subject floor area + energy ratings + lodgement date.
|
|
51
|
+
EPC certificate fields are operator paste-in — see `subject.total_floor_area_m2`, `subject.current_energy_rating`, `subject.lodgement_date` in the Inputs section. There is no EPC MCP fan-out.
|
|
54
52
|
|
|
55
53
|
`address-match-uprn` and `planning-applications` are deliberately omitted (CRM hygiene and dev-precedent reports respectively).
|
|
56
54
|
|
|
@@ -67,7 +65,7 @@ Return the PDF path to the operator.
|
|
|
67
65
|
## Pages
|
|
68
66
|
|
|
69
67
|
1. **Cover** — full-bleed `subject.cover_hero` with vertical gradient scrim; light logo top-left; eyebrow "`<brand.name>` · Pre-valuation"; address in display-italic; postcode chip; four-up meta strip (Report · Address · Postcode · Generated). Lifted from property-market-report's cover contract.
|
|
70
|
-
2. **Subject valuation** — KPI strip (sqft, beds, EPC rating, type). Centre panel: headline range = `sqft × sold_psf_low–high` (Land Registry 70pc band) with `sqft × asking_psf` shown as a cross-check. Operator `adjustments[]` displayed as a stacked list (label · delta% · running £). Final range pill at the foot.
|
|
68
|
+
2. **Subject valuation** — KPI strip (sqft, beds, EPC rating, type). Centre panel: headline range = `sqft × sold_psf_low–high` (Land Registry 70pc band) with `sqft × asking_psf` shown as a cross-check. Operator `adjustments[]` displayed as a stacked list (label · delta% · running £). Final range pill at the foot. One paragraph of `subject.narrative` under the panel. `sqft` is required — a null value aborts the run, never renders a benchmark-only panel.
|
|
71
69
|
3. **Market context** — KPI cube (avg asking, avg sold, £/sqft, YoY); pill + gauge for demand; 7y growth sparkline (£ solid + £/sqft dashed); summary paragraphs. Same patterns as property-market-report's page 2.
|
|
72
70
|
4. **Comparables (full-width, stacked)** — Nearest sold table (top 6) full-width across the page; Live asking tile grid (top 4) full-width below. Vertically stacked, never side-by-side — corrects the cramped two-column layout in the v1 preval where the asking tiles got pushed to ~40mm columns of italic addresses.
|
|
73
71
|
5. **Recently with `<brand.name>` + sources** — agent's own listings as a clickable tile grid (`<a>` per card, status badge: Sold / Under offer / For sale), then the sources / methodology / accuracy / investment block. Closes with `Real Agent · Generated <date> · <postcode>` in the footer.
|
|
@@ -75,11 +73,13 @@ Return the PDF path to the operator.
|
|
|
75
73
|
## How to execute
|
|
76
74
|
|
|
77
75
|
1. Match the postcode out of the address (`/[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}/i`). Stop if no match.
|
|
78
|
-
2. Run the
|
|
79
|
-
- **PropertyData `key-not-registered`** → abort with the `property-data-key-register`
|
|
80
|
-
- **PropertyData `http-429`** → back off
|
|
81
|
-
- **
|
|
76
|
+
2. Run the 12 PropertyData fan-out tools in parallel. Every failure is fail-loud — no mid-run paste prompts, no benchmark-only render, no degraded pack on disk:
|
|
77
|
+
- **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
|
|
78
|
+
- **PropertyData `http-429`** → back off and retry the single throttled tool **once**. A second `http-429` aborts `reason=property-data-rate-limited`.
|
|
79
|
+
- **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
|
|
80
|
+
- **Watchdog: any of the 12 PropertyData log lines missing** within 60 s of `[preval] start` → `render.py` aborts `reason=observability-missing tool=<name>` naming the silent tool. A silent tool drop must never proceed to render.
|
|
82
81
|
3. Compute valuation range:
|
|
82
|
+
- `sqft = round(subject.total_floor_area_m2 × 10.7639)` (operator-pasted m² × the standard conversion factor).
|
|
83
83
|
- `sold_psf_low` = `sold-prices-per-sqf` `70pc_range[0]`
|
|
84
84
|
- `sold_psf_high` = `sold-prices-per-sqf` `70pc_range[1]`
|
|
85
85
|
- `low = sqft × sold_psf_low`, `mid = sqft × sold_psf`, `high = sqft × sold_psf_high`
|
|
@@ -96,23 +96,81 @@ Deltas sum linearly (`-8 + 3 = -5%`, applied as `× 0.95`). Compound interpretat
|
|
|
96
96
|
|
|
97
97
|
- `[preval] start address="<addr>" postcode=<pc>` at run start.
|
|
98
98
|
- `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
|
|
99
|
-
-
|
|
99
|
+
- `[preval] aborted reason=<cause> address="<addr>" postcode=<pc> ms=<n>` on any abort (one line, then exit 2; no `done`).
|
|
100
|
+
- Each fan-out call emits its own `[property-data] tool=<name>` line in `server.log` (inherited contract; no new instrumentation here). `render.py`'s observability watchdog tails these lines and aborts with `observability-missing tool=<name>` if any of the 12 expected names is absent 60 s after `[preval] start`.
|
|
101
|
+
|
|
102
|
+
## Hard breakpoints
|
|
103
|
+
|
|
104
|
+
Each row aborts the run with the named cause. No PDF or `index.html` is left on disk. Operator sees a single-line cause + one-line remediation hint, nothing else.
|
|
105
|
+
|
|
106
|
+
| Forbidden state | `reason=` cause |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `subject.total_floor_area_m2` / `current_energy_rating` / `lodgement_date` null or missing | `epc-certificate-not-supplied` (operator pastes the three fields from the EPC certificate before re-invoking) |
|
|
109
|
+
| `subject.cover_hero` missing, unreachable, below 1200×800, or symlinked into another property's folder | `subject-cover-hero-missing` / `subject-cover-hero-unreadable` / `subject-cover-hero-too-small` / `subject-cover-hero-symlinked-foreign` |
|
|
110
|
+
| `brand` not resolved from an on-disk `DESIGN.md` (required token missing) | `brand-unresolved` |
|
|
111
|
+
| `agent_listings` empty | `agent-listings-empty` |
|
|
112
|
+
| `market_summary.paragraphs` < 2 entries | `market-summary-too-thin` |
|
|
113
|
+
| PropertyData any-call `http-429` after 1 retry | `property-data-rate-limited` |
|
|
114
|
+
| PropertyData any-call `key-not-registered` | `property-data-key-not-registered` (operator runs `property-data-key-register`) |
|
|
115
|
+
| Any of the 12 PropertyData log lines missing 60 s after `[preval] start` | `observability-missing tool=<name>` (the silent tool is named in the abort hint) |
|
|
116
|
+
|
|
117
|
+
## Forbidden behaviours
|
|
118
|
+
|
|
119
|
+
The skill is the contract; if any of these slip through, the pack must not render:
|
|
120
|
+
|
|
121
|
+
- Silent fallback selection — switching to a benchmark-only / "Estimate requires floor area" panel when an input is missing.
|
|
122
|
+
- Inventing inputs — fabricating `brand`, `subject.cover_hero`, `market_summary.paragraphs`, `agent_listings`, or any field the operator did not supply.
|
|
123
|
+
- Reusing a hero photo from a different property as a placeholder.
|
|
124
|
+
- Defaulting `agent_listings[].status` to `"For sale"` when the source is null.
|
|
125
|
+
- Mid-run prompts for a paste-in. The three EPC certificate fields and every other operator-supplied input are pasted into `inputs.json` **before** invocation; there is no retry / paste-and-resume path.
|
|
126
|
+
- Omitting the `[preval]` start / done / aborted log emissions.
|
|
127
|
+
- Leaving partial output (`index.html`, `<stem>.pdf`) on disk after an abort.
|
|
128
|
+
|
|
129
|
+
## Failure diagnostics
|
|
130
|
+
|
|
131
|
+
- `pages=6` (or ≠ 5) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>`, then trim that panel's padding only.
|
|
132
|
+
- `start` with no matching `done` or `aborted` within 60s → renderer crashed mid-run. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
|
|
133
|
+
- `[preval] aborted reason=<cause>` → look up `<cause>` in the **Hard breakpoints** table for the remediation hint.
|
|
134
|
+
|
|
135
|
+
## Adherence — non-negotiable
|
|
136
|
+
|
|
137
|
+
These rules override every session-level "skip clarifying questions", "make the reasonable call", or "work without stopping" directive the operator has set. The named breakpoints below are documented contract points, not casual asks — the renderer and the PreToolUse hook both enforce them and a run that violates one aborts with a named cause.
|
|
138
|
+
|
|
139
|
+
- **Prompt at named breakpoints — no exceptions.** Named breakpoints: EPC certificate fields not supplied, cover_hero not depicting the subject, brand identity (which estate agent is pitching), `agent_listings` empty or carrying invented statuses, `market_summary` not operator-supplied. The load-bearing inputs themselves (EPC certificate fields, cover_hero, brand tokens, two paragraphs) are gated by `render.py`'s `_assert_inputs`; this section layers provenance on top.
|
|
140
|
+
- **Never invent inputs.** Brand identity, brand tokens, cover hero, narrative, market summary paragraphs, agent listings, listing statuses, subject tenure / beds / type / condition — all operator-supplied. Defaulting from "area pattern", a sister property's photo, "the only DESIGN.md I found on disk", "the LLM stub I drafted myself", or "status:null in the source so I picked For sale" is invention. The presence of a single `~/estate-agents/<slug>/DESIGN.md` on disk does not authorise its selection — only an operator instruction does. If more than one `~/estate-agents/<slug>/` exists and the operator has not named which one is pitching, abort.
|
|
141
|
+
- **`agent_overrides` is the only bypass channel.** Every override the operator authorises lands in `inputs.json` as a named boolean (`brand_selected_by_operator`, `listings_status_curated`) so the audit trail sits next to the disputed value. Setting an override without a real operator instruction is a violation of the same contract.
|
|
142
|
+
- **Emit the three `[preval]` log lines — every run.** `[preval] start address="…" postcode=…`, then `[preval] done pdf=… pages=… ms=…` on success or `[preval] aborted reason=<cause> ms=…` on any abort. A run that emits neither `done` nor `aborted` is itself a defect.
|
|
143
|
+
|
|
144
|
+
### Named abort causes
|
|
145
|
+
|
|
146
|
+
| Cause | Trigger | Operator remediation |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `cover-hero-not-subject` | `subject.cover_hero` resolves to a `*/properties/*/` directory whose slug doesn't contain the subject's primary street token | Supply the subject's own photo |
|
|
149
|
+
| `agent-listings-invented` | An `agent_listings[].status` is set when the source `property.json` (named by `agent_listings[].origin`) carries `status: null`, and `agent_overrides.listings_status_curated` is not `true` | Operator signs off on each listing status, then sets the override |
|
|
150
|
+
| `market-summary-invented` | `market_summary.origin` is missing or not in `{"operator", "operator-edited-llm-stub"}` | Operator drafts or accepts the paragraphs; set `origin` accordingly |
|
|
151
|
+
| `brand-invented` | `brand.name` is set without `agent_overrides.brand_selected_by_operator: true` | Operator names which estate agent is pitching; set the override |
|
|
152
|
+
| `epc-certificate-not-supplied` | Any of `subject.total_floor_area_m2`, `subject.current_energy_rating`, or `subject.lodgement_date` is null or missing | Operator copies the three fields verbatim from the EPC certificate into `subject` in `inputs.json` before re-invoking |
|
|
153
|
+
| `observability-missing tool=<name>` | One of the 12 expected `[property-data] tool=<name>` log lines did not appear within 60 s of `[preval] start` | Re-invoke and confirm every PropertyData tool returns; if a tool is genuinely down, surface a `property-data-*` cause first |
|
|
154
|
+
| `log-destination-unavailable` | Neither `~/.maxy/logs/server.log` nor `<out_dir>/preval.log` is writable | Fix filesystem permissions before re-running |
|
|
155
|
+
|
|
156
|
+
## Self-check before declaring done
|
|
100
157
|
|
|
101
|
-
|
|
158
|
+
Answer all seven inline at the end of every run, with line references from `server.log` or `<out_dir>/preval.log` — not paraphrase:
|
|
102
159
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
160
|
+
1. `[preval] start` emitted? (cite the log line)
|
|
161
|
+
2. All 12 expected `[property-data] tool=<name>` log lines present, or `[preval] aborted` emitted with a named cause? (cite the lines)
|
|
162
|
+
3. EPC certificate fields (`subject.total_floor_area_m2`, `subject.current_energy_rating`, `subject.lodgement_date`) all populated from the operator paste? (cite the `inputs.json` excerpt)
|
|
163
|
+
4. Every input field traceable to a real source — operator paste (including the EPC certificate), on-disk `DESIGN.md`, MCP response? Anything sourced from "area pattern", a sister property file, or LLM imagination is a defect.
|
|
164
|
+
5. `[preval] done pages=5` emitted? (cite the log line)
|
|
165
|
+
6. PDF page count = 5 asserted? (cite the assertion result)
|
|
166
|
+
7. No fields invented from area pattern, sister-property files, or LLM imagination? List the three most fabrication-prone fields (`brand.name`, `market_summary.paragraphs`, `agent_listings[].status`) and the source for each in this run.
|
|
109
167
|
|
|
110
168
|
## Out of scope
|
|
111
169
|
|
|
112
170
|
- Multi-address batch preval (CSV in → many PDFs out).
|
|
113
171
|
- Loop CRM auto-publish of the PDF onto a contact's record.
|
|
114
172
|
- Pulling `agent_listings` from Loop CRM automatically — operator paste-in for now; CRM wire-up is a separate task.
|
|
115
|
-
- Caching of MCP responses (
|
|
173
|
+
- Caching of MCP responses (12 credits per pack is acceptable).
|
|
116
174
|
- A separate cover letter / pitch letter — the cover page *is* the pitch.
|
|
117
175
|
- Compound adjustments (linear sum is the contract; see above).
|
|
118
|
-
- Subject
|
|
176
|
+
- Subject EPC fields from sources other than the operator paste-in (e.g. Loop CRM floor plans, Rightmove sqft scrape, restored EPC API) — separate task if pursued.
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Adherence-gate tests for property-preval (Tasks 157 + 163).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
|
|
5
|
+
Task 157 provenance causes (agent didn't invent the input):
|
|
6
|
+
- cover-hero-not-subject (slug heuristic; complements 156's symlink check)
|
|
7
|
+
- agent-listings-invented (status set when source property.json had null)
|
|
8
|
+
- market-summary-invented (origin missing or "agent-invented")
|
|
9
|
+
- brand-invented (brand.name without operator override)
|
|
10
|
+
- log-destination-unavailable (observability emit-or-abort)
|
|
11
|
+
|
|
12
|
+
Task 163 causes (replaces 156's sqft gates after EPC removal):
|
|
13
|
+
- epc-certificate-not-supplied (any of the three operator-paste EPC
|
|
14
|
+
fields missing — render.py + hook)
|
|
15
|
+
- observability-missing tool=<name> (one of the 12 expected
|
|
16
|
+
[property-data] tool=<name> log
|
|
17
|
+
lines absent within the budget)
|
|
18
|
+
|
|
19
|
+
Plus the PreToolUse hook red-team (blocks the same causes before
|
|
20
|
+
inputs.json reaches disk).
|
|
21
|
+
|
|
22
|
+
Run with: python3 references/__tests__/adherence.test.py
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
import json, os, struct, subprocess, sys, tempfile, unittest, zlib
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
HERE = Path(__file__).resolve().parent
|
|
29
|
+
REFERENCES = HERE.parent
|
|
30
|
+
PLUGIN_ROOT = REFERENCES.parent.parent.parent
|
|
31
|
+
HOOK = PLUGIN_ROOT / "hooks" / "preval-prompt-required.sh"
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, str(REFERENCES))
|
|
34
|
+
from render import ( # noqa: E402
|
|
35
|
+
_assert_inputs,
|
|
36
|
+
_assert_observability_logs,
|
|
37
|
+
EXPECTED_PROPERTY_DATA_TOOLS,
|
|
38
|
+
PrevalAbort,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _write_png(path: Path, w: int = 1600, h: int = 1200) -> None:
|
|
43
|
+
"""Minimal valid PNG large enough to clear Task 156's 1200x800 gate."""
|
|
44
|
+
def chunk(tag: bytes, data: bytes) -> bytes:
|
|
45
|
+
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
|
|
46
|
+
sig = b"\x89PNG\r\n\x1a\n"
|
|
47
|
+
ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
|
|
48
|
+
raw = b"".join(b"\x00" + b"\x00\x00\x00" * w for _ in range(h))
|
|
49
|
+
idat = zlib.compress(raw)
|
|
50
|
+
path.write_bytes(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b""))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _complete_inputs(tmp: Path) -> dict:
|
|
54
|
+
"""An inputs.json that PASSES every 156 + 157 gate."""
|
|
55
|
+
hero = tmp / "hero.png"
|
|
56
|
+
_write_png(hero)
|
|
57
|
+
return {
|
|
58
|
+
"out_dir": str(tmp),
|
|
59
|
+
"filename_stem": "subject",
|
|
60
|
+
"address": "9 Phoenix Drive, Bishop's Stortford, CM23 2UJ",
|
|
61
|
+
"postcode": "CM23 2UJ",
|
|
62
|
+
"generated": "19 May 2026",
|
|
63
|
+
"brand": {
|
|
64
|
+
"name": "Muvin",
|
|
65
|
+
"tagline": "Estate Agents",
|
|
66
|
+
"primary": "#000", "primary_dark": "#000", "accent": "#000",
|
|
67
|
+
"paper": "#fff", "paper_banded": "#fff", "rule": "#000",
|
|
68
|
+
},
|
|
69
|
+
"subject": {
|
|
70
|
+
"cover_hero": str(hero),
|
|
71
|
+
"total_floor_area_m2": 93.2,
|
|
72
|
+
"current_energy_rating": "C",
|
|
73
|
+
"lodgement_date": "2018-04-21",
|
|
74
|
+
},
|
|
75
|
+
"valuation": {
|
|
76
|
+
"avg_asking": 500000, "asking_psf": 450, "avg_sold": 480000,
|
|
77
|
+
"sold_psf": 440, "sold_psf_low": 400, "sold_psf_high": 480,
|
|
78
|
+
"sold_comps": [], "asking_comps": [],
|
|
79
|
+
},
|
|
80
|
+
"area": {
|
|
81
|
+
"crime_rating": "Low", "flood_risk": "Low", "council_tax_band_d": 2000,
|
|
82
|
+
"population": 1000, "crime_breakdown": [], "council_tax_bands": [],
|
|
83
|
+
"headline": "",
|
|
84
|
+
},
|
|
85
|
+
"demand_trend": {
|
|
86
|
+
"demand_rating": "High", "dom": 30, "turnover_pct": 5, "for_sale": 10,
|
|
87
|
+
"growth_series": [], "growth_psf_series": [],
|
|
88
|
+
"rental": {"demand_rating": "High", "dom": 20, "turnover_pct": 4, "for_rent": 5},
|
|
89
|
+
"type_dist": [],
|
|
90
|
+
},
|
|
91
|
+
"market_summary": {
|
|
92
|
+
"headline": "Bishop's Stortford is selling.",
|
|
93
|
+
"paragraphs": ["The market is active.", "Prices held flat YoY."],
|
|
94
|
+
"demand_gauge_position": "62%",
|
|
95
|
+
"price_growth_note": "Up 3% YoY.",
|
|
96
|
+
"demand_note": "High demand.",
|
|
97
|
+
"origin": "operator",
|
|
98
|
+
},
|
|
99
|
+
"agent_listings": [
|
|
100
|
+
{
|
|
101
|
+
"address": "1 Some Road, CM23",
|
|
102
|
+
"price": 600000,
|
|
103
|
+
"status": "Sold",
|
|
104
|
+
"slug": "1-some-road",
|
|
105
|
+
"origin": "operator",
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
"agent_overrides": {
|
|
109
|
+
"brand_selected_by_operator": True,
|
|
110
|
+
"listings_status_curated": False,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestAdherenceGate(unittest.TestCase):
|
|
116
|
+
def setUp(self):
|
|
117
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
118
|
+
self.tmp = Path(self._tmp.name)
|
|
119
|
+
|
|
120
|
+
def tearDown(self):
|
|
121
|
+
self._tmp.cleanup()
|
|
122
|
+
|
|
123
|
+
def _assert_aborts(self, inputs: dict, cause: str):
|
|
124
|
+
with self.assertRaises(PrevalAbort) as ctx:
|
|
125
|
+
_assert_inputs(inputs)
|
|
126
|
+
self.assertEqual(ctx.exception.cause, cause)
|
|
127
|
+
self.assertFalse(any(self.tmp.glob("*.pdf")))
|
|
128
|
+
|
|
129
|
+
def test_clean_run_passes(self):
|
|
130
|
+
_assert_inputs(_complete_inputs(self.tmp))
|
|
131
|
+
|
|
132
|
+
def test_cover_hero_not_subject(self):
|
|
133
|
+
# Plant the hero PNG in a sister property's folder to trigger the
|
|
134
|
+
# 157 slug heuristic. 156's symlink check passes (real file, not
|
|
135
|
+
# a symlink); only the 157 cause should fire.
|
|
136
|
+
sister = self.tmp / "estate-agents" / "muvin" / "properties" / "07-the-old-orchard-rye-street-cm23"
|
|
137
|
+
sister.mkdir(parents=True)
|
|
138
|
+
bad_hero = sister / "hero.png"
|
|
139
|
+
_write_png(bad_hero)
|
|
140
|
+
inputs = _complete_inputs(self.tmp)
|
|
141
|
+
inputs["subject"]["cover_hero"] = str(bad_hero)
|
|
142
|
+
self._assert_aborts(inputs, "cover-hero-not-subject")
|
|
143
|
+
|
|
144
|
+
def test_cover_hero_subject_match_passes(self):
|
|
145
|
+
subject_dir = self.tmp / "estate-agents" / "muvin" / "properties" / "9-phoenix-drive-cm23-2uj"
|
|
146
|
+
subject_dir.mkdir(parents=True)
|
|
147
|
+
good_hero = subject_dir / "hero.png"
|
|
148
|
+
_write_png(good_hero)
|
|
149
|
+
inputs = _complete_inputs(self.tmp)
|
|
150
|
+
inputs["subject"]["cover_hero"] = str(good_hero)
|
|
151
|
+
_assert_inputs(inputs)
|
|
152
|
+
|
|
153
|
+
def test_agent_listings_invented_status(self):
|
|
154
|
+
source = self.tmp / "source.json"
|
|
155
|
+
source.write_text(json.dumps({"status": None, "address": "1 Some Road"}))
|
|
156
|
+
inputs = _complete_inputs(self.tmp)
|
|
157
|
+
inputs["agent_listings"][0]["origin"] = str(source)
|
|
158
|
+
inputs["agent_overrides"]["listings_status_curated"] = False
|
|
159
|
+
self._assert_aborts(inputs, "agent-listings-invented")
|
|
160
|
+
|
|
161
|
+
def test_agent_listings_curated_override_passes(self):
|
|
162
|
+
source = self.tmp / "source.json"
|
|
163
|
+
source.write_text(json.dumps({"status": None, "address": "1 Some Road"}))
|
|
164
|
+
inputs = _complete_inputs(self.tmp)
|
|
165
|
+
inputs["agent_listings"][0]["origin"] = str(source)
|
|
166
|
+
inputs["agent_overrides"]["listings_status_curated"] = True
|
|
167
|
+
_assert_inputs(inputs)
|
|
168
|
+
|
|
169
|
+
def test_market_summary_invented_missing_origin(self):
|
|
170
|
+
inputs = _complete_inputs(self.tmp)
|
|
171
|
+
inputs["market_summary"].pop("origin")
|
|
172
|
+
self._assert_aborts(inputs, "market-summary-invented")
|
|
173
|
+
|
|
174
|
+
def test_market_summary_invented_explicit(self):
|
|
175
|
+
inputs = _complete_inputs(self.tmp)
|
|
176
|
+
inputs["market_summary"]["origin"] = "agent-invented"
|
|
177
|
+
self._assert_aborts(inputs, "market-summary-invented")
|
|
178
|
+
|
|
179
|
+
def test_brand_invented(self):
|
|
180
|
+
inputs = _complete_inputs(self.tmp)
|
|
181
|
+
inputs["agent_overrides"]["brand_selected_by_operator"] = False
|
|
182
|
+
self._assert_aborts(inputs, "brand-invented")
|
|
183
|
+
|
|
184
|
+
def test_epc_certificate_not_supplied_missing_m2(self):
|
|
185
|
+
inputs = _complete_inputs(self.tmp)
|
|
186
|
+
del inputs["subject"]["total_floor_area_m2"]
|
|
187
|
+
self._assert_aborts(inputs, "epc-certificate-not-supplied")
|
|
188
|
+
|
|
189
|
+
def test_epc_certificate_not_supplied_missing_rating(self):
|
|
190
|
+
inputs = _complete_inputs(self.tmp)
|
|
191
|
+
inputs["subject"]["current_energy_rating"] = None
|
|
192
|
+
self._assert_aborts(inputs, "epc-certificate-not-supplied")
|
|
193
|
+
|
|
194
|
+
def test_epc_certificate_not_supplied_missing_lodgement(self):
|
|
195
|
+
inputs = _complete_inputs(self.tmp)
|
|
196
|
+
inputs["subject"]["lodgement_date"] = ""
|
|
197
|
+
self._assert_aborts(inputs, "epc-certificate-not-supplied")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestPreToolUseHook(unittest.TestCase):
|
|
201
|
+
def _run_hook(self, tool_name: str, file_path: str, content_obj: dict) -> tuple[int, str]:
|
|
202
|
+
payload = json.dumps({
|
|
203
|
+
"tool_name": tool_name,
|
|
204
|
+
"tool_input": {"file_path": file_path, "content": json.dumps(content_obj)},
|
|
205
|
+
})
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
["bash", str(HOOK)],
|
|
208
|
+
input=payload,
|
|
209
|
+
capture_output=True,
|
|
210
|
+
text=True,
|
|
211
|
+
)
|
|
212
|
+
return result.returncode, result.stderr
|
|
213
|
+
|
|
214
|
+
def test_hook_blocks_market_summary_invented(self):
|
|
215
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
216
|
+
inputs = _complete_inputs(Path(tmp))
|
|
217
|
+
inputs["market_summary"]["origin"] = "agent-invented"
|
|
218
|
+
code, stderr = self._run_hook(
|
|
219
|
+
"Write",
|
|
220
|
+
f"{tmp}/preval/CM232UJ-2026-05-19/inputs.json",
|
|
221
|
+
inputs,
|
|
222
|
+
)
|
|
223
|
+
self.assertEqual(code, 2, msg=stderr)
|
|
224
|
+
self.assertIn("market-summary-invented", stderr)
|
|
225
|
+
|
|
226
|
+
def test_hook_blocks_brand_invented(self):
|
|
227
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
228
|
+
inputs = _complete_inputs(Path(tmp))
|
|
229
|
+
inputs["agent_overrides"]["brand_selected_by_operator"] = False
|
|
230
|
+
code, stderr = self._run_hook(
|
|
231
|
+
"Write",
|
|
232
|
+
f"{tmp}/preval/CM232UJ-2026-05-19/inputs.json",
|
|
233
|
+
inputs,
|
|
234
|
+
)
|
|
235
|
+
self.assertEqual(code, 2, msg=stderr)
|
|
236
|
+
self.assertIn("brand-invented", stderr)
|
|
237
|
+
|
|
238
|
+
def test_hook_passthrough_unrelated_path(self):
|
|
239
|
+
code, _ = self._run_hook(
|
|
240
|
+
"Write",
|
|
241
|
+
"/tmp/some-other-file.json",
|
|
242
|
+
{"hello": "world"},
|
|
243
|
+
)
|
|
244
|
+
self.assertEqual(code, 0)
|
|
245
|
+
|
|
246
|
+
def test_hook_passthrough_unrelated_tool(self):
|
|
247
|
+
code, _ = self._run_hook(
|
|
248
|
+
"Bash",
|
|
249
|
+
"/whatever/preval/inputs.json",
|
|
250
|
+
{},
|
|
251
|
+
)
|
|
252
|
+
self.assertEqual(code, 0)
|
|
253
|
+
|
|
254
|
+
def test_hook_blocks_epc_certificate_not_supplied(self):
|
|
255
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
256
|
+
inputs = _complete_inputs(Path(tmp))
|
|
257
|
+
inputs["subject"]["current_energy_rating"] = None
|
|
258
|
+
code, stderr = self._run_hook(
|
|
259
|
+
"Write",
|
|
260
|
+
f"{tmp}/preval/CM232UJ-2026-05-19/inputs.json",
|
|
261
|
+
inputs,
|
|
262
|
+
)
|
|
263
|
+
self.assertEqual(code, 2, msg=stderr)
|
|
264
|
+
self.assertIn("epc-certificate-not-supplied", stderr)
|
|
265
|
+
self.assertIn("current_energy_rating", stderr)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestObservability(unittest.TestCase):
|
|
269
|
+
def test_log_destination_unavailable_aborts(self):
|
|
270
|
+
from render import _pick_log_path
|
|
271
|
+
ro = Path("/dev/null/cannot-write-here")
|
|
272
|
+
old_home = os.environ.get("HOME")
|
|
273
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
274
|
+
holder = Path(tmp) / "not-a-dir"
|
|
275
|
+
holder.write_text("")
|
|
276
|
+
os.environ["HOME"] = str(holder)
|
|
277
|
+
try:
|
|
278
|
+
with self.assertRaises(PrevalAbort) as ctx:
|
|
279
|
+
_pick_log_path(ro)
|
|
280
|
+
self.assertEqual(ctx.exception.cause, "log-destination-unavailable")
|
|
281
|
+
finally:
|
|
282
|
+
if old_home is not None:
|
|
283
|
+
os.environ["HOME"] = old_home
|
|
284
|
+
else:
|
|
285
|
+
del os.environ["HOME"]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class TestObservabilityWatchdog(unittest.TestCase):
|
|
289
|
+
"""Task 163 — 12-call PropertyData fan-out watchdog."""
|
|
290
|
+
|
|
291
|
+
def setUp(self):
|
|
292
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
293
|
+
self.tmp = Path(self._tmp.name)
|
|
294
|
+
self.log_path = self.tmp / "preval.log"
|
|
295
|
+
|
|
296
|
+
def tearDown(self):
|
|
297
|
+
self._tmp.cleanup()
|
|
298
|
+
|
|
299
|
+
def _write_log(self, present_tools: list[str]) -> None:
|
|
300
|
+
lines = [
|
|
301
|
+
f"[property-data] tool={name} account=local postcode=CM232UJ status=ok ms=42"
|
|
302
|
+
for name in present_tools
|
|
303
|
+
]
|
|
304
|
+
self.log_path.write_text("\n".join(lines) + "\n")
|
|
305
|
+
|
|
306
|
+
def test_all_12_present_passes(self):
|
|
307
|
+
self._write_log(list(EXPECTED_PROPERTY_DATA_TOOLS))
|
|
308
|
+
_assert_observability_logs(self.log_path, deadline_s=0.0)
|
|
309
|
+
|
|
310
|
+
def test_missing_growth_psf_aborts(self):
|
|
311
|
+
present = [t for t in EXPECTED_PROPERTY_DATA_TOOLS if t != "property-data-growth-psf"]
|
|
312
|
+
self._write_log(present)
|
|
313
|
+
with self.assertRaises(PrevalAbort) as ctx:
|
|
314
|
+
_assert_observability_logs(self.log_path, deadline_s=0.0)
|
|
315
|
+
self.assertEqual(
|
|
316
|
+
ctx.exception.cause,
|
|
317
|
+
"observability-missing tool=property-data-growth-psf",
|
|
318
|
+
)
|
|
319
|
+
self.assertIn("property-data-growth-psf", ctx.exception.hint)
|
|
320
|
+
|
|
321
|
+
def test_missing_flood_risk_aborts(self):
|
|
322
|
+
present = [t for t in EXPECTED_PROPERTY_DATA_TOOLS if t != "property-data-flood-risk"]
|
|
323
|
+
self._write_log(present)
|
|
324
|
+
with self.assertRaises(PrevalAbort) as ctx:
|
|
325
|
+
_assert_observability_logs(self.log_path, deadline_s=0.0)
|
|
326
|
+
self.assertEqual(
|
|
327
|
+
ctx.exception.cause,
|
|
328
|
+
"observability-missing tool=property-data-flood-risk",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def test_polls_until_late_arriving_line_lands(self):
|
|
332
|
+
"""The watchdog must keep polling within the budget; a late arrival
|
|
333
|
+
before the deadline is a pass, not an abort. Wall-clock waits are
|
|
334
|
+
replaced by injected _now/_sleep so the test stays fast."""
|
|
335
|
+
present = [t for t in EXPECTED_PROPERTY_DATA_TOOLS if t != "property-data-prices"]
|
|
336
|
+
self._write_log(present)
|
|
337
|
+
ticks = iter([0.0, 1.0, 2.0]) # never reaches deadline_s=10
|
|
338
|
+
appended = []
|
|
339
|
+
|
|
340
|
+
def fake_sleep(s):
|
|
341
|
+
appended.append(s)
|
|
342
|
+
with self.log_path.open("a") as f:
|
|
343
|
+
f.write(
|
|
344
|
+
"[property-data] tool=property-data-prices account=local postcode=CM232UJ status=ok ms=99\n"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
_assert_observability_logs(
|
|
348
|
+
self.log_path,
|
|
349
|
+
deadline_s=10.0,
|
|
350
|
+
poll_s=2.0,
|
|
351
|
+
_now=lambda: next(ticks),
|
|
352
|
+
_sleep=fake_sleep,
|
|
353
|
+
)
|
|
354
|
+
self.assertEqual(appended, [2.0])
|
|
355
|
+
|
|
356
|
+
def test_unreadable_log_aborts_at_deadline(self):
|
|
357
|
+
"""A log path that can't be read is treated as missing tools — abort
|
|
358
|
+
once the budget elapses, name a missing tool. No silent skip."""
|
|
359
|
+
# Don't write the log file at all.
|
|
360
|
+
ticks = iter([0.0, 100.0])
|
|
361
|
+
with self.assertRaises(PrevalAbort) as ctx:
|
|
362
|
+
_assert_observability_logs(
|
|
363
|
+
self.log_path,
|
|
364
|
+
deadline_s=60.0,
|
|
365
|
+
poll_s=2.0,
|
|
366
|
+
_now=lambda: next(ticks),
|
|
367
|
+
_sleep=lambda s: None,
|
|
368
|
+
)
|
|
369
|
+
self.assertTrue(ctx.exception.cause.startswith("observability-missing tool="))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
unittest.main()
|