@rubytech/create-maxy-code 0.1.61 → 0.1.62
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/package.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +9 -7
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +41 -13
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +180 -22
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +7 -6
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +0 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +9 -7
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +41 -13
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +180 -22
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +7 -6
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +0 -1
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ Consumer sub-plugin. No MCP server, no new tools — only a skill (`property-pre
|
|
|
16
16
|
A 5-page A4 PDF and the source HTML, written to `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/`:
|
|
17
17
|
|
|
18
18
|
- **Page 1 — Cover.** Full-bleed subject hero photo with gradient scrim. Light brand wordmark top-left. Address in display-italic. Postcode chip. Four-up meta strip (Report · Address · Postcode · Generated). Lifted from the `property-market-report` cover contract.
|
|
19
|
-
- **Page 2 — Subject valuation.** KPI strip (floor area, bedrooms, EPC rating, type). Valuation panel: headline range = `subject.sqft × sold £/sqft 70pc band` (Land Registry), asking £/sqft cross-check, operator adjustments (`subject.adjustments[]`) shown as a stacked list with colour-coded percentages, final indicative range pill.
|
|
19
|
+
- **Page 2 — Subject valuation.** KPI strip (floor area, bedrooms, EPC rating, type). Valuation panel: headline range = `subject.sqft × sold £/sqft 70pc band` (Land Registry), asking £/sqft cross-check, operator adjustments (`subject.adjustments[]`) shown as a stacked list with colour-coded percentages, final indicative range pill. `subject.sqft` is required — a null value aborts the run.
|
|
20
20
|
- **Page 3 — Market context.** Market summary headline + paragraphs with drop cap. KPI strip (avg asking £, avg sold £, £/sqft). Price-growth panel (YoY) + demand pill with gauge. 7-year sparkline of £ and £/sqft. Same patterns as `property-market-report`.
|
|
21
21
|
- **Page 4 — Comparables.** Full-width Land Registry sold-transactions table (≤ 6 rows), stacked above a full-width live-asking-comparables tile grid (≤ 4 tiles). Never side-by-side — corrects the cramped two-column layout in v1 preval.
|
|
22
22
|
- **Page 5 — Recently with `<brand>` + sources.** Operator-curated `agent_listings[]` rendered as a clickable tile grid with status badges (Sold / Under offer / For sale), then the sources & methodology block. The vendor reads this page and knows who to call.
|
|
@@ -32,14 +32,16 @@ The skill fans out to **13** MCP calls in parallel:
|
|
|
32
32
|
|
|
33
33
|
## Failure handling
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- **PropertyData http-
|
|
35
|
+
Every failure is fail-loud. The skill emits `[preval] aborted reason=<cause> …` and exits 2; no partial `index.html` or PDF is left on disk.
|
|
36
|
+
|
|
37
|
+
- **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
|
|
38
|
+
- **PropertyData `http-429`** → back off and retry the single throttled tool once. A second `http-429` aborts `reason=property-data-rate-limited`.
|
|
39
|
+
- **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
|
|
40
|
+
- **EPC failure of any kind** — `key-not-registered`, `http-404 (no record)`, `http-429`, `http-5xx`, network failure, EPC MCP not configured — aborts the run. There is no in-band prompt and no benchmark-only mode. The operator's options before re-running are (a) run Task 155 to deploy / register the EPC MCP, or (b) paste `subject.sqft` and `subject.sqft_m2` into the inputs as a deliberate operator override.
|
|
41
|
+
- See `skills/property-preval/SKILL.md` § Hard breakpoints for the full table of abort causes.
|
|
40
42
|
|
|
41
43
|
## External dependencies
|
|
42
44
|
|
|
43
45
|
- Requires `real-agent-property-data` enabled on the account with a registered PropertyData API key (`property-data-key-register`).
|
|
44
|
-
- Requires `real-agent-epc` enabled with a registered EPC API key (`epc-key-register`)
|
|
46
|
+
- Requires `real-agent-epc` enabled with a registered EPC API key (`epc-key-register`), **or** an operator-supplied `subject.sqft` + `subject.sqft_m2` override pre-baked into the inputs. EPC failures are not silently absorbed.
|
|
45
47
|
- Requires Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` for headless `--print-to-pdf`. Same Chrome dependency as `property-market-report`.
|
|
@@ -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
|
-
- **Floor area is auto-pulled from the EPC register** via `real-agent-epc` (`epc-lookup-by-address`) and surfaced as `sqft_m2` + derived `sqft`. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), operator pastes `subject.sqft` directly
|
|
29
|
+
- **Floor area is auto-pulled from the EPC register** via `real-agent-epc` (`epc-lookup-by-address`) and surfaced as `sqft_m2` + derived `sqft`. Both are required. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), the operator pastes `subject.sqft` directly **before invocation** as a deliberate override. There is no mid-run paste prompt and no benchmark-only render — a null `sqft` aborts the run with `reason=subject-sqft-missing`.
|
|
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
|
|
|
@@ -67,7 +67,7 @@ Return the PDF path to the operator.
|
|
|
67
67
|
## Pages
|
|
68
68
|
|
|
69
69
|
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.
|
|
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. One paragraph of `subject.narrative` under the panel. `sqft` is required — a null value aborts the run, never renders a benchmark-only panel.
|
|
71
71
|
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
72
|
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
73
|
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,10 +75,11 @@ Return the PDF path to the operator.
|
|
|
75
75
|
## How to execute
|
|
76
76
|
|
|
77
77
|
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 13 fan-out tools (12 PropertyData + 1 EPC) in parallel.
|
|
79
|
-
- **PropertyData `key-not-registered`** → abort with the `property-data-key-register`
|
|
80
|
-
- **PropertyData `http-429`** → back off
|
|
81
|
-
- **
|
|
78
|
+
2. Run the 13 fan-out tools (12 PropertyData + 1 EPC) in parallel. Every failure is fail-loud — no mid-run paste prompts, no benchmark-only render, no degraded pack on disk:
|
|
79
|
+
- **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
|
|
80
|
+
- **PropertyData `http-429`** → back off and retry the single throttled tool **once**. A second `http-429` aborts `reason=property-data-rate-limited`.
|
|
81
|
+
- **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
|
|
82
|
+
- **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-5xx`, network failure, or MCP not configured on the account — aborts the run. The operator's options before re-running are (a) run Task 155 to deploy / register the EPC MCP, or (b) paste `subject.sqft` (plus `subject.sqft_m2`) into the inputs as a deliberate operator override. There is no in-band prompt and no benchmark-only mode.
|
|
82
83
|
3. Compute valuation range:
|
|
83
84
|
- `sold_psf_low` = `sold-prices-per-sqf` `70pc_range[0]`
|
|
84
85
|
- `sold_psf_high` = `sold-prices-per-sqf` `70pc_range[1]`
|
|
@@ -96,16 +97,43 @@ Deltas sum linearly (`-8 + 3 = -5%`, applied as `× 0.95`). Compound interpretat
|
|
|
96
97
|
|
|
97
98
|
- `[preval] start address="<addr>" postcode=<pc>` at run start.
|
|
98
99
|
- `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
|
|
100
|
+
- `[preval] aborted reason=<cause> address="<addr>" postcode=<pc> ms=<n>` on any abort (one line, then exit 2; no `done`).
|
|
99
101
|
- Each fan-out call emits its own `[property-data] tool=<name>` or `[epc] tool=<name>` line in `server.log` (inherited contracts; no new instrumentation here).
|
|
100
102
|
|
|
101
|
-
##
|
|
103
|
+
## Hard breakpoints
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
| Forbidden state | `reason=` cause |
|
|
108
|
+
|---|---|
|
|
109
|
+
| EPC MCP not configured / `key-not-registered` / `http-5xx` / network error | `epc-mcp-unavailable` (operator must run Task 155 or paste `subject.sqft` before invocation) |
|
|
110
|
+
| `subject.sqft` null after EPC lookup | `subject-sqft-missing` |
|
|
111
|
+
| `subject.sqft_m2` null | `subject-sqft-m2-missing` |
|
|
112
|
+
| `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` |
|
|
113
|
+
| `brand` not resolved from an on-disk `DESIGN.md` (required token missing) | `brand-unresolved` |
|
|
114
|
+
| `agent_listings` empty | `agent-listings-empty` |
|
|
115
|
+
| `market_summary.paragraphs` < 2 entries | `market-summary-too-thin` |
|
|
116
|
+
| PropertyData any-call `http-429` after 1 retry | `property-data-rate-limited` |
|
|
117
|
+
| PropertyData any-call `key-not-registered` | `property-data-key-not-registered` (operator runs `property-data-key-register`) |
|
|
118
|
+
| EPC lookup returns `http-404` (no record on file) | `epc-no-record` (operator pastes `subject.sqft` as override and re-runs, or accepts that this address can't be preval'd until lodged) |
|
|
119
|
+
|
|
120
|
+
## Forbidden behaviours
|
|
121
|
+
|
|
122
|
+
The skill is the contract; if any of these slip through, the pack must not render:
|
|
123
|
+
|
|
124
|
+
- Silent fallback selection — switching to a benchmark-only / "Estimate requires floor area" panel when an input is missing.
|
|
125
|
+
- Inventing inputs — fabricating `brand`, `subject.cover_hero`, `market_summary.paragraphs`, `agent_listings`, or any field the operator did not supply.
|
|
126
|
+
- Reusing a hero photo from a different property as a placeholder.
|
|
127
|
+
- Defaulting `agent_listings[].status` to `"For sale"` when the source is null.
|
|
128
|
+
- Mid-run prompts for a paste-in (EPC retries, sqft, etc). Overrides are operator-supplied **before** invocation.
|
|
129
|
+
- Omitting the `[preval]` start / done / aborted log emissions.
|
|
130
|
+
- Leaving partial output (`index.html`, `<stem>.pdf`) on disk after an abort.
|
|
131
|
+
|
|
132
|
+
## Failure diagnostics
|
|
133
|
+
|
|
134
|
+
- `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.
|
|
135
|
+
- `start` with no matching `done` or `aborted` within 60s → renderer crashed mid-run. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
|
|
136
|
+
- `[preval] aborted reason=<cause>` → look up `<cause>` in the **Hard breakpoints** table for the remediation hint.
|
|
109
137
|
|
|
110
138
|
## Out of scope
|
|
111
139
|
|
|
@@ -4,11 +4,14 @@ Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
|
|
|
4
4
|
derived from one inputs.json (shape: template-inputs.schema.json), drives
|
|
5
5
|
Chrome headless to produce a 5-page A4 PDF, and asserts page count == 5.
|
|
6
6
|
|
|
7
|
+
Fails loud on any missing / unverifiable load-bearing input. No silent
|
|
8
|
+
fallback modes — see _assert_inputs.
|
|
9
|
+
|
|
7
10
|
Usage:
|
|
8
11
|
python3 render.py inputs.json
|
|
9
12
|
"""
|
|
10
13
|
from __future__ import annotations
|
|
11
|
-
import html, json, re, shutil, subprocess, sys
|
|
14
|
+
import html, json, re, shutil, subprocess, sys, time
|
|
12
15
|
from pathlib import Path
|
|
13
16
|
|
|
14
17
|
|
|
@@ -214,20 +217,9 @@ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
|
|
|
214
217
|
def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
215
218
|
"""Build the page-2 valuation panel: headline range, adjustments, final range.
|
|
216
219
|
|
|
217
|
-
|
|
220
|
+
sqft is asserted non-null upstream by _assert_inputs — no fallback panel here.
|
|
218
221
|
"""
|
|
219
|
-
sqft = subject
|
|
220
|
-
if not sqft:
|
|
221
|
-
return (
|
|
222
|
-
'<div class="val-panel"><div class="val-empty">'
|
|
223
|
-
"Estimate requires floor area.<br>"
|
|
224
|
-
"<span style=\"font-size:10pt;color:var(--text-muted)\">"
|
|
225
|
-
f"Benchmark: sold £/sqft = {_money(valuation['sold_psf'])}, "
|
|
226
|
-
f"asking £/sqft = {_money(valuation['asking_psf'])}. "
|
|
227
|
-
"Paste subject.sqft to compute the range."
|
|
228
|
-
"</span></div></div>"
|
|
229
|
-
)
|
|
230
|
-
|
|
222
|
+
sqft = subject["sqft"]
|
|
231
223
|
low_psf = valuation["sold_psf_low"]
|
|
232
224
|
mid_psf = valuation["sold_psf"]
|
|
233
225
|
high_psf = valuation["sold_psf_high"]
|
|
@@ -277,12 +269,10 @@ def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
|
277
269
|
|
|
278
270
|
|
|
279
271
|
def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
|
|
280
|
-
sqft = subject
|
|
272
|
+
sqft = subject["sqft"]
|
|
281
273
|
m2 = subject.get("sqft_m2")
|
|
282
|
-
if
|
|
283
|
-
|
|
284
|
-
return f"{int(sqft):,}", sub
|
|
285
|
-
return "—", "No EPC on file"
|
|
274
|
+
sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
|
|
275
|
+
return f"{int(sqft):,}", sub
|
|
286
276
|
|
|
287
277
|
|
|
288
278
|
def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
@@ -299,9 +289,145 @@ def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
|
299
289
|
return current, " · ".join(sub_bits) or "EPC register"
|
|
300
290
|
|
|
301
291
|
|
|
292
|
+
# ---------------- input assertions ----------------
|
|
293
|
+
|
|
294
|
+
class PrevalAbort(RuntimeError):
|
|
295
|
+
"""Raised when a load-bearing input is missing or unverifiable.
|
|
296
|
+
|
|
297
|
+
.cause is the short slug logged as `reason=<cause>` and shown in the
|
|
298
|
+
operator's remediation hint.
|
|
299
|
+
"""
|
|
300
|
+
def __init__(self, cause: str, hint: str):
|
|
301
|
+
super().__init__(f"{cause}: {hint}")
|
|
302
|
+
self.cause = cause
|
|
303
|
+
self.hint = hint
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
|
|
307
|
+
"""Existence + dimension check. Symlinks pointing into another property's
|
|
308
|
+
folder are rejected (cover-hero cross-contamination guard)."""
|
|
309
|
+
p = Path(path_str).expanduser()
|
|
310
|
+
if not p.exists():
|
|
311
|
+
raise PrevalAbort(
|
|
312
|
+
"subject-cover-hero-missing",
|
|
313
|
+
f"cover_hero path does not exist: {p}",
|
|
314
|
+
)
|
|
315
|
+
if p.is_symlink():
|
|
316
|
+
target = p.resolve()
|
|
317
|
+
parts = target.parts
|
|
318
|
+
if "properties" in parts:
|
|
319
|
+
# symlink → properties/<other-slug>/… is a cross-contamination
|
|
320
|
+
raise PrevalAbort(
|
|
321
|
+
"subject-cover-hero-symlinked-foreign",
|
|
322
|
+
f"cover_hero is a symlink into another property's folder: {target}",
|
|
323
|
+
)
|
|
324
|
+
# Dimensions. Avoid a Pillow dep; read PNG/JPEG headers directly.
|
|
325
|
+
w, h = _image_dimensions(p)
|
|
326
|
+
if w is None or h is None:
|
|
327
|
+
raise PrevalAbort(
|
|
328
|
+
"subject-cover-hero-unreadable",
|
|
329
|
+
f"cover_hero is not a readable PNG or JPEG: {p}",
|
|
330
|
+
)
|
|
331
|
+
if w < min_w or h < min_h:
|
|
332
|
+
raise PrevalAbort(
|
|
333
|
+
"subject-cover-hero-too-small",
|
|
334
|
+
f"cover_hero is {w}×{h}, below the {min_w}×{min_h} minimum: {p}",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
|
|
339
|
+
"""Return (width, height) for PNG or JPEG. None on any failure."""
|
|
340
|
+
try:
|
|
341
|
+
with p.open("rb") as f:
|
|
342
|
+
head = f.read(24)
|
|
343
|
+
if len(head) < 24:
|
|
344
|
+
return None, None
|
|
345
|
+
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
|
346
|
+
# IHDR width/height at bytes 16..24, big-endian uint32.
|
|
347
|
+
w = int.from_bytes(head[16:20], "big")
|
|
348
|
+
h = int.from_bytes(head[20:24], "big")
|
|
349
|
+
return w, h
|
|
350
|
+
if head[:2] == b"\xff\xd8":
|
|
351
|
+
f.seek(2)
|
|
352
|
+
while True:
|
|
353
|
+
b = f.read(1)
|
|
354
|
+
while b and b != b"\xff":
|
|
355
|
+
b = f.read(1)
|
|
356
|
+
marker = f.read(1)
|
|
357
|
+
if not marker:
|
|
358
|
+
return None, None
|
|
359
|
+
if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
|
|
360
|
+
f.read(3) # length(2) + precision(1)
|
|
361
|
+
h = int.from_bytes(f.read(2), "big")
|
|
362
|
+
w = int.from_bytes(f.read(2), "big")
|
|
363
|
+
return w, h
|
|
364
|
+
seg_len = int.from_bytes(f.read(2), "big")
|
|
365
|
+
f.seek(seg_len - 2, 1)
|
|
366
|
+
return None, None
|
|
367
|
+
except OSError:
|
|
368
|
+
return None, None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _assert_inputs(inputs: dict) -> None:
|
|
372
|
+
"""Fail loud on any missing or unverifiable load-bearing input.
|
|
373
|
+
|
|
374
|
+
Raises PrevalAbort with a named cause. No silent fallback paths.
|
|
375
|
+
"""
|
|
376
|
+
subject = inputs.get("subject") or {}
|
|
377
|
+
|
|
378
|
+
# subject.sqft / sqft_m2 — required, non-null integers.
|
|
379
|
+
if not subject.get("sqft"):
|
|
380
|
+
raise PrevalAbort(
|
|
381
|
+
"subject-sqft-missing",
|
|
382
|
+
"subject.sqft is null — run epc-key-register and re-derive, or paste subject.sqft as an operator override before invocation.",
|
|
383
|
+
)
|
|
384
|
+
if not subject.get("sqft_m2"):
|
|
385
|
+
raise PrevalAbort(
|
|
386
|
+
"subject-sqft-m2-missing",
|
|
387
|
+
"subject.sqft_m2 is null — the EPC m² figure must be supplied alongside sqft.",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# subject.cover_hero — required, exists, ≥ 1200×800, not symlinked into another property's folder.
|
|
391
|
+
hero = subject.get("cover_hero")
|
|
392
|
+
if not hero:
|
|
393
|
+
raise PrevalAbort(
|
|
394
|
+
"subject-cover-hero-missing",
|
|
395
|
+
"subject.cover_hero is required for the cover page.",
|
|
396
|
+
)
|
|
397
|
+
if not hero.startswith(("http://", "https://", "data:")):
|
|
398
|
+
_assert_path_image(hero)
|
|
399
|
+
|
|
400
|
+
# brand — must be the resolved DESIGN.md object, not a stub.
|
|
401
|
+
brand = inputs.get("brand") or {}
|
|
402
|
+
required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
|
|
403
|
+
missing_brand = [k for k in required_brand if not brand.get(k)]
|
|
404
|
+
if missing_brand:
|
|
405
|
+
raise PrevalAbort(
|
|
406
|
+
"brand-unresolved",
|
|
407
|
+
f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# agent_listings — non-empty.
|
|
411
|
+
if not inputs.get("agent_listings"):
|
|
412
|
+
raise PrevalAbort(
|
|
413
|
+
"agent-listings-empty",
|
|
414
|
+
"agent_listings is empty — paste at least one recent listing for the closing page.",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# market_summary.paragraphs — at least 2.
|
|
418
|
+
paras = (inputs.get("market_summary") or {}).get("paragraphs") or []
|
|
419
|
+
if len(paras) < 2:
|
|
420
|
+
raise PrevalAbort(
|
|
421
|
+
"market-summary-too-thin",
|
|
422
|
+
f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
302
426
|
# ---------------- render ----------------
|
|
303
427
|
|
|
304
428
|
def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
|
|
429
|
+
_assert_inputs(inputs)
|
|
430
|
+
|
|
305
431
|
template_path = template_path or (Path(__file__).parent / "template.html")
|
|
306
432
|
tmpl = template_path.read_text()
|
|
307
433
|
|
|
@@ -454,16 +580,48 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
|
454
580
|
return n
|
|
455
581
|
|
|
456
582
|
|
|
583
|
+
def _abort(cause: str, hint: str, *, address: str, postcode: str, t0: float, out_dir: Path | None) -> None:
|
|
584
|
+
"""Emit the abort log line, scrub any partial output, exit non-zero."""
|
|
585
|
+
ms = int((time.monotonic() - t0) * 1000)
|
|
586
|
+
print(
|
|
587
|
+
f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
|
|
588
|
+
file=sys.stderr,
|
|
589
|
+
)
|
|
590
|
+
print(hint, file=sys.stderr)
|
|
591
|
+
if out_dir is not None:
|
|
592
|
+
for name in ("index.html",):
|
|
593
|
+
p = out_dir / name
|
|
594
|
+
if p.exists():
|
|
595
|
+
p.unlink()
|
|
596
|
+
# Any PDF whose stem matches filename_stem would be a partial — caller passes the stem.
|
|
597
|
+
sys.exit(2)
|
|
598
|
+
|
|
599
|
+
|
|
457
600
|
def main():
|
|
601
|
+
t0 = time.monotonic()
|
|
458
602
|
if len(sys.argv) < 2:
|
|
459
603
|
print("Usage: render.py <inputs.json>", file=sys.stderr)
|
|
460
604
|
sys.exit(2)
|
|
461
605
|
inputs = json.loads(Path(sys.argv[1]).read_text())
|
|
462
606
|
out_dir = Path(inputs["out_dir"]).expanduser()
|
|
463
607
|
base = inputs["filename_stem"]
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
608
|
+
address = inputs.get("address", "")
|
|
609
|
+
postcode = inputs.get("postcode", "")
|
|
610
|
+
try:
|
|
611
|
+
render(inputs, out_dir)
|
|
612
|
+
pdf = render_pdf(out_dir, f"{base}.pdf")
|
|
613
|
+
pages = assert_page_count(pdf, 5)
|
|
614
|
+
except PrevalAbort as e:
|
|
615
|
+
# Scrub partial PDF too, if rendered before page-count failure.
|
|
616
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
617
|
+
if pdf_path.exists():
|
|
618
|
+
pdf_path.unlink()
|
|
619
|
+
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0, out_dir=out_dir)
|
|
620
|
+
except RuntimeError as e:
|
|
621
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
622
|
+
if pdf_path.exists():
|
|
623
|
+
pdf_path.unlink()
|
|
624
|
+
_abort("render-error", str(e), address=address, postcode=postcode, t0=t0, out_dir=out_dir)
|
|
467
625
|
print(f"HTML → {out_dir/'index.html'}")
|
|
468
626
|
print(f"PDF → {pdf} ({pages} pages)")
|
|
469
627
|
|
|
@@ -34,15 +34,15 @@
|
|
|
34
34
|
|
|
35
35
|
"subject": {
|
|
36
36
|
"type": "object",
|
|
37
|
-
"required": ["cover_hero"],
|
|
37
|
+
"required": ["cover_hero", "sqft", "sqft_m2"],
|
|
38
38
|
"properties": {
|
|
39
|
-
"cover_hero": { "type": "string", "description": "Filesystem path or URL to a photo of the subject property — required for the cover." },
|
|
39
|
+
"cover_hero": { "type": "string", "description": "Filesystem path or URL to a photo of the subject property — required for the cover. Renderer asserts the file exists, is ≥ 1200×800, and is not symlinked to another property's folder." },
|
|
40
40
|
"beds": { "type": ["integer", "null"] },
|
|
41
41
|
"type": { "type": ["string", "null"], "description": "e.g. 'Semi-detached', 'Terraced', 'Flat'." },
|
|
42
42
|
"tenure": { "type": ["string", "null"], "description": "e.g. 'Freehold', 'Leasehold'." },
|
|
43
43
|
"condition": { "type": ["string", "null"], "description": "'well' | 'average' | 'needs work'." },
|
|
44
|
-
"sqft": { "type":
|
|
45
|
-
"sqft_m2": { "type":
|
|
44
|
+
"sqft": { "type": "integer", "description": "Total internal area in square feet. Required. Pulled from EPC (m² × 10.7639, rounded) when available; operator paste-in is the only override. Null aborts the run." },
|
|
45
|
+
"sqft_m2": { "type": "number", "description": "Total internal area in square metres from the EPC register. Required alongside sqft." },
|
|
46
46
|
"epc_current": { "type": ["string", "null"], "description": "Current energy rating, e.g. 'C'." },
|
|
47
47
|
"epc_potential": { "type": ["string", "null"], "description": "Potential energy rating, e.g. 'B'." },
|
|
48
48
|
"epc_lodgement": { "type": ["string", "null"], "description": "ISO date of the EPC lodgement." },
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"required": ["headline", "paragraphs", "demand_gauge_position", "price_growth_note", "demand_note"],
|
|
159
159
|
"properties": {
|
|
160
160
|
"headline": { "type": "string", "description": "One-line display-italic h2 for the market context page." },
|
|
161
|
-
"paragraphs": { "type": "array", "items": { "type": "string" }, "description": "2–3 short paragraphs. First gets a drop cap." },
|
|
161
|
+
"paragraphs": { "type": "array", "minItems": 2, "items": { "type": "string" }, "description": "2–3 short paragraphs. First gets a drop cap. Fewer than 2 aborts the run." },
|
|
162
162
|
"demand_gauge_position": { "type": "string", "description": "Percentage string for the gauge marker position, e.g. '62%'." },
|
|
163
163
|
"price_growth_note": { "type": "string" },
|
|
164
164
|
"demand_note": { "type": "string" }
|
|
@@ -167,8 +167,9 @@
|
|
|
167
167
|
|
|
168
168
|
"agent_listings": {
|
|
169
169
|
"type": "array",
|
|
170
|
+
"minItems": 1,
|
|
170
171
|
"maxItems": 6,
|
|
171
|
-
"description": "Agent's own recent listings to feature on the closing page. Operator paste-in (not the PropertyData sample).",
|
|
172
|
+
"description": "Agent's own recent listings to feature on the closing page. Operator paste-in (not the PropertyData sample). Empty array aborts the run.",
|
|
172
173
|
"items": {
|
|
173
174
|
"type": "object",
|
|
174
175
|
"required": ["address", "price", "status", "slug"],
|
|
@@ -105,7 +105,6 @@
|
|
|
105
105
|
.val-final{margin-top:5mm;padding:5mm 6mm;background:var(--teal-900);color:var(--text-on-dark);border-radius:var(--radius-lg);display:flex;justify-content:space-between;align-items:baseline;gap:4mm}
|
|
106
106
|
.val-final .lbl{font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--gold-500)}
|
|
107
107
|
.val-final .num{font-family:var(--display);font-style:italic;font-weight:500;font-size:26pt;color:var(--text-on-dark);letter-spacing:-.4pt}
|
|
108
|
-
.val-empty{padding:8mm;text-align:center;font-family:var(--display);font-style:italic;font-size:14pt;color:var(--text-muted)}
|
|
109
108
|
|
|
110
109
|
/* --- TABLES --- */
|
|
111
110
|
table.cmp{width:100%;border-collapse:collapse;font-size:10pt;font-family:var(--body)}
|
|
@@ -16,7 +16,7 @@ Consumer sub-plugin. No MCP server, no new tools — only a skill (`property-pre
|
|
|
16
16
|
A 5-page A4 PDF and the source HTML, written to `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/`:
|
|
17
17
|
|
|
18
18
|
- **Page 1 — Cover.** Full-bleed subject hero photo with gradient scrim. Light brand wordmark top-left. Address in display-italic. Postcode chip. Four-up meta strip (Report · Address · Postcode · Generated). Lifted from the `property-market-report` cover contract.
|
|
19
|
-
- **Page 2 — Subject valuation.** KPI strip (floor area, bedrooms, EPC rating, type). Valuation panel: headline range = `subject.sqft × sold £/sqft 70pc band` (Land Registry), asking £/sqft cross-check, operator adjustments (`subject.adjustments[]`) shown as a stacked list with colour-coded percentages, final indicative range pill.
|
|
19
|
+
- **Page 2 — Subject valuation.** KPI strip (floor area, bedrooms, EPC rating, type). Valuation panel: headline range = `subject.sqft × sold £/sqft 70pc band` (Land Registry), asking £/sqft cross-check, operator adjustments (`subject.adjustments[]`) shown as a stacked list with colour-coded percentages, final indicative range pill. `subject.sqft` is required — a null value aborts the run.
|
|
20
20
|
- **Page 3 — Market context.** Market summary headline + paragraphs with drop cap. KPI strip (avg asking £, avg sold £, £/sqft). Price-growth panel (YoY) + demand pill with gauge. 7-year sparkline of £ and £/sqft. Same patterns as `property-market-report`.
|
|
21
21
|
- **Page 4 — Comparables.** Full-width Land Registry sold-transactions table (≤ 6 rows), stacked above a full-width live-asking-comparables tile grid (≤ 4 tiles). Never side-by-side — corrects the cramped two-column layout in v1 preval.
|
|
22
22
|
- **Page 5 — Recently with `<brand>` + sources.** Operator-curated `agent_listings[]` rendered as a clickable tile grid with status badges (Sold / Under offer / For sale), then the sources & methodology block. The vendor reads this page and knows who to call.
|
|
@@ -32,14 +32,16 @@ The skill fans out to **13** MCP calls in parallel:
|
|
|
32
32
|
|
|
33
33
|
## Failure handling
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- **PropertyData http-
|
|
35
|
+
Every failure is fail-loud. The skill emits `[preval] aborted reason=<cause> …` and exits 2; no partial `index.html` or PDF is left on disk.
|
|
36
|
+
|
|
37
|
+
- **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
|
|
38
|
+
- **PropertyData `http-429`** → back off and retry the single throttled tool once. A second `http-429` aborts `reason=property-data-rate-limited`.
|
|
39
|
+
- **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
|
|
40
|
+
- **EPC failure of any kind** — `key-not-registered`, `http-404 (no record)`, `http-429`, `http-5xx`, network failure, EPC MCP not configured — aborts the run. There is no in-band prompt and no benchmark-only mode. The operator's options before re-running are (a) run Task 155 to deploy / register the EPC MCP, or (b) paste `subject.sqft` and `subject.sqft_m2` into the inputs as a deliberate operator override.
|
|
41
|
+
- See `skills/property-preval/SKILL.md` § Hard breakpoints for the full table of abort causes.
|
|
40
42
|
|
|
41
43
|
## External dependencies
|
|
42
44
|
|
|
43
45
|
- Requires `real-agent-property-data` enabled on the account with a registered PropertyData API key (`property-data-key-register`).
|
|
44
|
-
- Requires `real-agent-epc` enabled with a registered EPC API key (`epc-key-register`)
|
|
46
|
+
- Requires `real-agent-epc` enabled with a registered EPC API key (`epc-key-register`), **or** an operator-supplied `subject.sqft` + `subject.sqft_m2` override pre-baked into the inputs. EPC failures are not silently absorbed.
|
|
45
47
|
- Requires Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` for headless `--print-to-pdf`. Same Chrome dependency as `property-market-report`.
|
|
@@ -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
|
-
- **Floor area is auto-pulled from the EPC register** via `real-agent-epc` (`epc-lookup-by-address`) and surfaced as `sqft_m2` + derived `sqft`. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), operator pastes `subject.sqft` directly
|
|
29
|
+
- **Floor area is auto-pulled from the EPC register** via `real-agent-epc` (`epc-lookup-by-address`) and surfaced as `sqft_m2` + derived `sqft`. Both are required. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), the operator pastes `subject.sqft` directly **before invocation** as a deliberate override. There is no mid-run paste prompt and no benchmark-only render — a null `sqft` aborts the run with `reason=subject-sqft-missing`.
|
|
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
|
|
|
@@ -67,7 +67,7 @@ Return the PDF path to the operator.
|
|
|
67
67
|
## Pages
|
|
68
68
|
|
|
69
69
|
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.
|
|
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. One paragraph of `subject.narrative` under the panel. `sqft` is required — a null value aborts the run, never renders a benchmark-only panel.
|
|
71
71
|
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
72
|
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
73
|
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,10 +75,11 @@ Return the PDF path to the operator.
|
|
|
75
75
|
## How to execute
|
|
76
76
|
|
|
77
77
|
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 13 fan-out tools (12 PropertyData + 1 EPC) in parallel.
|
|
79
|
-
- **PropertyData `key-not-registered`** → abort with the `property-data-key-register`
|
|
80
|
-
- **PropertyData `http-429`** → back off
|
|
81
|
-
- **
|
|
78
|
+
2. Run the 13 fan-out tools (12 PropertyData + 1 EPC) in parallel. Every failure is fail-loud — no mid-run paste prompts, no benchmark-only render, no degraded pack on disk:
|
|
79
|
+
- **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
|
|
80
|
+
- **PropertyData `http-429`** → back off and retry the single throttled tool **once**. A second `http-429` aborts `reason=property-data-rate-limited`.
|
|
81
|
+
- **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
|
|
82
|
+
- **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-5xx`, network failure, or MCP not configured on the account — aborts the run. The operator's options before re-running are (a) run Task 155 to deploy / register the EPC MCP, or (b) paste `subject.sqft` (plus `subject.sqft_m2`) into the inputs as a deliberate operator override. There is no in-band prompt and no benchmark-only mode.
|
|
82
83
|
3. Compute valuation range:
|
|
83
84
|
- `sold_psf_low` = `sold-prices-per-sqf` `70pc_range[0]`
|
|
84
85
|
- `sold_psf_high` = `sold-prices-per-sqf` `70pc_range[1]`
|
|
@@ -96,16 +97,43 @@ Deltas sum linearly (`-8 + 3 = -5%`, applied as `× 0.95`). Compound interpretat
|
|
|
96
97
|
|
|
97
98
|
- `[preval] start address="<addr>" postcode=<pc>` at run start.
|
|
98
99
|
- `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
|
|
100
|
+
- `[preval] aborted reason=<cause> address="<addr>" postcode=<pc> ms=<n>` on any abort (one line, then exit 2; no `done`).
|
|
99
101
|
- Each fan-out call emits its own `[property-data] tool=<name>` or `[epc] tool=<name>` line in `server.log` (inherited contracts; no new instrumentation here).
|
|
100
102
|
|
|
101
|
-
##
|
|
103
|
+
## Hard breakpoints
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
| Forbidden state | `reason=` cause |
|
|
108
|
+
|---|---|
|
|
109
|
+
| EPC MCP not configured / `key-not-registered` / `http-5xx` / network error | `epc-mcp-unavailable` (operator must run Task 155 or paste `subject.sqft` before invocation) |
|
|
110
|
+
| `subject.sqft` null after EPC lookup | `subject-sqft-missing` |
|
|
111
|
+
| `subject.sqft_m2` null | `subject-sqft-m2-missing` |
|
|
112
|
+
| `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` |
|
|
113
|
+
| `brand` not resolved from an on-disk `DESIGN.md` (required token missing) | `brand-unresolved` |
|
|
114
|
+
| `agent_listings` empty | `agent-listings-empty` |
|
|
115
|
+
| `market_summary.paragraphs` < 2 entries | `market-summary-too-thin` |
|
|
116
|
+
| PropertyData any-call `http-429` after 1 retry | `property-data-rate-limited` |
|
|
117
|
+
| PropertyData any-call `key-not-registered` | `property-data-key-not-registered` (operator runs `property-data-key-register`) |
|
|
118
|
+
| EPC lookup returns `http-404` (no record on file) | `epc-no-record` (operator pastes `subject.sqft` as override and re-runs, or accepts that this address can't be preval'd until lodged) |
|
|
119
|
+
|
|
120
|
+
## Forbidden behaviours
|
|
121
|
+
|
|
122
|
+
The skill is the contract; if any of these slip through, the pack must not render:
|
|
123
|
+
|
|
124
|
+
- Silent fallback selection — switching to a benchmark-only / "Estimate requires floor area" panel when an input is missing.
|
|
125
|
+
- Inventing inputs — fabricating `brand`, `subject.cover_hero`, `market_summary.paragraphs`, `agent_listings`, or any field the operator did not supply.
|
|
126
|
+
- Reusing a hero photo from a different property as a placeholder.
|
|
127
|
+
- Defaulting `agent_listings[].status` to `"For sale"` when the source is null.
|
|
128
|
+
- Mid-run prompts for a paste-in (EPC retries, sqft, etc). Overrides are operator-supplied **before** invocation.
|
|
129
|
+
- Omitting the `[preval]` start / done / aborted log emissions.
|
|
130
|
+
- Leaving partial output (`index.html`, `<stem>.pdf`) on disk after an abort.
|
|
131
|
+
|
|
132
|
+
## Failure diagnostics
|
|
133
|
+
|
|
134
|
+
- `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.
|
|
135
|
+
- `start` with no matching `done` or `aborted` within 60s → renderer crashed mid-run. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
|
|
136
|
+
- `[preval] aborted reason=<cause>` → look up `<cause>` in the **Hard breakpoints** table for the remediation hint.
|
|
109
137
|
|
|
110
138
|
## Out of scope
|
|
111
139
|
|
|
@@ -4,11 +4,14 @@ Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
|
|
|
4
4
|
derived from one inputs.json (shape: template-inputs.schema.json), drives
|
|
5
5
|
Chrome headless to produce a 5-page A4 PDF, and asserts page count == 5.
|
|
6
6
|
|
|
7
|
+
Fails loud on any missing / unverifiable load-bearing input. No silent
|
|
8
|
+
fallback modes — see _assert_inputs.
|
|
9
|
+
|
|
7
10
|
Usage:
|
|
8
11
|
python3 render.py inputs.json
|
|
9
12
|
"""
|
|
10
13
|
from __future__ import annotations
|
|
11
|
-
import html, json, re, shutil, subprocess, sys
|
|
14
|
+
import html, json, re, shutil, subprocess, sys, time
|
|
12
15
|
from pathlib import Path
|
|
13
16
|
|
|
14
17
|
|
|
@@ -214,20 +217,9 @@ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
|
|
|
214
217
|
def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
215
218
|
"""Build the page-2 valuation panel: headline range, adjustments, final range.
|
|
216
219
|
|
|
217
|
-
|
|
220
|
+
sqft is asserted non-null upstream by _assert_inputs — no fallback panel here.
|
|
218
221
|
"""
|
|
219
|
-
sqft = subject
|
|
220
|
-
if not sqft:
|
|
221
|
-
return (
|
|
222
|
-
'<div class="val-panel"><div class="val-empty">'
|
|
223
|
-
"Estimate requires floor area.<br>"
|
|
224
|
-
"<span style=\"font-size:10pt;color:var(--text-muted)\">"
|
|
225
|
-
f"Benchmark: sold £/sqft = {_money(valuation['sold_psf'])}, "
|
|
226
|
-
f"asking £/sqft = {_money(valuation['asking_psf'])}. "
|
|
227
|
-
"Paste subject.sqft to compute the range."
|
|
228
|
-
"</span></div></div>"
|
|
229
|
-
)
|
|
230
|
-
|
|
222
|
+
sqft = subject["sqft"]
|
|
231
223
|
low_psf = valuation["sold_psf_low"]
|
|
232
224
|
mid_psf = valuation["sold_psf"]
|
|
233
225
|
high_psf = valuation["sold_psf_high"]
|
|
@@ -277,12 +269,10 @@ def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
|
277
269
|
|
|
278
270
|
|
|
279
271
|
def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
|
|
280
|
-
sqft = subject
|
|
272
|
+
sqft = subject["sqft"]
|
|
281
273
|
m2 = subject.get("sqft_m2")
|
|
282
|
-
if
|
|
283
|
-
|
|
284
|
-
return f"{int(sqft):,}", sub
|
|
285
|
-
return "—", "No EPC on file"
|
|
274
|
+
sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
|
|
275
|
+
return f"{int(sqft):,}", sub
|
|
286
276
|
|
|
287
277
|
|
|
288
278
|
def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
@@ -299,9 +289,145 @@ def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
|
299
289
|
return current, " · ".join(sub_bits) or "EPC register"
|
|
300
290
|
|
|
301
291
|
|
|
292
|
+
# ---------------- input assertions ----------------
|
|
293
|
+
|
|
294
|
+
class PrevalAbort(RuntimeError):
|
|
295
|
+
"""Raised when a load-bearing input is missing or unverifiable.
|
|
296
|
+
|
|
297
|
+
.cause is the short slug logged as `reason=<cause>` and shown in the
|
|
298
|
+
operator's remediation hint.
|
|
299
|
+
"""
|
|
300
|
+
def __init__(self, cause: str, hint: str):
|
|
301
|
+
super().__init__(f"{cause}: {hint}")
|
|
302
|
+
self.cause = cause
|
|
303
|
+
self.hint = hint
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
|
|
307
|
+
"""Existence + dimension check. Symlinks pointing into another property's
|
|
308
|
+
folder are rejected (cover-hero cross-contamination guard)."""
|
|
309
|
+
p = Path(path_str).expanduser()
|
|
310
|
+
if not p.exists():
|
|
311
|
+
raise PrevalAbort(
|
|
312
|
+
"subject-cover-hero-missing",
|
|
313
|
+
f"cover_hero path does not exist: {p}",
|
|
314
|
+
)
|
|
315
|
+
if p.is_symlink():
|
|
316
|
+
target = p.resolve()
|
|
317
|
+
parts = target.parts
|
|
318
|
+
if "properties" in parts:
|
|
319
|
+
# symlink → properties/<other-slug>/… is a cross-contamination
|
|
320
|
+
raise PrevalAbort(
|
|
321
|
+
"subject-cover-hero-symlinked-foreign",
|
|
322
|
+
f"cover_hero is a symlink into another property's folder: {target}",
|
|
323
|
+
)
|
|
324
|
+
# Dimensions. Avoid a Pillow dep; read PNG/JPEG headers directly.
|
|
325
|
+
w, h = _image_dimensions(p)
|
|
326
|
+
if w is None or h is None:
|
|
327
|
+
raise PrevalAbort(
|
|
328
|
+
"subject-cover-hero-unreadable",
|
|
329
|
+
f"cover_hero is not a readable PNG or JPEG: {p}",
|
|
330
|
+
)
|
|
331
|
+
if w < min_w or h < min_h:
|
|
332
|
+
raise PrevalAbort(
|
|
333
|
+
"subject-cover-hero-too-small",
|
|
334
|
+
f"cover_hero is {w}×{h}, below the {min_w}×{min_h} minimum: {p}",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
|
|
339
|
+
"""Return (width, height) for PNG or JPEG. None on any failure."""
|
|
340
|
+
try:
|
|
341
|
+
with p.open("rb") as f:
|
|
342
|
+
head = f.read(24)
|
|
343
|
+
if len(head) < 24:
|
|
344
|
+
return None, None
|
|
345
|
+
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
|
346
|
+
# IHDR width/height at bytes 16..24, big-endian uint32.
|
|
347
|
+
w = int.from_bytes(head[16:20], "big")
|
|
348
|
+
h = int.from_bytes(head[20:24], "big")
|
|
349
|
+
return w, h
|
|
350
|
+
if head[:2] == b"\xff\xd8":
|
|
351
|
+
f.seek(2)
|
|
352
|
+
while True:
|
|
353
|
+
b = f.read(1)
|
|
354
|
+
while b and b != b"\xff":
|
|
355
|
+
b = f.read(1)
|
|
356
|
+
marker = f.read(1)
|
|
357
|
+
if not marker:
|
|
358
|
+
return None, None
|
|
359
|
+
if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
|
|
360
|
+
f.read(3) # length(2) + precision(1)
|
|
361
|
+
h = int.from_bytes(f.read(2), "big")
|
|
362
|
+
w = int.from_bytes(f.read(2), "big")
|
|
363
|
+
return w, h
|
|
364
|
+
seg_len = int.from_bytes(f.read(2), "big")
|
|
365
|
+
f.seek(seg_len - 2, 1)
|
|
366
|
+
return None, None
|
|
367
|
+
except OSError:
|
|
368
|
+
return None, None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _assert_inputs(inputs: dict) -> None:
|
|
372
|
+
"""Fail loud on any missing or unverifiable load-bearing input.
|
|
373
|
+
|
|
374
|
+
Raises PrevalAbort with a named cause. No silent fallback paths.
|
|
375
|
+
"""
|
|
376
|
+
subject = inputs.get("subject") or {}
|
|
377
|
+
|
|
378
|
+
# subject.sqft / sqft_m2 — required, non-null integers.
|
|
379
|
+
if not subject.get("sqft"):
|
|
380
|
+
raise PrevalAbort(
|
|
381
|
+
"subject-sqft-missing",
|
|
382
|
+
"subject.sqft is null — run epc-key-register and re-derive, or paste subject.sqft as an operator override before invocation.",
|
|
383
|
+
)
|
|
384
|
+
if not subject.get("sqft_m2"):
|
|
385
|
+
raise PrevalAbort(
|
|
386
|
+
"subject-sqft-m2-missing",
|
|
387
|
+
"subject.sqft_m2 is null — the EPC m² figure must be supplied alongside sqft.",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# subject.cover_hero — required, exists, ≥ 1200×800, not symlinked into another property's folder.
|
|
391
|
+
hero = subject.get("cover_hero")
|
|
392
|
+
if not hero:
|
|
393
|
+
raise PrevalAbort(
|
|
394
|
+
"subject-cover-hero-missing",
|
|
395
|
+
"subject.cover_hero is required for the cover page.",
|
|
396
|
+
)
|
|
397
|
+
if not hero.startswith(("http://", "https://", "data:")):
|
|
398
|
+
_assert_path_image(hero)
|
|
399
|
+
|
|
400
|
+
# brand — must be the resolved DESIGN.md object, not a stub.
|
|
401
|
+
brand = inputs.get("brand") or {}
|
|
402
|
+
required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
|
|
403
|
+
missing_brand = [k for k in required_brand if not brand.get(k)]
|
|
404
|
+
if missing_brand:
|
|
405
|
+
raise PrevalAbort(
|
|
406
|
+
"brand-unresolved",
|
|
407
|
+
f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# agent_listings — non-empty.
|
|
411
|
+
if not inputs.get("agent_listings"):
|
|
412
|
+
raise PrevalAbort(
|
|
413
|
+
"agent-listings-empty",
|
|
414
|
+
"agent_listings is empty — paste at least one recent listing for the closing page.",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# market_summary.paragraphs — at least 2.
|
|
418
|
+
paras = (inputs.get("market_summary") or {}).get("paragraphs") or []
|
|
419
|
+
if len(paras) < 2:
|
|
420
|
+
raise PrevalAbort(
|
|
421
|
+
"market-summary-too-thin",
|
|
422
|
+
f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
302
426
|
# ---------------- render ----------------
|
|
303
427
|
|
|
304
428
|
def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
|
|
429
|
+
_assert_inputs(inputs)
|
|
430
|
+
|
|
305
431
|
template_path = template_path or (Path(__file__).parent / "template.html")
|
|
306
432
|
tmpl = template_path.read_text()
|
|
307
433
|
|
|
@@ -454,16 +580,48 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
|
454
580
|
return n
|
|
455
581
|
|
|
456
582
|
|
|
583
|
+
def _abort(cause: str, hint: str, *, address: str, postcode: str, t0: float, out_dir: Path | None) -> None:
|
|
584
|
+
"""Emit the abort log line, scrub any partial output, exit non-zero."""
|
|
585
|
+
ms = int((time.monotonic() - t0) * 1000)
|
|
586
|
+
print(
|
|
587
|
+
f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
|
|
588
|
+
file=sys.stderr,
|
|
589
|
+
)
|
|
590
|
+
print(hint, file=sys.stderr)
|
|
591
|
+
if out_dir is not None:
|
|
592
|
+
for name in ("index.html",):
|
|
593
|
+
p = out_dir / name
|
|
594
|
+
if p.exists():
|
|
595
|
+
p.unlink()
|
|
596
|
+
# Any PDF whose stem matches filename_stem would be a partial — caller passes the stem.
|
|
597
|
+
sys.exit(2)
|
|
598
|
+
|
|
599
|
+
|
|
457
600
|
def main():
|
|
601
|
+
t0 = time.monotonic()
|
|
458
602
|
if len(sys.argv) < 2:
|
|
459
603
|
print("Usage: render.py <inputs.json>", file=sys.stderr)
|
|
460
604
|
sys.exit(2)
|
|
461
605
|
inputs = json.loads(Path(sys.argv[1]).read_text())
|
|
462
606
|
out_dir = Path(inputs["out_dir"]).expanduser()
|
|
463
607
|
base = inputs["filename_stem"]
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
608
|
+
address = inputs.get("address", "")
|
|
609
|
+
postcode = inputs.get("postcode", "")
|
|
610
|
+
try:
|
|
611
|
+
render(inputs, out_dir)
|
|
612
|
+
pdf = render_pdf(out_dir, f"{base}.pdf")
|
|
613
|
+
pages = assert_page_count(pdf, 5)
|
|
614
|
+
except PrevalAbort as e:
|
|
615
|
+
# Scrub partial PDF too, if rendered before page-count failure.
|
|
616
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
617
|
+
if pdf_path.exists():
|
|
618
|
+
pdf_path.unlink()
|
|
619
|
+
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0, out_dir=out_dir)
|
|
620
|
+
except RuntimeError as e:
|
|
621
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
622
|
+
if pdf_path.exists():
|
|
623
|
+
pdf_path.unlink()
|
|
624
|
+
_abort("render-error", str(e), address=address, postcode=postcode, t0=t0, out_dir=out_dir)
|
|
467
625
|
print(f"HTML → {out_dir/'index.html'}")
|
|
468
626
|
print(f"PDF → {pdf} ({pages} pages)")
|
|
469
627
|
|
|
@@ -34,15 +34,15 @@
|
|
|
34
34
|
|
|
35
35
|
"subject": {
|
|
36
36
|
"type": "object",
|
|
37
|
-
"required": ["cover_hero"],
|
|
37
|
+
"required": ["cover_hero", "sqft", "sqft_m2"],
|
|
38
38
|
"properties": {
|
|
39
|
-
"cover_hero": { "type": "string", "description": "Filesystem path or URL to a photo of the subject property — required for the cover." },
|
|
39
|
+
"cover_hero": { "type": "string", "description": "Filesystem path or URL to a photo of the subject property — required for the cover. Renderer asserts the file exists, is ≥ 1200×800, and is not symlinked to another property's folder." },
|
|
40
40
|
"beds": { "type": ["integer", "null"] },
|
|
41
41
|
"type": { "type": ["string", "null"], "description": "e.g. 'Semi-detached', 'Terraced', 'Flat'." },
|
|
42
42
|
"tenure": { "type": ["string", "null"], "description": "e.g. 'Freehold', 'Leasehold'." },
|
|
43
43
|
"condition": { "type": ["string", "null"], "description": "'well' | 'average' | 'needs work'." },
|
|
44
|
-
"sqft": { "type":
|
|
45
|
-
"sqft_m2": { "type":
|
|
44
|
+
"sqft": { "type": "integer", "description": "Total internal area in square feet. Required. Pulled from EPC (m² × 10.7639, rounded) when available; operator paste-in is the only override. Null aborts the run." },
|
|
45
|
+
"sqft_m2": { "type": "number", "description": "Total internal area in square metres from the EPC register. Required alongside sqft." },
|
|
46
46
|
"epc_current": { "type": ["string", "null"], "description": "Current energy rating, e.g. 'C'." },
|
|
47
47
|
"epc_potential": { "type": ["string", "null"], "description": "Potential energy rating, e.g. 'B'." },
|
|
48
48
|
"epc_lodgement": { "type": ["string", "null"], "description": "ISO date of the EPC lodgement." },
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"required": ["headline", "paragraphs", "demand_gauge_position", "price_growth_note", "demand_note"],
|
|
159
159
|
"properties": {
|
|
160
160
|
"headline": { "type": "string", "description": "One-line display-italic h2 for the market context page." },
|
|
161
|
-
"paragraphs": { "type": "array", "items": { "type": "string" }, "description": "2–3 short paragraphs. First gets a drop cap." },
|
|
161
|
+
"paragraphs": { "type": "array", "minItems": 2, "items": { "type": "string" }, "description": "2–3 short paragraphs. First gets a drop cap. Fewer than 2 aborts the run." },
|
|
162
162
|
"demand_gauge_position": { "type": "string", "description": "Percentage string for the gauge marker position, e.g. '62%'." },
|
|
163
163
|
"price_growth_note": { "type": "string" },
|
|
164
164
|
"demand_note": { "type": "string" }
|
|
@@ -167,8 +167,9 @@
|
|
|
167
167
|
|
|
168
168
|
"agent_listings": {
|
|
169
169
|
"type": "array",
|
|
170
|
+
"minItems": 1,
|
|
170
171
|
"maxItems": 6,
|
|
171
|
-
"description": "Agent's own recent listings to feature on the closing page. Operator paste-in (not the PropertyData sample).",
|
|
172
|
+
"description": "Agent's own recent listings to feature on the closing page. Operator paste-in (not the PropertyData sample). Empty array aborts the run.",
|
|
172
173
|
"items": {
|
|
173
174
|
"type": "object",
|
|
174
175
|
"required": ["address", "price", "status", "slug"],
|
|
@@ -105,7 +105,6 @@
|
|
|
105
105
|
.val-final{margin-top:5mm;padding:5mm 6mm;background:var(--teal-900);color:var(--text-on-dark);border-radius:var(--radius-lg);display:flex;justify-content:space-between;align-items:baseline;gap:4mm}
|
|
106
106
|
.val-final .lbl{font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--gold-500)}
|
|
107
107
|
.val-final .num{font-family:var(--display);font-style:italic;font-weight:500;font-size:26pt;color:var(--text-on-dark);letter-spacing:-.4pt}
|
|
108
|
-
.val-empty{padding:8mm;text-align:center;font-family:var(--display);font-style:italic;font-size:14pt;color:var(--text-muted)}
|
|
109
108
|
|
|
110
109
|
/* --- TABLES --- */
|
|
111
110
|
table.cmp{width:100%;border-collapse:collapse;font-size:10pt;font-family:var(--body)}
|