@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -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. Falls back to "Estimate requires floor area" benchmark-only mode when sqft is missing.
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
- - **PropertyData key-not-registered** abort with the `property-data-key-register` prompt. Market data is essential; the pack cannot render without it.
36
- - **EPC unavailable** — any of `key-not-registered`, `http-404 (no record)`, `http-429`, `http-500`, network failure, or EPC MCP not configured — is treated as a single class. The skill prompts the operator with the specific cause and offers two paths:
37
- 1. Paste `subject.sqft` directly pack renders with EPC KPI cells showing "—" + cause sub-label, but the valuation maths still runs on the pasted sqft.
38
- 2. Declinevaluation panel switches to "Estimate requires floor area" benchmark-only mode; the pack still renders all five pages.
39
- - **PropertyData http-429** back off and retry only the throttled tool (4 calls per 10 seconds upstream throttle).
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`) failure modes above; not strictly blocking thanks to the paste-sqft fallback.
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. Without a floor area the renderer prints "Estimate requires floor area" and shows only the £/sqft benchmark never invents a number.
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. If `sqft` missing the panel switches to the "requires floor area" mode (£/sqft benchmark only). One paragraph of `subject.narrative` under the panel.
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. Failure handling differs by source:
79
- - **PropertyData `key-not-registered`** → abort with the `property-data-key-register` prompt. The pack cannot render without market data.
80
- - **PropertyData `http-429`** → back off, retry only the throttled tool. Do not re-run the whole fan-out.
81
- - **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-500`, network failure, or MCP not configured on the account — is treated as a single class: **prompt the operator** with the relevant cause (e.g. "EPC API key not registered — register with `epc-key-register`, or paste subject.sqft to continue"; "No EPC on file for this address — paste subject.sqft to continue"; "EPC API unavailable — paste subject.sqft to continue"). If the operator pastes `subject.sqft`, the pack renders normally with the EPC-derived KPI cells switching to "—" + "No EPC on file" / "EPC API unavailable" sub-labels. If the operator declines to paste, the valuation panel switches to the "Estimate requires floor area" benchmark-only mode and the pack still renders the other four pages.
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
- ## Failure signatures
103
+ ## Hard breakpoints
102
104
 
103
- - `pages=6` (or 5) in the `done` line template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>` to see which page bled, then trim that panel's padding only.
104
- - `start` with no matching `done` within 60s → renderer crashed. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
105
- - `[epc] status=http-404` property has no EPC; prompt "no EPC on file for this address — paste subject.sqft to continue".
106
- - `[epc] … status=key-not-registered` → prompt "EPC API key not registered — run `epc-key-register`, or paste subject.sqft to continue".
107
- - `[epc] status=http-429` | `http-5xx` | `bad-response` | `network-error` prompt "EPC API unavailable paste subject.sqft to continue". The MCP-not-installed case (no `[epc] …` line at all and the tool call surfaces as "tool not found") is the same prompt with cause "EPC MCP not configured on this account".
108
- - `[property-data] … status=http-429` 4-calls-in-10-seconds throttle. Retry only the throttled tool after the back-off; do not re-run the whole fan-out.
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
- Falls back to the 'requires floor area' state when subject.sqft is missing.
220
+ sqft is asserted non-null upstream by _assert_inputs no fallback panel here.
218
221
  """
219
- sqft = subject.get("sqft")
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.get("sqft")
272
+ sqft = subject["sqft"]
281
273
  m2 = subject.get("sqft_m2")
282
- if sqft:
283
- sub = f"{m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
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
- render(inputs, out_dir)
465
- pdf = render_pdf(out_dir, f"{base}.pdf")
466
- pages = assert_page_count(pdf, 5)
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": ["integer", "null"], "description": "Total internal area in square feet. Pulled from EPC (m² × 10.7639, rounded) when available; operator paste-in otherwise. If null, valuation panel switches to 'requires floor area' mode." },
45
- "sqft_m2": { "type": ["number", "null"], "description": "Total internal area in square metres from the EPC register." },
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. Falls back to "Estimate requires floor area" benchmark-only mode when sqft is missing.
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
- - **PropertyData key-not-registered** abort with the `property-data-key-register` prompt. Market data is essential; the pack cannot render without it.
36
- - **EPC unavailable** — any of `key-not-registered`, `http-404 (no record)`, `http-429`, `http-500`, network failure, or EPC MCP not configured — is treated as a single class. The skill prompts the operator with the specific cause and offers two paths:
37
- 1. Paste `subject.sqft` directly pack renders with EPC KPI cells showing "—" + cause sub-label, but the valuation maths still runs on the pasted sqft.
38
- 2. Declinevaluation panel switches to "Estimate requires floor area" benchmark-only mode; the pack still renders all five pages.
39
- - **PropertyData http-429** back off and retry only the throttled tool (4 calls per 10 seconds upstream throttle).
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`) failure modes above; not strictly blocking thanks to the paste-sqft fallback.
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. Without a floor area the renderer prints "Estimate requires floor area" and shows only the £/sqft benchmark never invents a number.
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. If `sqft` missing the panel switches to the "requires floor area" mode (£/sqft benchmark only). One paragraph of `subject.narrative` under the panel.
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. Failure handling differs by source:
79
- - **PropertyData `key-not-registered`** → abort with the `property-data-key-register` prompt. The pack cannot render without market data.
80
- - **PropertyData `http-429`** → back off, retry only the throttled tool. Do not re-run the whole fan-out.
81
- - **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-500`, network failure, or MCP not configured on the account — is treated as a single class: **prompt the operator** with the relevant cause (e.g. "EPC API key not registered — register with `epc-key-register`, or paste subject.sqft to continue"; "No EPC on file for this address — paste subject.sqft to continue"; "EPC API unavailable — paste subject.sqft to continue"). If the operator pastes `subject.sqft`, the pack renders normally with the EPC-derived KPI cells switching to "—" + "No EPC on file" / "EPC API unavailable" sub-labels. If the operator declines to paste, the valuation panel switches to the "Estimate requires floor area" benchmark-only mode and the pack still renders the other four pages.
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
- ## Failure signatures
103
+ ## Hard breakpoints
102
104
 
103
- - `pages=6` (or 5) in the `done` line template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>` to see which page bled, then trim that panel's padding only.
104
- - `start` with no matching `done` within 60s → renderer crashed. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
105
- - `[epc] status=http-404` property has no EPC; prompt "no EPC on file for this address — paste subject.sqft to continue".
106
- - `[epc] … status=key-not-registered` → prompt "EPC API key not registered — run `epc-key-register`, or paste subject.sqft to continue".
107
- - `[epc] status=http-429` | `http-5xx` | `bad-response` | `network-error` prompt "EPC API unavailable paste subject.sqft to continue". The MCP-not-installed case (no `[epc] …` line at all and the tool call surfaces as "tool not found") is the same prompt with cause "EPC MCP not configured on this account".
108
- - `[property-data] … status=http-429` 4-calls-in-10-seconds throttle. Retry only the throttled tool after the back-off; do not re-run the whole fan-out.
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
- Falls back to the 'requires floor area' state when subject.sqft is missing.
220
+ sqft is asserted non-null upstream by _assert_inputs no fallback panel here.
218
221
  """
219
- sqft = subject.get("sqft")
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.get("sqft")
272
+ sqft = subject["sqft"]
281
273
  m2 = subject.get("sqft_m2")
282
- if sqft:
283
- sub = f"{m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
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
- render(inputs, out_dir)
465
- pdf = render_pdf(out_dir, f"{base}.pdf")
466
- pages = assert_page_count(pdf, 5)
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": ["integer", "null"], "description": "Total internal area in square feet. Pulled from EPC (m² × 10.7639, rounded) when available; operator paste-in otherwise. If null, valuation panel switches to 'requires floor area' mode." },
45
- "sqft_m2": { "type": ["number", "null"], "description": "Total internal area in square metres from the EPC register." },
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)}