@rubytech/create-maxy-code 0.1.62 → 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 +16 -8
- package/payload/platform/plugins/preval/hooks/preval-prompt-required.sh +140 -0
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +44 -14
- 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 +247 -62
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +29 -19
- 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 +16 -8
- 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 +44 -14
- 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 +247 -62
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +29 -19
- 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
|
@@ -5,7 +5,9 @@ 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
7
|
Fails loud on any missing / unverifiable load-bearing input. No silent
|
|
8
|
-
fallback modes — see _assert_inputs.
|
|
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.
|
|
9
11
|
|
|
10
12
|
Usage:
|
|
11
13
|
python3 render.py inputs.json
|
|
@@ -217,9 +219,11 @@ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
|
|
|
217
219
|
def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
218
220
|
"""Build the page-2 valuation panel: headline range, adjustments, final range.
|
|
219
221
|
|
|
220
|
-
|
|
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.
|
|
221
225
|
"""
|
|
222
|
-
sqft = subject
|
|
226
|
+
sqft = _derived_sqft(subject)
|
|
223
227
|
low_psf = valuation["sold_psf_low"]
|
|
224
228
|
mid_psf = valuation["sold_psf"]
|
|
225
229
|
high_psf = valuation["sold_psf_high"]
|
|
@@ -268,25 +272,20 @@ def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
|
268
272
|
return '<div class="val-panel">' + "".join(rows) + "</div>" + final
|
|
269
273
|
|
|
270
274
|
|
|
275
|
+
def _derived_sqft(subject: dict) -> int:
|
|
276
|
+
return round(float(subject["total_floor_area_m2"]) * M2_TO_SQFT)
|
|
277
|
+
|
|
278
|
+
|
|
271
279
|
def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
|
|
272
|
-
sqft = subject
|
|
273
|
-
m2 = subject
|
|
274
|
-
|
|
275
|
-
return f"{int(sqft):,}", sub
|
|
280
|
+
sqft = _derived_sqft(subject)
|
|
281
|
+
m2 = float(subject["total_floor_area_m2"])
|
|
282
|
+
return f"{sqft:,}", f"≈ {m2:.0f} m² · from EPC certificate"
|
|
276
283
|
|
|
277
284
|
|
|
278
285
|
def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
279
|
-
current = subject
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if not current:
|
|
283
|
-
return "—", "No EPC on file"
|
|
284
|
-
sub_bits = []
|
|
285
|
-
if potential:
|
|
286
|
-
sub_bits.append(f"potential {potential}")
|
|
287
|
-
if lodgement:
|
|
288
|
-
sub_bits.append(f"lodged {lodgement}")
|
|
289
|
-
return current, " · ".join(sub_bits) or "EPC register"
|
|
286
|
+
current = subject["current_energy_rating"]
|
|
287
|
+
lodgement = subject["lodgement_date"]
|
|
288
|
+
return current, f"lodged {lodgement}"
|
|
290
289
|
|
|
291
290
|
|
|
292
291
|
# ---------------- input assertions ----------------
|
|
@@ -303,6 +302,30 @@ class PrevalAbort(RuntimeError):
|
|
|
303
302
|
self.hint = hint
|
|
304
303
|
|
|
305
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
|
+
|
|
306
329
|
def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
|
|
307
330
|
"""Existence + dimension check. Symlinks pointing into another property's
|
|
308
331
|
folder are rejected (cover-hero cross-contamination guard)."""
|
|
@@ -316,12 +339,10 @@ def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> No
|
|
|
316
339
|
target = p.resolve()
|
|
317
340
|
parts = target.parts
|
|
318
341
|
if "properties" in parts:
|
|
319
|
-
# symlink → properties/<other-slug>/… is a cross-contamination
|
|
320
342
|
raise PrevalAbort(
|
|
321
343
|
"subject-cover-hero-symlinked-foreign",
|
|
322
344
|
f"cover_hero is a symlink into another property's folder: {target}",
|
|
323
345
|
)
|
|
324
|
-
# Dimensions. Avoid a Pillow dep; read PNG/JPEG headers directly.
|
|
325
346
|
w, h = _image_dimensions(p)
|
|
326
347
|
if w is None or h is None:
|
|
327
348
|
raise PrevalAbort(
|
|
@@ -343,7 +364,6 @@ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
|
|
|
343
364
|
if len(head) < 24:
|
|
344
365
|
return None, None
|
|
345
366
|
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
|
346
|
-
# IHDR width/height at bytes 16..24, big-endian uint32.
|
|
347
367
|
w = int.from_bytes(head[16:20], "big")
|
|
348
368
|
h = int.from_bytes(head[20:24], "big")
|
|
349
369
|
return w, h
|
|
@@ -357,7 +377,7 @@ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
|
|
|
357
377
|
if not marker:
|
|
358
378
|
return None, None
|
|
359
379
|
if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
|
|
360
|
-
f.read(3)
|
|
380
|
+
f.read(3)
|
|
361
381
|
h = int.from_bytes(f.read(2), "big")
|
|
362
382
|
w = int.from_bytes(f.read(2), "big")
|
|
363
383
|
return w, h
|
|
@@ -371,23 +391,36 @@ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
|
|
|
371
391
|
def _assert_inputs(inputs: dict) -> None:
|
|
372
392
|
"""Fail loud on any missing or unverifiable load-bearing input.
|
|
373
393
|
|
|
374
|
-
|
|
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.
|
|
375
405
|
"""
|
|
376
406
|
subject = inputs.get("subject") or {}
|
|
407
|
+
address = inputs.get("address") or ""
|
|
408
|
+
overrides = inputs.get("agent_overrides") or {}
|
|
377
409
|
|
|
378
|
-
#
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if not subject.get("sqft_m2"):
|
|
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:
|
|
385
416
|
raise PrevalAbort(
|
|
386
|
-
"
|
|
387
|
-
"subject
|
|
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.",
|
|
388
422
|
)
|
|
389
423
|
|
|
390
|
-
# subject.cover_hero — required, exists, ≥ 1200×800, not symlinked into another property's folder.
|
|
391
424
|
hero = subject.get("cover_hero")
|
|
392
425
|
if not hero:
|
|
393
426
|
raise PrevalAbort(
|
|
@@ -397,7 +430,6 @@ def _assert_inputs(inputs: dict) -> None:
|
|
|
397
430
|
if not hero.startswith(("http://", "https://", "data:")):
|
|
398
431
|
_assert_path_image(hero)
|
|
399
432
|
|
|
400
|
-
# brand — must be the resolved DESIGN.md object, not a stub.
|
|
401
433
|
brand = inputs.get("brand") or {}
|
|
402
434
|
required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
|
|
403
435
|
missing_brand = [k for k in required_brand if not brand.get(k)]
|
|
@@ -407,21 +439,73 @@ def _assert_inputs(inputs: dict) -> None:
|
|
|
407
439
|
f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
|
|
408
440
|
)
|
|
409
441
|
|
|
410
|
-
# agent_listings — non-empty.
|
|
411
442
|
if not inputs.get("agent_listings"):
|
|
412
443
|
raise PrevalAbort(
|
|
413
444
|
"agent-listings-empty",
|
|
414
445
|
"agent_listings is empty — paste at least one recent listing for the closing page.",
|
|
415
446
|
)
|
|
416
447
|
|
|
417
|
-
|
|
418
|
-
paras =
|
|
448
|
+
ms = inputs.get("market_summary") or {}
|
|
449
|
+
paras = ms.get("paragraphs") or []
|
|
419
450
|
if len(paras) < 2:
|
|
420
451
|
raise PrevalAbort(
|
|
421
452
|
"market-summary-too-thin",
|
|
422
453
|
f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
|
|
423
454
|
)
|
|
424
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
|
+
)
|
|
508
|
+
|
|
425
509
|
|
|
426
510
|
# ---------------- render ----------------
|
|
427
511
|
|
|
@@ -452,10 +536,9 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
452
536
|
|
|
453
537
|
psf = d.get("sales_per_month")
|
|
454
538
|
if psf is None:
|
|
455
|
-
psf = max(1, round(d["for_sale"] / 7))
|
|
539
|
+
psf = max(1, round(d["for_sale"] / 7))
|
|
456
540
|
|
|
457
541
|
slots = {
|
|
458
|
-
# branding
|
|
459
542
|
"brand_name": brand["name"],
|
|
460
543
|
"brand_tagline": brand["tagline"],
|
|
461
544
|
"brand_primary": brand["primary"],
|
|
@@ -467,14 +550,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
467
550
|
"wordmark_light": _wordmark(brand, "light", out_dir),
|
|
468
551
|
"wordmark_dark": _wordmark(brand, "dark", out_dir),
|
|
469
552
|
|
|
470
|
-
# cover + header
|
|
471
553
|
"subject_hero_path": _hero_asset(subject, out_dir),
|
|
472
554
|
"address": inputs["address"],
|
|
473
555
|
"address_short": _short_address(inputs["address"]),
|
|
474
556
|
"postcode": inputs["postcode"],
|
|
475
557
|
"generated": inputs["generated"],
|
|
476
558
|
|
|
477
|
-
# page 2 — subject valuation
|
|
478
559
|
"kpi_subject_sqft": subject_sqft,
|
|
479
560
|
"kpi_subject_sqft_sub": subject_sqft_sub,
|
|
480
561
|
"kpi_subject_beds": str(subject.get("beds") or "—"),
|
|
@@ -485,7 +566,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
485
566
|
"valuation_block": _valuation_block(subject, v),
|
|
486
567
|
"subject_narrative": subject.get("narrative") or "",
|
|
487
568
|
|
|
488
|
-
# page 3 — market context
|
|
489
569
|
"summary_headline": ms["headline"],
|
|
490
570
|
"summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
|
|
491
571
|
"kpi_avg_asking": _money(v["avg_asking"]),
|
|
@@ -498,15 +578,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
498
578
|
"demand_note": ms["demand_note"],
|
|
499
579
|
"spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
|
|
500
580
|
|
|
501
|
-
# page 4 — comparables
|
|
502
581
|
"sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
|
|
503
582
|
"asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
|
|
504
583
|
|
|
505
|
-
# page 5 — agent listings + sources
|
|
506
584
|
"agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
|
|
507
585
|
}
|
|
508
586
|
|
|
509
|
-
# Escape only the plain-text slots; HTML fragments pass through untouched.
|
|
510
587
|
text_slots = {
|
|
511
588
|
"address", "address_short", "postcode", "generated",
|
|
512
589
|
"brand_name", "brand_tagline",
|
|
@@ -553,11 +630,6 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
|
|
|
553
630
|
|
|
554
631
|
|
|
555
632
|
def _count_pages(pdf_path: Path) -> int:
|
|
556
|
-
"""pdfinfo first (handles /ObjStm-compressed page-object streams), with a
|
|
557
|
-
raw /Type /Page byte scan as fallback. The fallback is correct for current
|
|
558
|
-
Chrome --print-to-pdf output but would undercount if Chrome ever switches
|
|
559
|
-
to compressed page-object streams — hence pdfinfo first when present.
|
|
560
|
-
"""
|
|
561
633
|
pdfinfo = shutil.which("pdfinfo")
|
|
562
634
|
if pdfinfo:
|
|
563
635
|
result = subprocess.run(
|
|
@@ -580,20 +652,113 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
|
580
652
|
return n
|
|
581
653
|
|
|
582
654
|
|
|
583
|
-
|
|
584
|
-
|
|
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:
|
|
585
752
|
ms = int((time.monotonic() - t0) * 1000)
|
|
586
|
-
|
|
753
|
+
_log_line(
|
|
754
|
+
log_path,
|
|
587
755
|
f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
|
|
588
|
-
file=sys.stderr,
|
|
589
756
|
)
|
|
590
757
|
print(hint, file=sys.stderr)
|
|
591
758
|
if out_dir is not None:
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
p.unlink()
|
|
596
|
-
# Any PDF whose stem matches filename_stem would be a partial — caller passes the stem.
|
|
759
|
+
p = out_dir / "index.html"
|
|
760
|
+
if p.exists():
|
|
761
|
+
p.unlink()
|
|
597
762
|
sys.exit(2)
|
|
598
763
|
|
|
599
764
|
|
|
@@ -604,24 +769,44 @@ def main():
|
|
|
604
769
|
sys.exit(2)
|
|
605
770
|
inputs = json.loads(Path(sys.argv[1]).read_text())
|
|
606
771
|
out_dir = Path(inputs["out_dir"]).expanduser()
|
|
772
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
607
773
|
base = inputs["filename_stem"]
|
|
608
774
|
address = inputs.get("address", "")
|
|
609
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
|
+
|
|
610
791
|
try:
|
|
611
792
|
render(inputs, out_dir)
|
|
612
793
|
pdf = render_pdf(out_dir, f"{base}.pdf")
|
|
613
794
|
pages = assert_page_count(pdf, 5)
|
|
614
795
|
except PrevalAbort as e:
|
|
615
|
-
# Scrub partial PDF too, if rendered before page-count failure.
|
|
616
796
|
pdf_path = out_dir / f"{base}.pdf"
|
|
617
797
|
if pdf_path.exists():
|
|
618
798
|
pdf_path.unlink()
|
|
619
|
-
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
|
|
799
|
+
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
|
|
800
|
+
out_dir=out_dir, log_path=log_path)
|
|
620
801
|
except RuntimeError as e:
|
|
621
802
|
pdf_path = out_dir / f"{base}.pdf"
|
|
622
803
|
if pdf_path.exists():
|
|
623
804
|
pdf_path.unlink()
|
|
624
|
-
_abort("render-error", str(e), address=address, postcode=postcode, t0=t0,
|
|
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}")
|
|
625
810
|
print(f"HTML → {out_dir/'index.html'}")
|
|
626
811
|
print(f"PDF → {pdf} ({pages} pages)")
|
|
627
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,13 +153,14 @@
|
|
|
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
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
|
|
|
@@ -172,7 +171,7 @@
|
|
|
172
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.",
|
|
173
172
|
"items": {
|
|
174
173
|
"type": "object",
|
|
175
|
-
"required": ["address", "price", "status", "slug"],
|
|
174
|
+
"required": ["address", "price", "status", "slug", "origin"],
|
|
176
175
|
"properties": {
|
|
177
176
|
"url": { "type": ["string", "null"], "description": "Source URL — listing card becomes an <a> when present." },
|
|
178
177
|
"address": { "type": "string" },
|
|
@@ -180,10 +179,21 @@
|
|
|
180
179
|
"beds": { "type": ["integer", "null"] },
|
|
181
180
|
"type": { "type": ["string", "null"] },
|
|
182
181
|
"image": { "type": ["string", "null"], "description": "Path or URL to a tile photo for the card." },
|
|
183
|
-
"status": { "type": "string", "
|
|
184
|
-
"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." }
|
|
185
185
|
}
|
|
186
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
|
+
}
|
|
187
197
|
}
|
|
188
198
|
}
|
|
189
199
|
}
|
|
@@ -4,48 +4,70 @@ description: "PropertyData UK market analytics — postcode-keyed valuation, ask
|
|
|
4
4
|
tools:
|
|
5
5
|
- name: property-data-key-register
|
|
6
6
|
publicAllowlist: false
|
|
7
|
+
adminAllowlist: false
|
|
7
8
|
- name: property-data-key-deregister
|
|
8
9
|
publicAllowlist: false
|
|
10
|
+
adminAllowlist: false
|
|
9
11
|
- name: property-data-key-list
|
|
10
12
|
publicAllowlist: false
|
|
13
|
+
adminAllowlist: false
|
|
11
14
|
- name: property-data-valuation-sale
|
|
12
15
|
publicAllowlist: false
|
|
16
|
+
adminAllowlist: false
|
|
13
17
|
- name: property-data-valuation-rent
|
|
14
18
|
publicAllowlist: false
|
|
19
|
+
adminAllowlist: false
|
|
15
20
|
- name: property-data-prices
|
|
16
21
|
publicAllowlist: false
|
|
22
|
+
adminAllowlist: false
|
|
17
23
|
- name: property-data-prices-rent
|
|
18
24
|
publicAllowlist: false
|
|
25
|
+
adminAllowlist: false
|
|
19
26
|
- name: property-data-sold-prices
|
|
20
27
|
publicAllowlist: false
|
|
28
|
+
adminAllowlist: false
|
|
21
29
|
- name: property-data-demand
|
|
22
30
|
publicAllowlist: false
|
|
31
|
+
adminAllowlist: false
|
|
23
32
|
- name: property-data-yields
|
|
24
33
|
publicAllowlist: false
|
|
34
|
+
adminAllowlist: false
|
|
25
35
|
- name: property-data-growth
|
|
26
36
|
publicAllowlist: false
|
|
37
|
+
adminAllowlist: false
|
|
27
38
|
- name: property-data-national-hpi
|
|
28
39
|
publicAllowlist: false
|
|
40
|
+
adminAllowlist: false
|
|
29
41
|
- name: property-data-prices-per-sqf
|
|
30
42
|
publicAllowlist: false
|
|
43
|
+
adminAllowlist: false
|
|
31
44
|
- name: property-data-sold-prices-per-sqf
|
|
32
45
|
publicAllowlist: false
|
|
46
|
+
adminAllowlist: false
|
|
33
47
|
- name: property-data-crime
|
|
34
48
|
publicAllowlist: false
|
|
49
|
+
adminAllowlist: false
|
|
35
50
|
- name: property-data-flood-risk
|
|
36
51
|
publicAllowlist: false
|
|
52
|
+
adminAllowlist: false
|
|
37
53
|
- name: property-data-council-tax
|
|
38
54
|
publicAllowlist: false
|
|
55
|
+
adminAllowlist: false
|
|
39
56
|
- name: property-data-planning-applications
|
|
40
57
|
publicAllowlist: false
|
|
58
|
+
adminAllowlist: false
|
|
41
59
|
- name: property-data-address-match-uprn
|
|
42
60
|
publicAllowlist: false
|
|
61
|
+
adminAllowlist: false
|
|
43
62
|
- name: property-data-growth-psf
|
|
44
63
|
publicAllowlist: false
|
|
64
|
+
adminAllowlist: false
|
|
45
65
|
- name: property-data-demand-rent
|
|
46
66
|
publicAllowlist: false
|
|
67
|
+
adminAllowlist: false
|
|
47
68
|
- name: property-data-property-types
|
|
48
69
|
publicAllowlist: false
|
|
70
|
+
adminAllowlist: false
|
|
49
71
|
skills:
|
|
50
72
|
- skills/property-data/SKILL.md
|
|
51
73
|
always: false
|