@rubytech/create-maxy-code 0.1.58 → 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.
Files changed (77) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +30 -19
  3. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +35 -90
  4. package/payload/platform/plugins/docs/references/admin-session.md +17 -16
  5. package/payload/platform/plugins/docs/references/platform.md +3 -3
  6. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  7. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +8 -0
  8. package/payload/platform/plugins/preval/PLUGIN.md +39 -0
  9. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -0
  10. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +265 -0
  11. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
  12. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +253 -0
  13. package/payload/platform/plugins/property-data/.claude-plugin/plugin.json +1 -1
  14. package/payload/platform/plugins/property-data/PLUGIN.md +46 -1
  15. package/payload/platform/plugins/property-data/mcp/dist/index.js +81 -1
  16. package/payload/platform/plugins/property-data/mcp/dist/index.js.map +1 -1
  17. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
  18. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  19. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js +13 -0
  20. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  21. package/payload/platform/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
  22. package/payload/platform/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
  23. package/payload/platform/plugins/property-data/mcp/src/index.ts +155 -0
  24. package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +20 -0
  25. package/payload/platform/plugins/property-data/skills/property-data/SKILL.md +19 -0
  26. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/http-server.js +25 -260
  28. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +3 -7
  30. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +6 -32
  32. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +6 -24
  34. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +18 -91
  36. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/reaper.js +0 -11
  39. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +0 -21
  41. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  43. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  44. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  45. package/payload/premium-plugins/real-agent/BUNDLE.md +5 -3
  46. package/payload/premium-plugins/real-agent/agents/negotiator.md +1 -1
  47. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  48. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  49. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +8 -0
  50. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +39 -0
  51. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -0
  52. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +265 -0
  53. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
  54. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +253 -0
  55. package/payload/premium-plugins/real-agent/plugins/property-data/.claude-plugin/plugin.json +1 -1
  56. package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +46 -1
  57. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +81 -1
  58. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js.map +1 -1
  59. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
  60. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  61. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js +13 -0
  62. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  63. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
  64. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
  65. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/index.ts +155 -0
  66. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +20 -0
  67. package/payload/premium-plugins/real-agent/plugins/property-data/skills/property-data/SKILL.md +19 -0
  68. package/payload/server/public/assets/{admin-2ZJmzJ_g.js → admin-nqCBFiSl.js} +1 -1
  69. package/payload/server/public/assets/{data-DlmoKxmN.js → data-BGUAGVkV.js} +1 -1
  70. package/payload/server/public/assets/{graph-BVvcFSEQ.js → graph-MvYxZOBF.js} +1 -1
  71. package/payload/server/public/assets/{graph-labels-cCvgu38Y.js → graph-labels-D865qb3K.js} +1 -1
  72. package/payload/server/public/assets/{page-BnAxPkmg.js → page--hOVRrgN.js} +1 -1
  73. package/payload/server/public/assets/{page-BnaN3u86.js → page-C2b1nlOc.js} +1 -1
  74. package/payload/server/public/data.html +3 -3
  75. package/payload/server/public/graph.html +3 -3
  76. package/payload/server/public/index.html +4 -4
  77. package/payload/server/server.js +19 -85
@@ -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 "&nbsp;"
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
+ }