@rubytech/create-maxy-code 0.1.61 → 0.1.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/installer-specialist-registration.test.js +112 -0
- package/dist/index.js +22 -0
- package/dist/specialist-registration.js +78 -0
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +22 -0
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +50 -13
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +22 -21
- package/payload/platform/plugins/contacts/PLUGIN.md +9 -0
- package/payload/platform/plugins/docs/references/admin-session.md +55 -7
- package/payload/platform/plugins/email/PLUGIN.md +9 -0
- package/payload/platform/plugins/epc/PLUGIN.md +5 -0
- package/payload/platform/plugins/loop/PLUGIN.md +27 -0
- package/payload/platform/plugins/memory/PLUGIN.md +37 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +3 -3
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/live-schema-source.test.js +137 -75
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/live-schema-source.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.d.ts +0 -17
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.js +32 -4
- package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts +65 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js +182 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection.test.js +8 -8
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.js +241 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.js +9 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.d.ts +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.js +9 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.js +10 -2
- package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.d.ts +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.js +5 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.d.ts +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.js +5 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +5 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +5 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +6 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +4 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/vitest.config.ts +10 -3
- package/payload/platform/plugins/outlook/PLUGIN.md +7 -0
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +22 -12
- package/payload/platform/plugins/preval/hooks/preval-prompt-required.sh +140 -0
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +77 -19
- package/payload/platform/plugins/preval/skills/property-preval/references/__tests__/adherence.test.py +373 -0
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +390 -47
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +32 -21
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +0 -1
- package/payload/platform/plugins/property-data/PLUGIN.md +22 -0
- package/payload/platform/plugins/replicate/PLUGIN.md +3 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
- package/payload/platform/plugins/tasks/PLUGIN.md +14 -0
- package/payload/platform/plugins/telegram/PLUGIN.md +3 -0
- package/payload/platform/plugins/waitlist/PLUGIN.md +7 -0
- package/payload/platform/plugins/whatsapp/PLUGIN.md +12 -0
- package/payload/platform/plugins/workflows/PLUGIN.md +8 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +39 -1
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +148 -41
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +23 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +141 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +13 -2
- package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/tool-surface.js +135 -37
- package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
- package/payload/premium-plugins/real-agent/agents/compliance.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +5 -0
- package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +27 -0
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +22 -12
- package/payload/premium-plugins/real-agent/plugins/preval/hooks/preval-prompt-required.sh +140 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +77 -19
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/__tests__/adherence.test.py +373 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +390 -47
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +32 -21
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +0 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +22 -0
- package/payload/server/public/assets/{admin-DhN3G8W7.js → admin-ymReS1mN.js} +1 -1
- package/payload/server/public/assets/{data-B2ZVXOcE.js → data-D6U2e85B.js} +1 -1
- package/payload/server/public/assets/{graph-BAMGPHrK.js → graph-C274PN27.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D9eBbvxo.js → graph-labels-DQFzd1FY.js} +1 -1
- package/payload/server/public/assets/{page-DjdVMWCz.js → page-D-OLjort.js} +1 -1
- package/payload/server/public/assets/{page-CV27Al6Z.js → page-DFFt9_Q0.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +1 -1
|
@@ -4,11 +4,16 @@ 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. Task 157 layers provenance gates
|
|
9
|
+
(market_summary.origin, agent_listings[].origin, agent_overrides) on top
|
|
10
|
+
of 156's load-bearing-input checks.
|
|
11
|
+
|
|
7
12
|
Usage:
|
|
8
13
|
python3 render.py inputs.json
|
|
9
14
|
"""
|
|
10
15
|
from __future__ import annotations
|
|
11
|
-
import html, json, re, shutil, subprocess, sys
|
|
16
|
+
import html, json, re, shutil, subprocess, sys, time
|
|
12
17
|
from pathlib import Path
|
|
13
18
|
|
|
14
19
|
|
|
@@ -214,20 +219,11 @@ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
|
|
|
214
219
|
def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
215
220
|
"""Build the page-2 valuation panel: headline range, adjustments, final range.
|
|
216
221
|
|
|
217
|
-
|
|
222
|
+
The three EPC certificate fields are asserted non-null upstream by
|
|
223
|
+
_assert_inputs — no fallback panel here. sqft is derived from
|
|
224
|
+
total_floor_area_m2 via M2_TO_SQFT.
|
|
218
225
|
"""
|
|
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
|
-
|
|
226
|
+
sqft = _derived_sqft(subject)
|
|
231
227
|
low_psf = valuation["sold_psf_low"]
|
|
232
228
|
mid_psf = valuation["sold_psf"]
|
|
233
229
|
high_psf = valuation["sold_psf_high"]
|
|
@@ -276,32 +272,246 @@ def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
|
276
272
|
return '<div class="val-panel">' + "".join(rows) + "</div>" + final
|
|
277
273
|
|
|
278
274
|
|
|
275
|
+
def _derived_sqft(subject: dict) -> int:
|
|
276
|
+
return round(float(subject["total_floor_area_m2"]) * M2_TO_SQFT)
|
|
277
|
+
|
|
278
|
+
|
|
279
279
|
def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
|
|
280
|
-
sqft = subject
|
|
281
|
-
m2 = subject
|
|
282
|
-
|
|
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"
|
|
280
|
+
sqft = _derived_sqft(subject)
|
|
281
|
+
m2 = float(subject["total_floor_area_m2"])
|
|
282
|
+
return f"{sqft:,}", f"≈ {m2:.0f} m² · from EPC certificate"
|
|
286
283
|
|
|
287
284
|
|
|
288
285
|
def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
289
|
-
current = subject
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
286
|
+
current = subject["current_energy_rating"]
|
|
287
|
+
lodgement = subject["lodgement_date"]
|
|
288
|
+
return current, f"lodged {lodgement}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------- input assertions ----------------
|
|
292
|
+
|
|
293
|
+
class PrevalAbort(RuntimeError):
|
|
294
|
+
"""Raised when a load-bearing input is missing or unverifiable.
|
|
295
|
+
|
|
296
|
+
.cause is the short slug logged as `reason=<cause>` and shown in the
|
|
297
|
+
operator's remediation hint.
|
|
298
|
+
"""
|
|
299
|
+
def __init__(self, cause: str, hint: str):
|
|
300
|
+
super().__init__(f"{cause}: {hint}")
|
|
301
|
+
self.cause = cause
|
|
302
|
+
self.hint = hint
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
_STREET_TOKEN_STOPWORDS = {
|
|
306
|
+
"the", "a", "an", "of", "and",
|
|
307
|
+
"road", "street", "drive", "lane", "avenue", "close", "court",
|
|
308
|
+
"place", "way", "gardens", "crescent", "hill", "rise", "grove",
|
|
309
|
+
"terrace", "mews", "square", "park", "walk", "row",
|
|
310
|
+
"rd", "st", "dr", "ln", "ave", "cl", "ct", "pl",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _street_token(address: str) -> str | None:
|
|
315
|
+
"""First non-stopword token from the address's street component.
|
|
316
|
+
|
|
317
|
+
Used to detect cover_hero photos sourced from a sister property folder.
|
|
318
|
+
"9 Phoenix Drive, Bishop's Stortford, CM23 2UJ" → "phoenix".
|
|
319
|
+
"""
|
|
320
|
+
if not address:
|
|
321
|
+
return None
|
|
322
|
+
first = address.split(",")[0].strip().lower()
|
|
323
|
+
for raw in re.split(r"[^a-z]+", first):
|
|
324
|
+
if raw and not raw.isdigit() and raw not in _STREET_TOKEN_STOPWORDS:
|
|
325
|
+
return raw
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
|
|
330
|
+
"""Existence + dimension check. Symlinks pointing into another property's
|
|
331
|
+
folder are rejected (cover-hero cross-contamination guard)."""
|
|
332
|
+
p = Path(path_str).expanduser()
|
|
333
|
+
if not p.exists():
|
|
334
|
+
raise PrevalAbort(
|
|
335
|
+
"subject-cover-hero-missing",
|
|
336
|
+
f"cover_hero path does not exist: {p}",
|
|
337
|
+
)
|
|
338
|
+
if p.is_symlink():
|
|
339
|
+
target = p.resolve()
|
|
340
|
+
parts = target.parts
|
|
341
|
+
if "properties" in parts:
|
|
342
|
+
raise PrevalAbort(
|
|
343
|
+
"subject-cover-hero-symlinked-foreign",
|
|
344
|
+
f"cover_hero is a symlink into another property's folder: {target}",
|
|
345
|
+
)
|
|
346
|
+
w, h = _image_dimensions(p)
|
|
347
|
+
if w is None or h is None:
|
|
348
|
+
raise PrevalAbort(
|
|
349
|
+
"subject-cover-hero-unreadable",
|
|
350
|
+
f"cover_hero is not a readable PNG or JPEG: {p}",
|
|
351
|
+
)
|
|
352
|
+
if w < min_w or h < min_h:
|
|
353
|
+
raise PrevalAbort(
|
|
354
|
+
"subject-cover-hero-too-small",
|
|
355
|
+
f"cover_hero is {w}×{h}, below the {min_w}×{min_h} minimum: {p}",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
|
|
360
|
+
"""Return (width, height) for PNG or JPEG. None on any failure."""
|
|
361
|
+
try:
|
|
362
|
+
with p.open("rb") as f:
|
|
363
|
+
head = f.read(24)
|
|
364
|
+
if len(head) < 24:
|
|
365
|
+
return None, None
|
|
366
|
+
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
|
367
|
+
w = int.from_bytes(head[16:20], "big")
|
|
368
|
+
h = int.from_bytes(head[20:24], "big")
|
|
369
|
+
return w, h
|
|
370
|
+
if head[:2] == b"\xff\xd8":
|
|
371
|
+
f.seek(2)
|
|
372
|
+
while True:
|
|
373
|
+
b = f.read(1)
|
|
374
|
+
while b and b != b"\xff":
|
|
375
|
+
b = f.read(1)
|
|
376
|
+
marker = f.read(1)
|
|
377
|
+
if not marker:
|
|
378
|
+
return None, None
|
|
379
|
+
if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
|
|
380
|
+
f.read(3)
|
|
381
|
+
h = int.from_bytes(f.read(2), "big")
|
|
382
|
+
w = int.from_bytes(f.read(2), "big")
|
|
383
|
+
return w, h
|
|
384
|
+
seg_len = int.from_bytes(f.read(2), "big")
|
|
385
|
+
f.seek(seg_len - 2, 1)
|
|
386
|
+
return None, None
|
|
387
|
+
except OSError:
|
|
388
|
+
return None, None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _assert_inputs(inputs: dict) -> None:
|
|
392
|
+
"""Fail loud on any missing or unverifiable load-bearing input.
|
|
393
|
+
|
|
394
|
+
Task 156 causes (load-bearing inputs):
|
|
395
|
+
subject-cover-hero-*, brand-unresolved,
|
|
396
|
+
agent-listings-empty, market-summary-too-thin.
|
|
397
|
+
|
|
398
|
+
Task 157 causes (provenance — agent didn't invent the input):
|
|
399
|
+
cover-hero-not-subject, agent-listings-invented,
|
|
400
|
+
market-summary-invented, brand-invented.
|
|
401
|
+
|
|
402
|
+
Task 163 cause (EPC certificate paste-in completes 157's mandate):
|
|
403
|
+
epc-certificate-not-supplied — any of total_floor_area_m2,
|
|
404
|
+
current_energy_rating, lodgement_date is null/missing.
|
|
405
|
+
"""
|
|
406
|
+
subject = inputs.get("subject") or {}
|
|
407
|
+
address = inputs.get("address") or ""
|
|
408
|
+
overrides = inputs.get("agent_overrides") or {}
|
|
409
|
+
|
|
410
|
+
# --- 163: EPC certificate fields are operator paste-in ---
|
|
411
|
+
missing_epc = [
|
|
412
|
+
f for f in ("total_floor_area_m2", "current_energy_rating", "lodgement_date")
|
|
413
|
+
if not subject.get(f)
|
|
414
|
+
]
|
|
415
|
+
if missing_epc:
|
|
416
|
+
raise PrevalAbort(
|
|
417
|
+
"epc-certificate-not-supplied",
|
|
418
|
+
f"subject is missing EPC certificate field(s) {missing_epc}. "
|
|
419
|
+
"Paste total_floor_area_m2, current_energy_rating, and lodgement_date "
|
|
420
|
+
"from the EPC certificate into subject before re-invoking. "
|
|
421
|
+
"No mid-run prompt — the operator supplies the certificate before invocation.",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
hero = subject.get("cover_hero")
|
|
425
|
+
if not hero:
|
|
426
|
+
raise PrevalAbort(
|
|
427
|
+
"subject-cover-hero-missing",
|
|
428
|
+
"subject.cover_hero is required for the cover page.",
|
|
429
|
+
)
|
|
430
|
+
if not hero.startswith(("http://", "https://", "data:")):
|
|
431
|
+
_assert_path_image(hero)
|
|
432
|
+
|
|
433
|
+
brand = inputs.get("brand") or {}
|
|
434
|
+
required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
|
|
435
|
+
missing_brand = [k for k in required_brand if not brand.get(k)]
|
|
436
|
+
if missing_brand:
|
|
437
|
+
raise PrevalAbort(
|
|
438
|
+
"brand-unresolved",
|
|
439
|
+
f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if not inputs.get("agent_listings"):
|
|
443
|
+
raise PrevalAbort(
|
|
444
|
+
"agent-listings-empty",
|
|
445
|
+
"agent_listings is empty — paste at least one recent listing for the closing page.",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
ms = inputs.get("market_summary") or {}
|
|
449
|
+
paras = ms.get("paragraphs") or []
|
|
450
|
+
if len(paras) < 2:
|
|
451
|
+
raise PrevalAbort(
|
|
452
|
+
"market-summary-too-thin",
|
|
453
|
+
f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# --- 157: provenance gates ---
|
|
457
|
+
if hero and not hero.startswith(("http://", "https://", "data:")):
|
|
458
|
+
hero_lc = hero.lower()
|
|
459
|
+
match = re.search(r"/properties?/([^/]+)/", hero_lc)
|
|
460
|
+
if match:
|
|
461
|
+
slug = match.group(1)
|
|
462
|
+
street = _street_token(address)
|
|
463
|
+
if street and street not in slug:
|
|
464
|
+
raise PrevalAbort(
|
|
465
|
+
"cover-hero-not-subject",
|
|
466
|
+
f"subject.cover_hero ({hero}) sits in a property folder whose "
|
|
467
|
+
f"slug ({slug}) does not contain the subject street token "
|
|
468
|
+
f"({street}). Prompt the operator for the subject's own photo.",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if not overrides.get("listings_status_curated"):
|
|
472
|
+
for listing in inputs.get("agent_listings") or []:
|
|
473
|
+
origin = (listing or {}).get("origin")
|
|
474
|
+
if not origin or origin == "operator":
|
|
475
|
+
continue
|
|
476
|
+
src = Path(str(origin)).expanduser()
|
|
477
|
+
if not src.exists():
|
|
478
|
+
continue
|
|
479
|
+
try:
|
|
480
|
+
source = json.loads(src.read_text())
|
|
481
|
+
except (OSError, json.JSONDecodeError):
|
|
482
|
+
continue
|
|
483
|
+
if source.get("status") in (None, "") and listing.get("status"):
|
|
484
|
+
raise PrevalAbort(
|
|
485
|
+
"agent-listings-invented",
|
|
486
|
+
f"agent_listings entry {listing.get('slug')} has status "
|
|
487
|
+
f"'{listing['status']}' but source {src} carries status:null. "
|
|
488
|
+
"Prompt the operator to confirm each status, then set "
|
|
489
|
+
"agent_overrides.listings_status_curated: true.",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if ms.get("origin") not in ("operator", "operator-edited-llm-stub"):
|
|
493
|
+
raise PrevalAbort(
|
|
494
|
+
"market-summary-invented",
|
|
495
|
+
"market_summary.origin must be 'operator' or 'operator-edited-llm-stub'. "
|
|
496
|
+
"Have the operator draft or accept the paragraphs before rendering — "
|
|
497
|
+
"the agent never invents the market narrative unprompted.",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if brand.get("name") and not overrides.get("brand_selected_by_operator"):
|
|
501
|
+
raise PrevalAbort(
|
|
502
|
+
"brand-invented",
|
|
503
|
+
f"brand.name ('{brand['name']}') was set without "
|
|
504
|
+
"agent_overrides.brand_selected_by_operator: true. Ask the operator "
|
|
505
|
+
"which estate agent is pitching for this instruction; the presence "
|
|
506
|
+
"of a DESIGN.md on disk does not authorise selection.",
|
|
507
|
+
)
|
|
300
508
|
|
|
301
509
|
|
|
302
510
|
# ---------------- render ----------------
|
|
303
511
|
|
|
304
512
|
def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
|
|
513
|
+
_assert_inputs(inputs)
|
|
514
|
+
|
|
305
515
|
template_path = template_path or (Path(__file__).parent / "template.html")
|
|
306
516
|
tmpl = template_path.read_text()
|
|
307
517
|
|
|
@@ -326,10 +536,9 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
326
536
|
|
|
327
537
|
psf = d.get("sales_per_month")
|
|
328
538
|
if psf is None:
|
|
329
|
-
psf = max(1, round(d["for_sale"] / 7))
|
|
539
|
+
psf = max(1, round(d["for_sale"] / 7))
|
|
330
540
|
|
|
331
541
|
slots = {
|
|
332
|
-
# branding
|
|
333
542
|
"brand_name": brand["name"],
|
|
334
543
|
"brand_tagline": brand["tagline"],
|
|
335
544
|
"brand_primary": brand["primary"],
|
|
@@ -341,14 +550,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
341
550
|
"wordmark_light": _wordmark(brand, "light", out_dir),
|
|
342
551
|
"wordmark_dark": _wordmark(brand, "dark", out_dir),
|
|
343
552
|
|
|
344
|
-
# cover + header
|
|
345
553
|
"subject_hero_path": _hero_asset(subject, out_dir),
|
|
346
554
|
"address": inputs["address"],
|
|
347
555
|
"address_short": _short_address(inputs["address"]),
|
|
348
556
|
"postcode": inputs["postcode"],
|
|
349
557
|
"generated": inputs["generated"],
|
|
350
558
|
|
|
351
|
-
# page 2 — subject valuation
|
|
352
559
|
"kpi_subject_sqft": subject_sqft,
|
|
353
560
|
"kpi_subject_sqft_sub": subject_sqft_sub,
|
|
354
561
|
"kpi_subject_beds": str(subject.get("beds") or "—"),
|
|
@@ -359,7 +566,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
359
566
|
"valuation_block": _valuation_block(subject, v),
|
|
360
567
|
"subject_narrative": subject.get("narrative") or "",
|
|
361
568
|
|
|
362
|
-
# page 3 — market context
|
|
363
569
|
"summary_headline": ms["headline"],
|
|
364
570
|
"summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
|
|
365
571
|
"kpi_avg_asking": _money(v["avg_asking"]),
|
|
@@ -372,15 +578,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
372
578
|
"demand_note": ms["demand_note"],
|
|
373
579
|
"spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
|
|
374
580
|
|
|
375
|
-
# page 4 — comparables
|
|
376
581
|
"sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
|
|
377
582
|
"asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
|
|
378
583
|
|
|
379
|
-
# page 5 — agent listings + sources
|
|
380
584
|
"agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
|
|
381
585
|
}
|
|
382
586
|
|
|
383
|
-
# Escape only the plain-text slots; HTML fragments pass through untouched.
|
|
384
587
|
text_slots = {
|
|
385
588
|
"address", "address_short", "postcode", "generated",
|
|
386
589
|
"brand_name", "brand_tagline",
|
|
@@ -427,11 +630,6 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
|
|
|
427
630
|
|
|
428
631
|
|
|
429
632
|
def _count_pages(pdf_path: Path) -> int:
|
|
430
|
-
"""pdfinfo first (handles /ObjStm-compressed page-object streams), with a
|
|
431
|
-
raw /Type /Page byte scan as fallback. The fallback is correct for current
|
|
432
|
-
Chrome --print-to-pdf output but would undercount if Chrome ever switches
|
|
433
|
-
to compressed page-object streams — hence pdfinfo first when present.
|
|
434
|
-
"""
|
|
435
633
|
pdfinfo = shutil.which("pdfinfo")
|
|
436
634
|
if pdfinfo:
|
|
437
635
|
result = subprocess.run(
|
|
@@ -454,16 +652,161 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
|
454
652
|
return n
|
|
455
653
|
|
|
456
654
|
|
|
655
|
+
# The 12 PropertyData fan-out tools the preval skill is contracted to call.
|
|
656
|
+
# Each tool the MCP server runs emits a `[property-data] tool=<name> ...` line
|
|
657
|
+
# (see propertydata-api.ts:92). The watchdog asserts every one of these names
|
|
658
|
+
# appears in the log within the 60s budget after `[preval] start`; a missing
|
|
659
|
+
# line means the fan-out silently dropped a call and the agent was about to
|
|
660
|
+
# render against an incomplete payload.
|
|
661
|
+
EXPECTED_PROPERTY_DATA_TOOLS = (
|
|
662
|
+
"property-data-prices",
|
|
663
|
+
"property-data-prices-per-sqf",
|
|
664
|
+
"property-data-sold-prices",
|
|
665
|
+
"property-data-sold-prices-per-sqf",
|
|
666
|
+
"property-data-growth",
|
|
667
|
+
"property-data-growth-psf",
|
|
668
|
+
"property-data-demand",
|
|
669
|
+
"property-data-demand-rent",
|
|
670
|
+
"property-data-property-types",
|
|
671
|
+
"property-data-crime",
|
|
672
|
+
"property-data-flood-risk",
|
|
673
|
+
"property-data-council-tax",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _missing_property_data_tools(log_text: str) -> list[str]:
|
|
678
|
+
"""Return EXPECTED_PROPERTY_DATA_TOOLS that have no `[property-data] tool=<name>` line in log_text."""
|
|
679
|
+
return [
|
|
680
|
+
name for name in EXPECTED_PROPERTY_DATA_TOOLS
|
|
681
|
+
if f"[property-data] tool={name} " not in log_text
|
|
682
|
+
and not log_text.endswith(f"[property-data] tool={name}")
|
|
683
|
+
and f"[property-data] tool={name}\n" not in log_text
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _assert_observability_logs(log_path: Path, *, deadline_s: float = 60.0,
|
|
688
|
+
poll_s: float = 2.0, _now=time.monotonic,
|
|
689
|
+
_sleep=time.sleep) -> None:
|
|
690
|
+
"""Poll the log until every PropertyData fan-out tool emitted its line.
|
|
691
|
+
|
|
692
|
+
Aborts with cause `observability-missing tool=<name>` once deadline_s
|
|
693
|
+
elapses if any expected name is still missing. `_now` and `_sleep` are
|
|
694
|
+
injectable for tests so the 60s budget can be exercised without
|
|
695
|
+
wall-clock waits.
|
|
696
|
+
"""
|
|
697
|
+
deadline = _now() + deadline_s
|
|
698
|
+
while True:
|
|
699
|
+
try:
|
|
700
|
+
text = log_path.read_text()
|
|
701
|
+
except OSError:
|
|
702
|
+
text = ""
|
|
703
|
+
missing = _missing_property_data_tools(text)
|
|
704
|
+
if not missing:
|
|
705
|
+
return
|
|
706
|
+
if _now() >= deadline:
|
|
707
|
+
raise PrevalAbort(
|
|
708
|
+
f"observability-missing tool={missing[0]}",
|
|
709
|
+
f"PropertyData tool {missing[0]} did not emit "
|
|
710
|
+
f"`[property-data] tool={missing[0]} ...` within {int(deadline_s)}s "
|
|
711
|
+
"of [preval] start. The fan-out silently dropped one or more calls; "
|
|
712
|
+
"re-invoke and confirm every PropertyData tool returned before render. "
|
|
713
|
+
f"Other missing tool(s): {missing[1:] or 'none'}.",
|
|
714
|
+
)
|
|
715
|
+
_sleep(poll_s)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _pick_log_path(out_dir: Path) -> Path:
|
|
719
|
+
"""~/.maxy/logs/server.log if writable, else <out_dir>/preval.log.
|
|
720
|
+
|
|
721
|
+
Aborts with `log-destination-unavailable` if neither is writable —
|
|
722
|
+
silent runs are a contract violation.
|
|
723
|
+
"""
|
|
724
|
+
primary = Path("~/.maxy/logs/server.log").expanduser()
|
|
725
|
+
for candidate in (primary, out_dir / "preval.log"):
|
|
726
|
+
try:
|
|
727
|
+
candidate.parent.mkdir(parents=True, exist_ok=True)
|
|
728
|
+
with candidate.open("a"):
|
|
729
|
+
pass
|
|
730
|
+
return candidate
|
|
731
|
+
except OSError:
|
|
732
|
+
continue
|
|
733
|
+
raise PrevalAbort(
|
|
734
|
+
"log-destination-unavailable",
|
|
735
|
+
"Neither ~/.maxy/logs/server.log nor <out_dir>/preval.log is writable. "
|
|
736
|
+
"Fix filesystem permissions before re-running.",
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _log_line(log_path: Path | None, line: str) -> None:
|
|
741
|
+
if log_path is not None:
|
|
742
|
+
try:
|
|
743
|
+
with log_path.open("a") as f:
|
|
744
|
+
f.write(line + "\n")
|
|
745
|
+
except OSError:
|
|
746
|
+
pass
|
|
747
|
+
print(line, file=sys.stderr)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _abort(cause: str, hint: str, *, address: str, postcode: str, t0: float,
|
|
751
|
+
out_dir: Path | None, log_path: Path | None) -> None:
|
|
752
|
+
ms = int((time.monotonic() - t0) * 1000)
|
|
753
|
+
_log_line(
|
|
754
|
+
log_path,
|
|
755
|
+
f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
|
|
756
|
+
)
|
|
757
|
+
print(hint, file=sys.stderr)
|
|
758
|
+
if out_dir is not None:
|
|
759
|
+
p = out_dir / "index.html"
|
|
760
|
+
if p.exists():
|
|
761
|
+
p.unlink()
|
|
762
|
+
sys.exit(2)
|
|
763
|
+
|
|
764
|
+
|
|
457
765
|
def main():
|
|
766
|
+
t0 = time.monotonic()
|
|
458
767
|
if len(sys.argv) < 2:
|
|
459
768
|
print("Usage: render.py <inputs.json>", file=sys.stderr)
|
|
460
769
|
sys.exit(2)
|
|
461
770
|
inputs = json.loads(Path(sys.argv[1]).read_text())
|
|
462
771
|
out_dir = Path(inputs["out_dir"]).expanduser()
|
|
772
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
463
773
|
base = inputs["filename_stem"]
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
774
|
+
address = inputs.get("address", "")
|
|
775
|
+
postcode = inputs.get("postcode", "")
|
|
776
|
+
|
|
777
|
+
try:
|
|
778
|
+
log_path = _pick_log_path(out_dir)
|
|
779
|
+
except PrevalAbort as e:
|
|
780
|
+
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
|
|
781
|
+
out_dir=out_dir, log_path=None)
|
|
782
|
+
|
|
783
|
+
_log_line(log_path, f'[preval] start address="{address}" postcode={postcode}')
|
|
784
|
+
|
|
785
|
+
try:
|
|
786
|
+
_assert_observability_logs(log_path)
|
|
787
|
+
except PrevalAbort as e:
|
|
788
|
+
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
|
|
789
|
+
out_dir=out_dir, log_path=log_path)
|
|
790
|
+
|
|
791
|
+
try:
|
|
792
|
+
render(inputs, out_dir)
|
|
793
|
+
pdf = render_pdf(out_dir, f"{base}.pdf")
|
|
794
|
+
pages = assert_page_count(pdf, 5)
|
|
795
|
+
except PrevalAbort as e:
|
|
796
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
797
|
+
if pdf_path.exists():
|
|
798
|
+
pdf_path.unlink()
|
|
799
|
+
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
|
|
800
|
+
out_dir=out_dir, log_path=log_path)
|
|
801
|
+
except RuntimeError as e:
|
|
802
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
803
|
+
if pdf_path.exists():
|
|
804
|
+
pdf_path.unlink()
|
|
805
|
+
_abort("render-error", str(e), address=address, postcode=postcode, t0=t0,
|
|
806
|
+
out_dir=out_dir, log_path=log_path)
|
|
807
|
+
|
|
808
|
+
ms = int((time.monotonic() - t0) * 1000)
|
|
809
|
+
_log_line(log_path, f"[preval] done pdf={pdf} pages={pages} ms={ms}")
|
|
467
810
|
print(f"HTML → {out_dir/'index.html'}")
|
|
468
811
|
print(f"PDF → {pdf} ({pages} pages)")
|
|
469
812
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"title": "property-preval inputs",
|
|
4
|
-
"description": "Single-source contract for render.py. Mirrors the 12 property-data MCP responses
|
|
4
|
+
"description": "Single-source contract for render.py. Mirrors the 12 property-data MCP responses for one full UK address, plus operator-supplied brand, subject (including the three EPC certificate fields pasted before invocation), market_summary, and agent_listings.",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"required": [
|
|
7
7
|
"out_dir", "filename_stem", "address", "postcode", "generated",
|
|
8
8
|
"brand", "subject", "valuation", "area", "demand_trend",
|
|
9
|
-
"market_summary", "agent_listings"
|
|
9
|
+
"market_summary", "agent_listings", "agent_overrides"
|
|
10
10
|
],
|
|
11
11
|
"properties": {
|
|
12
12
|
"out_dir": { "type": "string", "description": "Absolute path to the output directory (created if missing)." },
|
|
@@ -34,19 +34,17 @@
|
|
|
34
34
|
|
|
35
35
|
"subject": {
|
|
36
36
|
"type": "object",
|
|
37
|
-
"required": ["cover_hero"],
|
|
37
|
+
"required": ["cover_hero", "total_floor_area_m2", "current_energy_rating", "lodgement_date"],
|
|
38
38
|
"properties": {
|
|
39
|
-
"cover_hero":
|
|
40
|
-
"beds":
|
|
41
|
-
"type":
|
|
42
|
-
"tenure":
|
|
43
|
-
"condition":
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"epc_lodgement": { "type": ["string", "null"], "description": "ISO date of the EPC lodgement." },
|
|
49
|
-
"narrative": { "type": ["string", "null"], "description": "One short paragraph the operator writes about the subject — shown under the valuation panel." },
|
|
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
|
+
"beds": { "type": ["integer", "null"] },
|
|
41
|
+
"type": { "type": ["string", "null"], "description": "e.g. 'Semi-detached', 'Terraced', 'Flat'." },
|
|
42
|
+
"tenure": { "type": ["string", "null"], "description": "e.g. 'Freehold', 'Leasehold'." },
|
|
43
|
+
"condition": { "type": ["string", "null"], "description": "'well' | 'average' | 'needs work'." },
|
|
44
|
+
"total_floor_area_m2": { "type": "number", "description": "Total internal area in square metres, copied verbatim from the EPC certificate. Operator pastes this into inputs.json before invocation. Null aborts with epc-certificate-not-supplied." },
|
|
45
|
+
"current_energy_rating": { "type": "string", "description": "Current energy rating from the EPC certificate, e.g. 'C'. Operator paste-in. Null aborts with epc-certificate-not-supplied." },
|
|
46
|
+
"lodgement_date": { "type": "string", "description": "ISO date of the EPC certificate's lodgement, e.g. '2018-04-21'. Operator paste-in. Null aborts with epc-certificate-not-supplied." },
|
|
47
|
+
"narrative": { "type": ["string", "null"], "description": "One short paragraph the operator writes about the subject — shown under the valuation panel." },
|
|
50
48
|
"adjustments": {
|
|
51
49
|
"type": "array",
|
|
52
50
|
"description": "Operator-curated esoteric factors that shift the headline range. Deltas sum linearly into the final factor (1 + Σ delta_pct/100).",
|
|
@@ -155,23 +153,25 @@
|
|
|
155
153
|
|
|
156
154
|
"market_summary": {
|
|
157
155
|
"type": "object",
|
|
158
|
-
"required": ["headline", "paragraphs", "demand_gauge_position", "price_growth_note", "demand_note"],
|
|
156
|
+
"required": ["headline", "paragraphs", "demand_gauge_position", "price_growth_note", "demand_note", "origin"],
|
|
159
157
|
"properties": {
|
|
160
158
|
"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." },
|
|
159
|
+
"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
160
|
"demand_gauge_position": { "type": "string", "description": "Percentage string for the gauge marker position, e.g. '62%'." },
|
|
163
161
|
"price_growth_note": { "type": "string" },
|
|
164
|
-
"demand_note": { "type": "string" }
|
|
162
|
+
"demand_note": { "type": "string" },
|
|
163
|
+
"origin": { "type": "string", "enum": ["operator", "operator-edited-llm-stub"], "description": "Provenance of the paragraphs. 'agent-invented' is never permitted — the gate aborts with `market-summary-invented`." }
|
|
165
164
|
}
|
|
166
165
|
},
|
|
167
166
|
|
|
168
167
|
"agent_listings": {
|
|
169
168
|
"type": "array",
|
|
169
|
+
"minItems": 1,
|
|
170
170
|
"maxItems": 6,
|
|
171
|
-
"description": "Agent's own recent listings to feature on the closing page. Operator paste-in (not the PropertyData sample).",
|
|
171
|
+
"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
172
|
"items": {
|
|
173
173
|
"type": "object",
|
|
174
|
-
"required": ["address", "price", "status", "slug"],
|
|
174
|
+
"required": ["address", "price", "status", "slug", "origin"],
|
|
175
175
|
"properties": {
|
|
176
176
|
"url": { "type": ["string", "null"], "description": "Source URL — listing card becomes an <a> when present." },
|
|
177
177
|
"address": { "type": "string" },
|
|
@@ -179,10 +179,21 @@
|
|
|
179
179
|
"beds": { "type": ["integer", "null"] },
|
|
180
180
|
"type": { "type": ["string", "null"] },
|
|
181
181
|
"image": { "type": ["string", "null"], "description": "Path or URL to a tile photo for the card." },
|
|
182
|
-
"status": { "type": "string", "
|
|
183
|
-
"slug": { "type": "string", "description": "Used to name the copied image file under img/." }
|
|
182
|
+
"status": { "type": "string", "enum": ["Sold", "Under offer", "For sale"], "description": "Operator picks; no default. The gate aborts if status was set when the source property.json had status:null without agent_overrides.listings_status_curated." },
|
|
183
|
+
"slug": { "type": "string", "description": "Used to name the copied image file under img/." },
|
|
184
|
+
"origin": { "type": "string", "description": "'operator' when the operator typed the row by hand, or the filesystem path of the source property.json the row was copied from." }
|
|
184
185
|
}
|
|
185
186
|
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
"agent_overrides": {
|
|
190
|
+
"type": "object",
|
|
191
|
+
"description": "Explicit operator confirmations that authorise bypass of adherence gates. Each override is a named boolean that lands next to the disputed value in inputs.json — auditable post-hoc.",
|
|
192
|
+
"required": ["brand_selected_by_operator", "listings_status_curated"],
|
|
193
|
+
"properties": {
|
|
194
|
+
"brand_selected_by_operator": { "type": "boolean", "description": "True only when the operator told the agent which estate agent is pitching. Inferring brand from on-disk DESIGN.md files is invention." },
|
|
195
|
+
"listings_status_curated": { "type": "boolean", "description": "True only when the operator explicitly signed off on each agent_listings[].status value." }
|
|
196
|
+
}
|
|
186
197
|
}
|
|
187
198
|
}
|
|
188
199
|
}
|
|
@@ -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)}
|