@rubytech/create-maxy-code 0.1.60 → 0.1.62

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 (163) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  4. package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  5. package/payload/platform/plugins/docs/references/admin-session.md +2 -2
  6. package/payload/platform/plugins/docs/references/internals.md +1 -1
  7. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  8. package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
  9. package/payload/platform/plugins/epc/PLUGIN.md +85 -0
  10. package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
  11. package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  12. package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
  13. package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
  14. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  15. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  16. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  17. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  18. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  19. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  20. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  21. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  22. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  23. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  24. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  25. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  26. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  27. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  28. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  29. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  30. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  31. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  32. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  33. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  34. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  35. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  36. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  37. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  38. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  39. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  40. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  41. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  42. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  43. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  44. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  45. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  46. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  47. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  48. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  49. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  50. package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
  51. package/payload/platform/plugins/epc/mcp/package.json +21 -0
  52. package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  53. package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  54. package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  55. package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  56. package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
  57. package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  58. package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  59. package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  60. package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  61. package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  62. package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  63. package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  64. package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  65. package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  66. package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
  67. package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
  68. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
  69. package/payload/platform/plugins/preval/PLUGIN.md +25 -17
  70. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +107 -39
  71. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +449 -84
  72. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
  73. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +183 -124
  74. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  75. package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
  76. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  78. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  79. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
  80. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  81. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  82. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  83. package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
  84. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  85. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  86. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  87. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  88. package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
  89. package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
  90. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
  91. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  92. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
  93. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
  94. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  95. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  96. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  97. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  98. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  99. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  100. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  101. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  102. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  103. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  104. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  105. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  106. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  107. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  108. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  109. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  110. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  111. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  112. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  113. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  114. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  115. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  116. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  117. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  118. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  119. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  120. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  121. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  122. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  123. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  124. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  125. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  126. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  127. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  128. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  129. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  130. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
  131. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
  132. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  133. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  134. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  135. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  136. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
  137. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  138. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  139. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  140. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  141. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  142. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  143. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  144. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  145. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  146. package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
  147. package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
  148. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
  149. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +25 -17
  150. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +107 -39
  151. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +449 -84
  152. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
  153. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +183 -124
  154. package/payload/server/public/assets/{admin-Bp-BjBCX.js → admin-DhN3G8W7.js} +1 -1
  155. package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
  156. package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
  157. package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
  158. package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
  159. package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
  160. package/payload/server/public/data.html +3 -3
  161. package/payload/server/public/graph.html +3 -3
  162. package/payload/server/public/index.html +4 -4
  163. package/payload/server/server.js +3 -16
@@ -2,20 +2,38 @@
2
2
 
3
3
  Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
4
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.
5
+ Chrome headless to produce a 5-page A4 PDF, and asserts page count == 5.
6
+
7
+ Fails loud on any missing / unverifiable load-bearing input. No silent
8
+ fallback modes — see _assert_inputs.
6
9
 
7
10
  Usage:
8
11
  python3 render.py inputs.json
9
12
  """
10
13
  from __future__ import annotations
11
- import html, json, re, shutil, subprocess, sys
14
+ import html, json, re, shutil, subprocess, sys, time
12
15
  from pathlib import Path
13
16
 
14
17
 
18
+ M2_TO_SQFT = 10.7639
19
+
20
+
15
21
  def _money(n: int) -> str:
16
22
  return f"£{int(n):,}"
17
23
 
18
24
 
25
+ def _range_money(low: int, high: int) -> str:
26
+ return f"£{int(low):,} – £{int(high):,}"
27
+
28
+
29
+ # ---------------- helpers ----------------
30
+
31
+ def _short_address(address: str) -> str:
32
+ """First two address tokens for the cover meta strip."""
33
+ parts = [p.strip() for p in address.split(",")]
34
+ return ", ".join(parts[:2]) if len(parts) >= 2 else address
35
+
36
+
19
37
  def _sold_comps_rows(rows: list[dict]) -> str:
20
38
  if not rows:
21
39
  return '<tr><td colspan="4" class="muted">No sold transactions in the sample.</td></tr>'
@@ -49,31 +67,6 @@ def _asking_comps_tiles(rows: list[dict]) -> str:
49
67
  return "".join(out)
50
68
 
51
69
 
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
70
  def _type_dist_rows(rows: list[dict]) -> str:
78
71
  if not rows:
79
72
  return '<div class="muted">No property-type mix data.</div>'
@@ -89,7 +82,7 @@ def _type_dist_rows(rows: list[dict]) -> str:
89
82
 
90
83
 
91
84
  def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
92
- """Two-line sparkline in a 1000x380 viewBox SVG, no external dependencies."""
85
+ """Two-line sparkline in a 1000x380 viewBox SVG, no external deps."""
93
86
  W, H = 1000, 380
94
87
  pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
95
88
  iw, ih = W - pad_l - pad_r, H - pad_t - pad_b
@@ -102,9 +95,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
102
95
  if ymax == ymin:
103
96
  ymax = ymin + 1
104
97
  n = len(series)
105
- pts = []
106
- circles = []
107
- labels = []
98
+ pts, circles, labels = [], [], []
108
99
  for i, p in enumerate(series):
109
100
  x = pad_l + (i * iw / max(1, n - 1))
110
101
  y = pad_t + ih - ((float(p[key]) - ymin) / (ymax - ymin)) * ih
@@ -115,8 +106,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
115
106
  )
116
107
  return (
117
108
  f'<polyline class="{css_pl}" points="{" ".join(pts)}"/>'
118
- + "".join(circles)
119
- + "".join(labels)
109
+ + "".join(circles) + "".join(labels)
120
110
  )
121
111
 
122
112
  axis = (
@@ -128,54 +118,406 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
128
118
  )
129
119
 
130
120
 
121
+ def _summary_paragraphs(paras: list[str]) -> str:
122
+ if not paras:
123
+ return ""
124
+ out = [f'<p class="dropcap">{html.escape(paras[0])}</p>']
125
+ for p in paras[1:]:
126
+ out.append(f"<p>{html.escape(p)}</p>")
127
+ return "".join(out)
128
+
129
+
130
+ def _wordmark(brand: dict, variant: str, out_dir: Path) -> str:
131
+ """variant ∈ {'light', 'dark'}. Mirrors property-market-report's wordmark."""
132
+ key = "logo_light" if variant == "light" else "logo_dark"
133
+ logo_path = brand.get(key)
134
+ if logo_path:
135
+ src = Path(logo_path).expanduser()
136
+ dest_dir = out_dir / "img"
137
+ dest_dir.mkdir(parents=True, exist_ok=True)
138
+ dest = dest_dir / src.name
139
+ if src.resolve() != dest.resolve():
140
+ shutil.copy2(src, dest)
141
+ rel = f"img/{src.name}"
142
+ height = "14mm" if variant == "light" else "9mm"
143
+ return f'<img src="{html.escape(rel)}" alt="{html.escape(brand["name"])}" style="height:{height};width:auto;display:block">'
144
+ cls = "wm light" if variant == "light" else "wm small"
145
+ return (
146
+ f'<div class="{cls}">'
147
+ f'<span class="wm-name">{html.escape(brand["name"])}</span>'
148
+ f'<span class="wm-sub">{html.escape(brand["tagline"])}</span>'
149
+ f"</div>"
150
+ )
151
+
152
+
153
+ def _hero_asset(subject: dict, out_dir: Path) -> str:
154
+ """Copy subject.cover_hero into img/ and return the relative path. URLs pass through."""
155
+ hero = subject["cover_hero"]
156
+ if hero.startswith(("http://", "https://", "data:")):
157
+ return hero
158
+ src = Path(hero).expanduser()
159
+ if not src.exists():
160
+ raise FileNotFoundError(f"subject.cover_hero not found: {src}")
161
+ dest_dir = out_dir / "img"
162
+ dest_dir.mkdir(parents=True, exist_ok=True)
163
+ dest = dest_dir / f"hero{src.suffix.lower()}"
164
+ if src.resolve() != dest.resolve():
165
+ shutil.copy2(src, dest)
166
+ return f"img/{dest.name}"
167
+
168
+
169
+ def _listing_card(p: dict, index: int, out_dir: Path) -> str:
170
+ addr_parts = [x.strip() for x in p["address"].split(",")]
171
+ addr = html.escape(addr_parts[0])
172
+ chips = []
173
+ if p.get("beds"): chips.append(f"{p['beds']} bed")
174
+ if p.get("type"): chips.append(html.escape(p["type"]))
175
+ chips_html = "".join(f"<span>{c}</span>" for c in chips)
176
+ img_src = ""
177
+ if p.get("image"):
178
+ src = Path(p["image"]).expanduser() if not str(p["image"]).startswith(("http://", "https://", "data:")) else None
179
+ if src and src.exists():
180
+ dest_dir = out_dir / "img"
181
+ dest_dir.mkdir(parents=True, exist_ok=True)
182
+ dest = dest_dir / f"agent-{index:02d}-{p['slug']}{src.suffix.lower()}"
183
+ if src.resolve() != dest.resolve():
184
+ shutil.copy2(src, dest)
185
+ img_src = f"img/{dest.name}"
186
+ elif src is None:
187
+ img_src = str(p["image"])
188
+ status = p.get("status", "")
189
+ badge_cls = {"Sold": "sold", "Under offer": "under"}.get(status, "")
190
+ photo = (
191
+ f'<div class="photo">'
192
+ f'{f"<img src=\"{html.escape(img_src)}\" alt=\"{addr}\">" if img_src else ""}'
193
+ f'<div class="badge {badge_cls}">{html.escape(status)}</div>'
194
+ f"</div>"
195
+ )
196
+ meta = (
197
+ f'<div class="meta">'
198
+ f'<div class="addr">{addr}</div>'
199
+ f'<div class="price">{_money(p["price"])}</div>'
200
+ f'<div class="kfs">{chips_html}</div>'
201
+ f"</div>"
202
+ )
203
+ inner = photo + meta
204
+ if p.get("url"):
205
+ return f'<a class="listing" href="{html.escape(p["url"])}" target="_blank" rel="noopener">{inner}</a>'
206
+ return f'<div class="listing">{inner}</div>'
207
+
208
+
209
+ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
210
+ if not rows:
211
+ return '<div class="muted" style="margin-top:5mm">No recent listings supplied.</div>'
212
+ return "".join(_listing_card(p, i, out_dir) for i, p in enumerate(rows[:6]))
213
+
214
+
215
+ # ---------------- valuation maths ----------------
216
+
217
+ def _valuation_block(subject: dict, valuation: dict) -> str:
218
+ """Build the page-2 valuation panel: headline range, adjustments, final range.
219
+
220
+ sqft is asserted non-null upstream by _assert_inputs — no fallback panel here.
221
+ """
222
+ sqft = subject["sqft"]
223
+ low_psf = valuation["sold_psf_low"]
224
+ mid_psf = valuation["sold_psf"]
225
+ high_psf = valuation["sold_psf_high"]
226
+ ask_psf = valuation["asking_psf"]
227
+
228
+ low_raw = int(sqft * low_psf)
229
+ mid_raw = int(sqft * mid_psf)
230
+ high_raw = int(sqft * high_psf)
231
+ ask_raw = int(sqft * ask_psf)
232
+
233
+ rows = [
234
+ ('<div class="val-row headline">'
235
+ f'<div class="lbl">Headline range<small>{sqft:,} sqft × £{low_psf}–£{high_psf} sold £/sqft (Land Registry 70pc band)</small></div>'
236
+ f'<div class="num">{_range_money(low_raw, high_raw)}</div></div>'),
237
+ ('<div class="val-row">'
238
+ f'<div class="lbl">Cross-check · asking £/sqft<small>{sqft:,} sqft × £{ask_psf} mean asking £/sqft</small></div>'
239
+ f'<div class="num">{_money(ask_raw)}</div></div>'),
240
+ ]
241
+
242
+ adjustments = subject.get("adjustments") or []
243
+ if adjustments:
244
+ rows.append('<div class="val-row"><div class="lbl" style="font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--text-accent)">Adjustments</div><div class="num">&nbsp;</div></div>')
245
+ for adj in adjustments:
246
+ delta = float(adj["delta_pct"])
247
+ cls = "pos" if delta > 0 else ("neg" if delta < 0 else "")
248
+ sign = "+" if delta >= 0 else ""
249
+ rows.append(
250
+ '<div class="val-row adj">'
251
+ f'<div class="lbl">{html.escape(adj["label"])}</div>'
252
+ f'<div class="num {cls}">{sign}{delta:.1f}%</div>'
253
+ "</div>"
254
+ )
255
+
256
+ delta_sum = sum(float(a["delta_pct"]) for a in adjustments)
257
+ factor = 1.0 + delta_sum / 100.0
258
+ low_final = int(low_raw * factor)
259
+ high_final = int(high_raw * factor)
260
+
261
+ final = (
262
+ '<div class="val-final">'
263
+ f'<div class="lbl">Final indicative range</div>'
264
+ f'<div class="num">{_range_money(low_final, high_final)}</div>'
265
+ "</div>"
266
+ )
267
+
268
+ return '<div class="val-panel">' + "".join(rows) + "</div>" + final
269
+
270
+
271
+ def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
272
+ sqft = subject["sqft"]
273
+ m2 = subject.get("sqft_m2")
274
+ sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
275
+ return f"{int(sqft):,}", sub
276
+
277
+
278
+ def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
279
+ current = subject.get("epc_current")
280
+ potential = subject.get("epc_potential")
281
+ lodgement = subject.get("epc_lodgement")
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"
290
+
291
+
292
+ # ---------------- input assertions ----------------
293
+
294
+ class PrevalAbort(RuntimeError):
295
+ """Raised when a load-bearing input is missing or unverifiable.
296
+
297
+ .cause is the short slug logged as `reason=<cause>` and shown in the
298
+ operator's remediation hint.
299
+ """
300
+ def __init__(self, cause: str, hint: str):
301
+ super().__init__(f"{cause}: {hint}")
302
+ self.cause = cause
303
+ self.hint = hint
304
+
305
+
306
+ def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
307
+ """Existence + dimension check. Symlinks pointing into another property's
308
+ folder are rejected (cover-hero cross-contamination guard)."""
309
+ p = Path(path_str).expanduser()
310
+ if not p.exists():
311
+ raise PrevalAbort(
312
+ "subject-cover-hero-missing",
313
+ f"cover_hero path does not exist: {p}",
314
+ )
315
+ if p.is_symlink():
316
+ target = p.resolve()
317
+ parts = target.parts
318
+ if "properties" in parts:
319
+ # symlink → properties/<other-slug>/… is a cross-contamination
320
+ raise PrevalAbort(
321
+ "subject-cover-hero-symlinked-foreign",
322
+ f"cover_hero is a symlink into another property's folder: {target}",
323
+ )
324
+ # Dimensions. Avoid a Pillow dep; read PNG/JPEG headers directly.
325
+ w, h = _image_dimensions(p)
326
+ if w is None or h is None:
327
+ raise PrevalAbort(
328
+ "subject-cover-hero-unreadable",
329
+ f"cover_hero is not a readable PNG or JPEG: {p}",
330
+ )
331
+ if w < min_w or h < min_h:
332
+ raise PrevalAbort(
333
+ "subject-cover-hero-too-small",
334
+ f"cover_hero is {w}×{h}, below the {min_w}×{min_h} minimum: {p}",
335
+ )
336
+
337
+
338
+ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
339
+ """Return (width, height) for PNG or JPEG. None on any failure."""
340
+ try:
341
+ with p.open("rb") as f:
342
+ head = f.read(24)
343
+ if len(head) < 24:
344
+ return None, None
345
+ if head[:8] == b"\x89PNG\r\n\x1a\n":
346
+ # IHDR width/height at bytes 16..24, big-endian uint32.
347
+ w = int.from_bytes(head[16:20], "big")
348
+ h = int.from_bytes(head[20:24], "big")
349
+ return w, h
350
+ if head[:2] == b"\xff\xd8":
351
+ f.seek(2)
352
+ while True:
353
+ b = f.read(1)
354
+ while b and b != b"\xff":
355
+ b = f.read(1)
356
+ marker = f.read(1)
357
+ if not marker:
358
+ return None, None
359
+ if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
360
+ f.read(3) # length(2) + precision(1)
361
+ h = int.from_bytes(f.read(2), "big")
362
+ w = int.from_bytes(f.read(2), "big")
363
+ return w, h
364
+ seg_len = int.from_bytes(f.read(2), "big")
365
+ f.seek(seg_len - 2, 1)
366
+ return None, None
367
+ except OSError:
368
+ return None, None
369
+
370
+
371
+ def _assert_inputs(inputs: dict) -> None:
372
+ """Fail loud on any missing or unverifiable load-bearing input.
373
+
374
+ Raises PrevalAbort with a named cause. No silent fallback paths.
375
+ """
376
+ subject = inputs.get("subject") or {}
377
+
378
+ # subject.sqft / sqft_m2 — required, non-null integers.
379
+ if not subject.get("sqft"):
380
+ raise PrevalAbort(
381
+ "subject-sqft-missing",
382
+ "subject.sqft is null — run epc-key-register and re-derive, or paste subject.sqft as an operator override before invocation.",
383
+ )
384
+ if not subject.get("sqft_m2"):
385
+ raise PrevalAbort(
386
+ "subject-sqft-m2-missing",
387
+ "subject.sqft_m2 is null — the EPC m² figure must be supplied alongside sqft.",
388
+ )
389
+
390
+ # subject.cover_hero — required, exists, ≥ 1200×800, not symlinked into another property's folder.
391
+ hero = subject.get("cover_hero")
392
+ if not hero:
393
+ raise PrevalAbort(
394
+ "subject-cover-hero-missing",
395
+ "subject.cover_hero is required for the cover page.",
396
+ )
397
+ if not hero.startswith(("http://", "https://", "data:")):
398
+ _assert_path_image(hero)
399
+
400
+ # brand — must be the resolved DESIGN.md object, not a stub.
401
+ brand = inputs.get("brand") or {}
402
+ required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
403
+ missing_brand = [k for k in required_brand if not brand.get(k)]
404
+ if missing_brand:
405
+ raise PrevalAbort(
406
+ "brand-unresolved",
407
+ f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
408
+ )
409
+
410
+ # agent_listings — non-empty.
411
+ if not inputs.get("agent_listings"):
412
+ raise PrevalAbort(
413
+ "agent-listings-empty",
414
+ "agent_listings is empty — paste at least one recent listing for the closing page.",
415
+ )
416
+
417
+ # market_summary.paragraphs — at least 2.
418
+ paras = (inputs.get("market_summary") or {}).get("paragraphs") or []
419
+ if len(paras) < 2:
420
+ raise PrevalAbort(
421
+ "market-summary-too-thin",
422
+ f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
423
+ )
424
+
425
+
426
+ # ---------------- render ----------------
427
+
131
428
  def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
429
+ _assert_inputs(inputs)
430
+
132
431
  template_path = template_path or (Path(__file__).parent / "template.html")
133
432
  tmpl = template_path.read_text()
134
433
 
135
- v = inputs["valuation"]
136
- a = inputs["area"]
137
- d = inputs["demand_trend"]
138
- r = d["rental"]
434
+ brand = inputs["brand"]
435
+ subject = inputs["subject"]
436
+ v = inputs["valuation"]
437
+ a = inputs["area"]
438
+ d = inputs["demand_trend"]
439
+ ms = inputs["market_summary"]
440
+ r = d["rental"]
441
+
442
+ out_dir.mkdir(parents=True, exist_ok=True)
443
+
444
+ subject_sqft, subject_sqft_sub = _kpi_subject_sqft(subject)
445
+ subject_epc, subject_epc_sub = _kpi_subject_epc(subject)
446
+
447
+ yoy = d.get("yoy_growth_pct")
448
+ if yoy is None and len(d.get("growth_series", [])) >= 2:
449
+ last, prev = d["growth_series"][-1]["price"], d["growth_series"][-2]["price"]
450
+ yoy = (last - prev) / prev * 100.0
451
+ yoy_str = f"{yoy:+.1f}%" if yoy is not None else "—"
452
+
453
+ psf = d.get("sales_per_month")
454
+ if psf is None:
455
+ psf = max(1, round(d["for_sale"] / 7)) # crude fallback only when not provided
139
456
 
140
457
  slots = {
458
+ # branding
459
+ "brand_name": brand["name"],
460
+ "brand_tagline": brand["tagline"],
461
+ "brand_primary": brand["primary"],
462
+ "brand_primary_dark": brand["primary_dark"],
463
+ "brand_accent": brand["accent"],
464
+ "brand_paper": brand["paper"],
465
+ "brand_paper_banded": brand["paper_banded"],
466
+ "brand_rule": brand["rule"],
467
+ "wordmark_light": _wordmark(brand, "light", out_dir),
468
+ "wordmark_dark": _wordmark(brand, "dark", out_dir),
469
+
470
+ # cover + header
471
+ "subject_hero_path": _hero_asset(subject, out_dir),
141
472
  "address": inputs["address"],
473
+ "address_short": _short_address(inputs["address"]),
142
474
  "postcode": inputs["postcode"],
143
475
  "generated": inputs["generated"],
144
476
 
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", [])),
477
+ # page 2 — subject valuation
478
+ "kpi_subject_sqft": subject_sqft,
479
+ "kpi_subject_sqft_sub": subject_sqft_sub,
480
+ "kpi_subject_beds": str(subject.get("beds") or "—"),
481
+ "kpi_subject_epc": subject_epc,
482
+ "kpi_subject_epc_sub": subject_epc_sub,
483
+ "kpi_subject_type": subject.get("type") or "—",
484
+ "kpi_subject_tenure": subject.get("tenure") or "Tenure unknown",
485
+ "valuation_block": _valuation_block(subject, v),
486
+ "subject_narrative": subject.get("narrative") or "",
487
+
488
+ # page 3 — market context
489
+ "summary_headline": ms["headline"],
490
+ "summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
491
+ "kpi_avg_asking": _money(v["avg_asking"]),
492
+ "kpi_avg_sold": _money(v["avg_sold"]),
493
+ "kpi_psf": _money(v["asking_psf"]),
494
+ "kpi_yoy": yoy_str,
495
+ "demand_rating": d["demand_rating"],
496
+ "gauge_position": ms["demand_gauge_position"],
497
+ "price_growth_note": ms["price_growth_note"],
498
+ "demand_note": ms["demand_note"],
499
+ "spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
500
+
501
+ # page 4 — comparables
502
+ "sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
503
+ "asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
504
+
505
+ # page 5 — agent listings + sources
506
+ "agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
172
507
  }
173
508
 
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"}
509
+ # Escape only the plain-text slots; HTML fragments pass through untouched.
510
+ text_slots = {
511
+ "address", "address_short", "postcode", "generated",
512
+ "brand_name", "brand_tagline",
513
+ "kpi_subject_beds", "kpi_subject_epc", "kpi_subject_epc_sub",
514
+ "kpi_subject_sqft", "kpi_subject_sqft_sub",
515
+ "kpi_subject_type", "kpi_subject_tenure",
516
+ "summary_headline", "demand_rating",
517
+ "price_growth_note", "demand_note", "subject_narrative",
518
+ }
177
519
  for k in text_slots:
178
- slots[k] = html.escape(slots[k])
520
+ slots[k] = html.escape(str(slots[k]))
179
521
 
180
522
  out = tmpl
181
523
  for k, val in slots.items():
@@ -187,7 +529,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
187
529
  "template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
188
530
  )
189
531
 
190
- out_dir.mkdir(parents=True, exist_ok=True)
191
532
  out_path = out_dir / "index.html"
192
533
  out_path.write_text(out)
193
534
  return out_path
@@ -212,11 +553,10 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
212
553
 
213
554
 
214
555
  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.
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.
220
560
  """
221
561
  pdfinfo = shutil.which("pdfinfo")
222
562
  if pdfinfo:
@@ -231,13 +571,6 @@ def _count_pages(pdf_path: Path) -> int:
231
571
 
232
572
 
233
573
  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
574
  n = _count_pages(pdf_path)
242
575
  if n != expected:
243
576
  raise RuntimeError(
@@ -247,16 +580,48 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
247
580
  return n
248
581
 
249
582
 
583
+ def _abort(cause: str, hint: str, *, address: str, postcode: str, t0: float, out_dir: Path | None) -> None:
584
+ """Emit the abort log line, scrub any partial output, exit non-zero."""
585
+ ms = int((time.monotonic() - t0) * 1000)
586
+ print(
587
+ f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
588
+ file=sys.stderr,
589
+ )
590
+ print(hint, file=sys.stderr)
591
+ if out_dir is not None:
592
+ for name in ("index.html",):
593
+ p = out_dir / name
594
+ if p.exists():
595
+ p.unlink()
596
+ # Any PDF whose stem matches filename_stem would be a partial — caller passes the stem.
597
+ sys.exit(2)
598
+
599
+
250
600
  def main():
601
+ t0 = time.monotonic()
251
602
  if len(sys.argv) < 2:
252
603
  print("Usage: render.py <inputs.json>", file=sys.stderr)
253
604
  sys.exit(2)
254
605
  inputs = json.loads(Path(sys.argv[1]).read_text())
255
606
  out_dir = Path(inputs["out_dir"]).expanduser()
256
607
  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)
608
+ address = inputs.get("address", "")
609
+ postcode = inputs.get("postcode", "")
610
+ try:
611
+ render(inputs, out_dir)
612
+ pdf = render_pdf(out_dir, f"{base}.pdf")
613
+ pages = assert_page_count(pdf, 5)
614
+ except PrevalAbort as e:
615
+ # Scrub partial PDF too, if rendered before page-count failure.
616
+ pdf_path = out_dir / f"{base}.pdf"
617
+ if pdf_path.exists():
618
+ pdf_path.unlink()
619
+ _abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0, out_dir=out_dir)
620
+ except RuntimeError as e:
621
+ pdf_path = out_dir / f"{base}.pdf"
622
+ if pdf_path.exists():
623
+ pdf_path.unlink()
624
+ _abort("render-error", str(e), address=address, postcode=postcode, t0=t0, out_dir=out_dir)
260
625
  print(f"HTML → {out_dir/'index.html'}")
261
626
  print(f"PDF → {pdf} ({pages} pages)")
262
627