@rubytech/create-maxy-code 0.1.57 → 0.1.59
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/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 +46 -1
- package/payload/platform/plugins/property-data/mcp/dist/index.js +81 -1
- package/payload/platform/plugins/property-data/mcp/dist/index.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 +13 -0
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.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 +155 -0
- package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +20 -0
- 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/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 +46 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +81 -1
- 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/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 +13 -0
- 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/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 +155 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +20 -0
- 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-nqCBFiSl.js} +1 -1
- 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 +19 -83
|
@@ -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
|
+
}
|