@rubytech/create-maxy-code 0.1.58 → 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +30 -19
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +35 -90
- package/payload/platform/plugins/docs/references/admin-session.md +17 -16
- package/payload/platform/plugins/docs/references/platform.md +3 -3
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/loop/PLUGIN.md +13 -1
- package/payload/platform/plugins/loop/mcp/dist/index.js +8 -7
- package/payload/platform/plugins/loop/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js +165 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js +11 -28
- package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/src/index.ts +10 -7
- package/payload/platform/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
- package/payload/platform/plugins/loop/mcp/src/lib/key-store.ts +221 -0
- package/payload/platform/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
- package/payload/platform/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
- package/payload/platform/plugins/loop/mcp/src/tools/key-register.ts +14 -39
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +8 -0
- package/payload/platform/plugins/preval/PLUGIN.md +39 -0
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -0
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +265 -0
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +253 -0
- package/payload/platform/plugins/property-data/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/property-data/PLUGIN.md +60 -2
- package/payload/platform/plugins/property-data/mcp/dist/index.js +95 -12
- package/payload/platform/plugins/property-data/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js +15 -2
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
- package/payload/platform/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
- package/payload/platform/plugins/property-data/mcp/src/index.ts +171 -11
- package/payload/platform/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/platform/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
- package/payload/platform/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
- package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +22 -2
- package/payload/platform/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
- package/payload/platform/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
- package/payload/platform/plugins/property-data/skills/property-data/SKILL.md +19 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +25 -260
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +3 -7
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +6 -32
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +6 -24
- 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 +18 -91
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.js +0 -11
- package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +0 -21
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/admin/SOUL.md +1 -1
- package/payload/premium-plugins/real-agent/BUNDLE.md +5 -3
- package/payload/premium-plugins/real-agent/agents/negotiator.md +1 -1
- package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +13 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +8 -7
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js +165 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js +11 -28
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +10 -7
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/key-store.ts +221 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-register.ts +14 -39
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +8 -0
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +39 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +265 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +253 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +60 -2
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +95 -12
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js +15 -2
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/index.ts +171 -11
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +22 -2
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
- package/payload/premium-plugins/real-agent/plugins/property-data/skills/property-data/SKILL.md +19 -0
- package/payload/server/public/assets/{admin-2ZJmzJ_g.js → admin-Bp-BjBCX.js} +51 -51
- package/payload/server/public/assets/{data-DlmoKxmN.js → data-BGUAGVkV.js} +1 -1
- package/payload/server/public/assets/{graph-BVvcFSEQ.js → graph-MvYxZOBF.js} +1 -1
- package/payload/server/public/assets/{graph-labels-cCvgu38Y.js → graph-labels-D865qb3K.js} +1 -1
- package/payload/server/public/assets/{page-BnAxPkmg.js → page--hOVRrgN.js} +1 -1
- package/payload/server/public/assets/{page-BnaN3u86.js → page-C2b1nlOc.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 +18 -83
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +0 -51
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +0 -1
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +0 -196
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +0 -1
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +0 -7
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""property-preval renderer.
|
|
2
|
+
|
|
3
|
+
Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
|
|
4
|
+
derived from one inputs.json (shape: template-inputs.schema.json), drives
|
|
5
|
+
Chrome headless to produce a 4-page A4 PDF, and asserts page count == 4.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 render.py inputs.json
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import html, json, re, shutil, subprocess, sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _money(n: int) -> str:
|
|
16
|
+
return f"£{int(n):,}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sold_comps_rows(rows: list[dict]) -> str:
|
|
20
|
+
if not rows:
|
|
21
|
+
return '<tr><td colspan="4" class="muted">No sold transactions in the sample.</td></tr>'
|
|
22
|
+
return "".join(
|
|
23
|
+
f"<tr><td>{html.escape(r['date'])}</td>"
|
|
24
|
+
f"<td>{html.escape(r['address'])}</td>"
|
|
25
|
+
f"<td>{html.escape(r['type'])}</td>"
|
|
26
|
+
f"<td class=\"num\">{_money(r['price'])}</td></tr>"
|
|
27
|
+
for r in rows[:6]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _asking_comps_tiles(rows: list[dict]) -> str:
|
|
32
|
+
if not rows:
|
|
33
|
+
return '<div class="muted">No live asking comps in the sample.</div>'
|
|
34
|
+
out = []
|
|
35
|
+
for r in rows[:4]:
|
|
36
|
+
meta_bits = []
|
|
37
|
+
if r.get("beds"):
|
|
38
|
+
meta_bits.append(f"{r['beds']} bed")
|
|
39
|
+
if r.get("type"):
|
|
40
|
+
meta_bits.append(html.escape(r["type"]))
|
|
41
|
+
meta = " · ".join(meta_bits) or " "
|
|
42
|
+
out.append(
|
|
43
|
+
f'<div class="ask-tile">'
|
|
44
|
+
f'<div class="addr">{html.escape(r["address"])}</div>'
|
|
45
|
+
f'<div class="price">{_money(r["price"])}</div>'
|
|
46
|
+
f'<div class="meta">{meta}</div>'
|
|
47
|
+
f"</div>"
|
|
48
|
+
)
|
|
49
|
+
return "".join(out)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _crime_rows(rows: list[dict]) -> str:
|
|
53
|
+
if not rows:
|
|
54
|
+
return '<div class="muted">No crime data.</div>'
|
|
55
|
+
top = sorted(rows, key=lambda r: r["count"], reverse=True)[:6]
|
|
56
|
+
cap = max((r["count"] for r in top), default=1) or 1
|
|
57
|
+
return "".join(
|
|
58
|
+
f'<div class="dist-row">'
|
|
59
|
+
f'<div class="name">{html.escape(r["category"])}</div>'
|
|
60
|
+
f'<div class="bar"><span{" class=gold" if i == 0 else ""} style="width:{int(100 * r["count"] / cap)}%"></span></div>'
|
|
61
|
+
f'<div class="pct">{r["count"]}</div>'
|
|
62
|
+
f"</div>"
|
|
63
|
+
for i, r in enumerate(top)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _band_rows(rows: list[dict]) -> str:
|
|
68
|
+
if not rows:
|
|
69
|
+
return '<tr><td colspan="2" class="muted">No banding data.</td></tr>'
|
|
70
|
+
return "".join(
|
|
71
|
+
f"<tr><td>Band {html.escape(r['band'])}</td>"
|
|
72
|
+
f"<td class=\"num\">{_money(r['amount'])}</td></tr>"
|
|
73
|
+
for r in rows
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _type_dist_rows(rows: list[dict]) -> str:
|
|
78
|
+
if not rows:
|
|
79
|
+
return '<div class="muted">No property-type mix data.</div>'
|
|
80
|
+
ordered = sorted(rows, key=lambda r: float(r["pct"]), reverse=True)
|
|
81
|
+
return "".join(
|
|
82
|
+
f'<div class="dist-row">'
|
|
83
|
+
f'<div class="name">{html.escape(r["name"])}</div>'
|
|
84
|
+
f'<div class="bar"><span{" class=gold" if i == 0 else ""} style="width:{float(r["pct"]):.0f}%"></span></div>'
|
|
85
|
+
f'<div class="pct">{float(r["pct"]):.1f}%</div>'
|
|
86
|
+
f"</div>"
|
|
87
|
+
for i, r in enumerate(ordered)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
92
|
+
"""Two-line sparkline in a 1000x380 viewBox SVG, no external dependencies."""
|
|
93
|
+
W, H = 1000, 380
|
|
94
|
+
pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
|
|
95
|
+
iw, ih = W - pad_l - pad_r, H - pad_t - pad_b
|
|
96
|
+
|
|
97
|
+
def _line(series, key, css_pl, css_pt):
|
|
98
|
+
if not series:
|
|
99
|
+
return ""
|
|
100
|
+
ys = [float(p[key]) for p in series]
|
|
101
|
+
ymin, ymax = min(ys), max(ys)
|
|
102
|
+
if ymax == ymin:
|
|
103
|
+
ymax = ymin + 1
|
|
104
|
+
n = len(series)
|
|
105
|
+
pts = []
|
|
106
|
+
circles = []
|
|
107
|
+
labels = []
|
|
108
|
+
for i, p in enumerate(series):
|
|
109
|
+
x = pad_l + (i * iw / max(1, n - 1))
|
|
110
|
+
y = pad_t + ih - ((float(p[key]) - ymin) / (ymax - ymin)) * ih
|
|
111
|
+
pts.append(f"{x:.1f},{y:.1f}")
|
|
112
|
+
circles.append(f'<circle class="{css_pt}" cx="{x:.1f}" cy="{y:.1f}" r="3"/>')
|
|
113
|
+
labels.append(
|
|
114
|
+
f'<text x="{x:.1f}" y="{H - 18}" text-anchor="middle">{html.escape(p["label"])}</text>'
|
|
115
|
+
)
|
|
116
|
+
return (
|
|
117
|
+
f'<polyline class="{css_pl}" points="{" ".join(pts)}"/>'
|
|
118
|
+
+ "".join(circles)
|
|
119
|
+
+ "".join(labels)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
axis = (
|
|
123
|
+
f'<line class="axis" x1="{pad_l}" y1="{pad_t + ih}" x2="{W - pad_r}" y2="{pad_t + ih}"/>'
|
|
124
|
+
)
|
|
125
|
+
body = axis + _line(price_series, "price", "pl", "pt") + _line(psf_series, "psf", "pl-psf", "pt-psf")
|
|
126
|
+
return (
|
|
127
|
+
f'<svg class="spark" viewBox="0 0 {W} {H}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">{body}</svg>'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
|
|
132
|
+
template_path = template_path or (Path(__file__).parent / "template.html")
|
|
133
|
+
tmpl = template_path.read_text()
|
|
134
|
+
|
|
135
|
+
v = inputs["valuation"]
|
|
136
|
+
a = inputs["area"]
|
|
137
|
+
d = inputs["demand_trend"]
|
|
138
|
+
r = d["rental"]
|
|
139
|
+
|
|
140
|
+
slots = {
|
|
141
|
+
"address": inputs["address"],
|
|
142
|
+
"postcode": inputs["postcode"],
|
|
143
|
+
"generated": inputs["generated"],
|
|
144
|
+
|
|
145
|
+
"kpi_avg_asking": _money(v["avg_asking"]),
|
|
146
|
+
"kpi_asking_psf": _money(v["asking_psf"]),
|
|
147
|
+
"kpi_avg_sold": _money(v["avg_sold"]),
|
|
148
|
+
"kpi_sold_psf": _money(v["sold_psf"]),
|
|
149
|
+
"sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
|
|
150
|
+
"asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
|
|
151
|
+
|
|
152
|
+
"area_headline": a["headline"],
|
|
153
|
+
"kpi_crime": a["crime_rating"],
|
|
154
|
+
"kpi_flood": a["flood_risk"],
|
|
155
|
+
"kpi_band_d": _money(a["council_tax_band_d"]),
|
|
156
|
+
"kpi_population": f"{int(a['population']):,}",
|
|
157
|
+
"crime_rows": _crime_rows(a.get("crime_breakdown", [])),
|
|
158
|
+
"band_rows": _band_rows(a.get("council_tax_bands", [])),
|
|
159
|
+
|
|
160
|
+
"kpi_demand_rating": d["demand_rating"],
|
|
161
|
+
"kpi_dom": str(d["dom"]),
|
|
162
|
+
"kpi_turnover": f"{int(d['turnover_pct'])}%",
|
|
163
|
+
"kpi_for_sale": str(d["for_sale"]),
|
|
164
|
+
"spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
|
|
165
|
+
|
|
166
|
+
"rental_rating": r["demand_rating"],
|
|
167
|
+
"rental_dom": str(r["dom"]),
|
|
168
|
+
"rental_turnover": f"{int(r['turnover_pct'])}%",
|
|
169
|
+
"rental_for_rent": str(r["for_rent"]),
|
|
170
|
+
|
|
171
|
+
"type_dist_rows": _type_dist_rows(d.get("type_dist", [])),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Escape free-form text slots that aren't already HTML fragments.
|
|
175
|
+
text_slots = {"address", "postcode", "generated", "area_headline", "kpi_crime", "kpi_flood",
|
|
176
|
+
"kpi_demand_rating", "rental_rating"}
|
|
177
|
+
for k in text_slots:
|
|
178
|
+
slots[k] = html.escape(slots[k])
|
|
179
|
+
|
|
180
|
+
out = tmpl
|
|
181
|
+
for k, val in slots.items():
|
|
182
|
+
out = out.replace(f"<!-- REPLACE: {k} -->", val)
|
|
183
|
+
|
|
184
|
+
leftovers = re.findall(r"<!-- REPLACE: (\w+) -->", out)
|
|
185
|
+
if leftovers:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
"template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
out_path = out_dir / "index.html"
|
|
192
|
+
out_path.write_text(out)
|
|
193
|
+
return out_path
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def render_pdf(out_dir: Path, pdf_name: str) -> Path:
|
|
197
|
+
chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
198
|
+
if not Path(chrome).exists():
|
|
199
|
+
raise FileNotFoundError(
|
|
200
|
+
"Chrome not found at " + chrome + " — install Chrome or override the path."
|
|
201
|
+
)
|
|
202
|
+
pdf_path = out_dir / pdf_name
|
|
203
|
+
subprocess.run([
|
|
204
|
+
chrome, "--headless", "--disable-gpu", "--no-pdf-header-footer",
|
|
205
|
+
f"--print-to-pdf={pdf_path.resolve()}", "--virtual-time-budget=30000",
|
|
206
|
+
f"file://{(out_dir / 'index.html').resolve()}",
|
|
207
|
+
], check=True, capture_output=True)
|
|
208
|
+
return pdf_path
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _count_pages(pdf_path: Path) -> int:
|
|
215
|
+
"""Prefer pdfinfo (handles compressed object streams); fall back to a
|
|
216
|
+
raw `/Type /Page` byte scan when pdfinfo is not installed. The fallback
|
|
217
|
+
is correct for current Chrome `--print-to-pdf` output (no `/ObjStm`)
|
|
218
|
+
but would silently undercount if a future Chrome switches to compressed
|
|
219
|
+
page-object streams — hence pdfinfo first when available.
|
|
220
|
+
"""
|
|
221
|
+
pdfinfo = shutil.which("pdfinfo")
|
|
222
|
+
if pdfinfo:
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
[pdfinfo, str(pdf_path)], check=True, capture_output=True, text=True,
|
|
225
|
+
)
|
|
226
|
+
for line in result.stdout.splitlines():
|
|
227
|
+
if line.startswith("Pages:"):
|
|
228
|
+
return int(line.split(":", 1)[1].strip())
|
|
229
|
+
raise RuntimeError(f"pdfinfo emitted no Pages line for {pdf_path}")
|
|
230
|
+
return len(_PAGE_OBJ_RE.findall(pdf_path.read_bytes()))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
234
|
+
"""Assert PDF page count == expected; raise RuntimeError otherwise.
|
|
235
|
+
|
|
236
|
+
Synchronous and reliable — does not depend on Spotlight (`mdls` returns
|
|
237
|
+
`(null)` until indexing finishes, which is async). Operators can still
|
|
238
|
+
verify externally with `mdls -name kMDItemNumberOfPages <pdf>` once
|
|
239
|
+
Spotlight catches up.
|
|
240
|
+
"""
|
|
241
|
+
n = _count_pages(pdf_path)
|
|
242
|
+
if n != expected:
|
|
243
|
+
raise RuntimeError(
|
|
244
|
+
f"page-count mismatch: expected {expected}, got {n} ({pdf_path}) — "
|
|
245
|
+
"tighten panel padding for the overflowing page"
|
|
246
|
+
)
|
|
247
|
+
return n
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main():
|
|
251
|
+
if len(sys.argv) < 2:
|
|
252
|
+
print("Usage: render.py <inputs.json>", file=sys.stderr)
|
|
253
|
+
sys.exit(2)
|
|
254
|
+
inputs = json.loads(Path(sys.argv[1]).read_text())
|
|
255
|
+
out_dir = Path(inputs["out_dir"]).expanduser()
|
|
256
|
+
base = inputs["filename_stem"]
|
|
257
|
+
render(inputs, out_dir)
|
|
258
|
+
pdf = render_pdf(out_dir, f"{base}.pdf")
|
|
259
|
+
pages = assert_page_count(pdf, 4)
|
|
260
|
+
print(f"HTML → {out_dir/'index.html'}")
|
|
261
|
+
print(f"PDF → {pdf} ({pages} pages)")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "property-preval inputs",
|
|
4
|
+
"description": "Single-source contract for render.py. Mirrors the 12 property-data MCP responses for one full UK address.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["out_dir", "filename_stem", "address", "postcode", "generated", "valuation", "area", "demand_trend"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"out_dir": { "type": "string", "description": "Absolute path to the output directory (created if missing)." },
|
|
9
|
+
"filename_stem": { "type": "string", "description": "PDF filename stem; .pdf appended by the renderer." },
|
|
10
|
+
"address": { "type": "string", "description": "Full UK address as the operator gave it." },
|
|
11
|
+
"postcode": { "type": "string", "description": "Postcode derived from the address, e.g. 'CM23 2UJ'." },
|
|
12
|
+
"generated": { "type": "string", "description": "Human date, e.g. '19 May 2026'." },
|
|
13
|
+
"valuation": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"required": ["avg_asking", "asking_psf", "avg_sold", "sold_psf", "sold_comps", "asking_comps"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"avg_asking": { "type": "integer", "description": "Mean asking £ (from property-data-prices)." },
|
|
18
|
+
"asking_psf": { "type": "integer", "description": "Asking £/sqft (from property-data-prices-per-sqf)." },
|
|
19
|
+
"avg_sold": { "type": "integer", "description": "Mean sold £ (from property-data-sold-prices)." },
|
|
20
|
+
"sold_psf": { "type": "integer", "description": "Sold £/sqft (from property-data-sold-prices-per-sqf)." },
|
|
21
|
+
"sold_range": { "type": "array", "items": { "type": "integer" }, "minItems": 2, "maxItems": 2, "description": "Optional 70pc range [low, high]." },
|
|
22
|
+
"sold_comps": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"description": "Nearest sold transactions (≤ 6 rows in the table).",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["date", "address", "type", "price"],
|
|
28
|
+
"properties": {
|
|
29
|
+
"date": { "type": "string" },
|
|
30
|
+
"address": { "type": "string" },
|
|
31
|
+
"type": { "type": "string" },
|
|
32
|
+
"price": { "type": "integer" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"asking_comps": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"description": "Live asking comps (≤ 4 tiles).",
|
|
39
|
+
"items": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"required": ["address", "price"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"address": { "type": "string" },
|
|
44
|
+
"price": { "type": "integer" },
|
|
45
|
+
"beds": { "type": ["integer", "null"] },
|
|
46
|
+
"type": { "type": ["string", "null"] }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"area": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": ["crime_rating", "flood_risk", "council_tax_band_d", "population", "crime_breakdown", "council_tax_bands", "headline"],
|
|
55
|
+
"properties": {
|
|
56
|
+
"crime_rating": { "type": "string", "description": "e.g. 'Below average'." },
|
|
57
|
+
"flood_risk": { "type": "string", "description": "e.g. 'Very low'." },
|
|
58
|
+
"council_tax_band_d": { "type": "integer", "description": "Annual £ at band D." },
|
|
59
|
+
"population": { "type": "integer", "description": "Local area population." },
|
|
60
|
+
"crime_breakdown": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["category", "count"],
|
|
65
|
+
"properties": {
|
|
66
|
+
"category": { "type": "string" },
|
|
67
|
+
"count": { "type": "integer" }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"council_tax_bands": {
|
|
72
|
+
"type": "array",
|
|
73
|
+
"description": "Band → annual £ rows.",
|
|
74
|
+
"items": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"required": ["band", "amount"],
|
|
77
|
+
"properties": {
|
|
78
|
+
"band": { "type": "string" },
|
|
79
|
+
"amount": { "type": "integer" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"headline": { "type": "string", "description": "One-sentence area read." }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"demand_trend": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"required": ["demand_rating", "dom", "turnover_pct", "for_sale", "growth_series", "growth_psf_series", "rental", "type_dist"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"demand_rating": { "type": "string", "description": "e.g. 'Balanced market'." },
|
|
91
|
+
"dom": { "type": "integer", "description": "Days on market." },
|
|
92
|
+
"turnover_pct": { "type": "integer", "description": "Turnover percent per month." },
|
|
93
|
+
"for_sale": { "type": "integer", "description": "Current for-sale count." },
|
|
94
|
+
"growth_series": {
|
|
95
|
+
"type": "array",
|
|
96
|
+
"description": "7-year £ series, oldest first.",
|
|
97
|
+
"items": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"required": ["label", "price"],
|
|
100
|
+
"properties": {
|
|
101
|
+
"label": { "type": "string" },
|
|
102
|
+
"price": { "type": "integer" }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"growth_psf_series": {
|
|
107
|
+
"type": "array",
|
|
108
|
+
"description": "7-year £/sqft series, oldest first.",
|
|
109
|
+
"items": {
|
|
110
|
+
"type": "object",
|
|
111
|
+
"required": ["label", "psf"],
|
|
112
|
+
"properties": {
|
|
113
|
+
"label": { "type": "string" },
|
|
114
|
+
"psf": { "type": "integer" }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"rental": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"required": ["demand_rating", "dom", "turnover_pct", "for_rent"],
|
|
121
|
+
"properties": {
|
|
122
|
+
"demand_rating": { "type": "string" },
|
|
123
|
+
"dom": { "type": "integer" },
|
|
124
|
+
"turnover_pct": { "type": "integer" },
|
|
125
|
+
"for_rent": { "type": "integer" }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"type_dist": {
|
|
129
|
+
"type": "array",
|
|
130
|
+
"description": "Property-type mix doughnut rows.",
|
|
131
|
+
"items": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"required": ["name", "pct"],
|
|
134
|
+
"properties": {
|
|
135
|
+
"name": { "type": "string" },
|
|
136
|
+
"pct": { "type": "number" }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|