@rubytech/create-maxy-code 0.1.60 → 0.1.61

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 +23 -17
  70. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -38
  71. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +288 -81
  72. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
  73. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +184 -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 +23 -17
  150. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -38
  151. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +288 -81
  152. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
  153. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +184 -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,7 +2,7 @@
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
6
 
7
7
  Usage:
8
8
  python3 render.py inputs.json
@@ -12,10 +12,25 @@ import html, json, re, shutil, subprocess, sys
12
12
  from pathlib import Path
13
13
 
14
14
 
15
+ M2_TO_SQFT = 10.7639
16
+
17
+
15
18
  def _money(n: int) -> str:
16
19
  return f"£{int(n):,}"
17
20
 
18
21
 
22
+ def _range_money(low: int, high: int) -> str:
23
+ return f"£{int(low):,} – £{int(high):,}"
24
+
25
+
26
+ # ---------------- helpers ----------------
27
+
28
+ def _short_address(address: str) -> str:
29
+ """First two address tokens for the cover meta strip."""
30
+ parts = [p.strip() for p in address.split(",")]
31
+ return ", ".join(parts[:2]) if len(parts) >= 2 else address
32
+
33
+
19
34
  def _sold_comps_rows(rows: list[dict]) -> str:
20
35
  if not rows:
21
36
  return '<tr><td colspan="4" class="muted">No sold transactions in the sample.</td></tr>'
@@ -49,31 +64,6 @@ def _asking_comps_tiles(rows: list[dict]) -> str:
49
64
  return "".join(out)
50
65
 
51
66
 
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
67
  def _type_dist_rows(rows: list[dict]) -> str:
78
68
  if not rows:
79
69
  return '<div class="muted">No property-type mix data.</div>'
@@ -89,7 +79,7 @@ def _type_dist_rows(rows: list[dict]) -> str:
89
79
 
90
80
 
91
81
  def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
92
- """Two-line sparkline in a 1000x380 viewBox SVG, no external dependencies."""
82
+ """Two-line sparkline in a 1000x380 viewBox SVG, no external deps."""
93
83
  W, H = 1000, 380
94
84
  pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
95
85
  iw, ih = W - pad_l - pad_r, H - pad_t - pad_b
@@ -102,9 +92,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
102
92
  if ymax == ymin:
103
93
  ymax = ymin + 1
104
94
  n = len(series)
105
- pts = []
106
- circles = []
107
- labels = []
95
+ pts, circles, labels = [], [], []
108
96
  for i, p in enumerate(series):
109
97
  x = pad_l + (i * iw / max(1, n - 1))
110
98
  y = pad_t + ih - ((float(p[key]) - ymin) / (ymax - ymin)) * ih
@@ -115,8 +103,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
115
103
  )
116
104
  return (
117
105
  f'<polyline class="{css_pl}" points="{" ".join(pts)}"/>'
118
- + "".join(circles)
119
- + "".join(labels)
106
+ + "".join(circles) + "".join(labels)
120
107
  )
121
108
 
122
109
  axis = (
@@ -128,54 +115,283 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
128
115
  )
129
116
 
130
117
 
118
+ def _summary_paragraphs(paras: list[str]) -> str:
119
+ if not paras:
120
+ return ""
121
+ out = [f'<p class="dropcap">{html.escape(paras[0])}</p>']
122
+ for p in paras[1:]:
123
+ out.append(f"<p>{html.escape(p)}</p>")
124
+ return "".join(out)
125
+
126
+
127
+ def _wordmark(brand: dict, variant: str, out_dir: Path) -> str:
128
+ """variant ∈ {'light', 'dark'}. Mirrors property-market-report's wordmark."""
129
+ key = "logo_light" if variant == "light" else "logo_dark"
130
+ logo_path = brand.get(key)
131
+ if logo_path:
132
+ src = Path(logo_path).expanduser()
133
+ dest_dir = out_dir / "img"
134
+ dest_dir.mkdir(parents=True, exist_ok=True)
135
+ dest = dest_dir / src.name
136
+ if src.resolve() != dest.resolve():
137
+ shutil.copy2(src, dest)
138
+ rel = f"img/{src.name}"
139
+ height = "14mm" if variant == "light" else "9mm"
140
+ return f'<img src="{html.escape(rel)}" alt="{html.escape(brand["name"])}" style="height:{height};width:auto;display:block">'
141
+ cls = "wm light" if variant == "light" else "wm small"
142
+ return (
143
+ f'<div class="{cls}">'
144
+ f'<span class="wm-name">{html.escape(brand["name"])}</span>'
145
+ f'<span class="wm-sub">{html.escape(brand["tagline"])}</span>'
146
+ f"</div>"
147
+ )
148
+
149
+
150
+ def _hero_asset(subject: dict, out_dir: Path) -> str:
151
+ """Copy subject.cover_hero into img/ and return the relative path. URLs pass through."""
152
+ hero = subject["cover_hero"]
153
+ if hero.startswith(("http://", "https://", "data:")):
154
+ return hero
155
+ src = Path(hero).expanduser()
156
+ if not src.exists():
157
+ raise FileNotFoundError(f"subject.cover_hero not found: {src}")
158
+ dest_dir = out_dir / "img"
159
+ dest_dir.mkdir(parents=True, exist_ok=True)
160
+ dest = dest_dir / f"hero{src.suffix.lower()}"
161
+ if src.resolve() != dest.resolve():
162
+ shutil.copy2(src, dest)
163
+ return f"img/{dest.name}"
164
+
165
+
166
+ def _listing_card(p: dict, index: int, out_dir: Path) -> str:
167
+ addr_parts = [x.strip() for x in p["address"].split(",")]
168
+ addr = html.escape(addr_parts[0])
169
+ chips = []
170
+ if p.get("beds"): chips.append(f"{p['beds']} bed")
171
+ if p.get("type"): chips.append(html.escape(p["type"]))
172
+ chips_html = "".join(f"<span>{c}</span>" for c in chips)
173
+ img_src = ""
174
+ if p.get("image"):
175
+ src = Path(p["image"]).expanduser() if not str(p["image"]).startswith(("http://", "https://", "data:")) else None
176
+ if src and src.exists():
177
+ dest_dir = out_dir / "img"
178
+ dest_dir.mkdir(parents=True, exist_ok=True)
179
+ dest = dest_dir / f"agent-{index:02d}-{p['slug']}{src.suffix.lower()}"
180
+ if src.resolve() != dest.resolve():
181
+ shutil.copy2(src, dest)
182
+ img_src = f"img/{dest.name}"
183
+ elif src is None:
184
+ img_src = str(p["image"])
185
+ status = p.get("status", "")
186
+ badge_cls = {"Sold": "sold", "Under offer": "under"}.get(status, "")
187
+ photo = (
188
+ f'<div class="photo">'
189
+ f'{f"<img src=\"{html.escape(img_src)}\" alt=\"{addr}\">" if img_src else ""}'
190
+ f'<div class="badge {badge_cls}">{html.escape(status)}</div>'
191
+ f"</div>"
192
+ )
193
+ meta = (
194
+ f'<div class="meta">'
195
+ f'<div class="addr">{addr}</div>'
196
+ f'<div class="price">{_money(p["price"])}</div>'
197
+ f'<div class="kfs">{chips_html}</div>'
198
+ f"</div>"
199
+ )
200
+ inner = photo + meta
201
+ if p.get("url"):
202
+ return f'<a class="listing" href="{html.escape(p["url"])}" target="_blank" rel="noopener">{inner}</a>'
203
+ return f'<div class="listing">{inner}</div>'
204
+
205
+
206
+ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
207
+ if not rows:
208
+ return '<div class="muted" style="margin-top:5mm">No recent listings supplied.</div>'
209
+ return "".join(_listing_card(p, i, out_dir) for i, p in enumerate(rows[:6]))
210
+
211
+
212
+ # ---------------- valuation maths ----------------
213
+
214
+ def _valuation_block(subject: dict, valuation: dict) -> str:
215
+ """Build the page-2 valuation panel: headline range, adjustments, final range.
216
+
217
+ Falls back to the 'requires floor area' state when subject.sqft is missing.
218
+ """
219
+ sqft = subject.get("sqft")
220
+ if not sqft:
221
+ return (
222
+ '<div class="val-panel"><div class="val-empty">'
223
+ "Estimate requires floor area.<br>"
224
+ "<span style=\"font-size:10pt;color:var(--text-muted)\">"
225
+ f"Benchmark: sold £/sqft = {_money(valuation['sold_psf'])}, "
226
+ f"asking £/sqft = {_money(valuation['asking_psf'])}. "
227
+ "Paste subject.sqft to compute the range."
228
+ "</span></div></div>"
229
+ )
230
+
231
+ low_psf = valuation["sold_psf_low"]
232
+ mid_psf = valuation["sold_psf"]
233
+ high_psf = valuation["sold_psf_high"]
234
+ ask_psf = valuation["asking_psf"]
235
+
236
+ low_raw = int(sqft * low_psf)
237
+ mid_raw = int(sqft * mid_psf)
238
+ high_raw = int(sqft * high_psf)
239
+ ask_raw = int(sqft * ask_psf)
240
+
241
+ rows = [
242
+ ('<div class="val-row headline">'
243
+ f'<div class="lbl">Headline range<small>{sqft:,} sqft × £{low_psf}–£{high_psf} sold £/sqft (Land Registry 70pc band)</small></div>'
244
+ f'<div class="num">{_range_money(low_raw, high_raw)}</div></div>'),
245
+ ('<div class="val-row">'
246
+ f'<div class="lbl">Cross-check · asking £/sqft<small>{sqft:,} sqft × £{ask_psf} mean asking £/sqft</small></div>'
247
+ f'<div class="num">{_money(ask_raw)}</div></div>'),
248
+ ]
249
+
250
+ adjustments = subject.get("adjustments") or []
251
+ if adjustments:
252
+ 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>')
253
+ for adj in adjustments:
254
+ delta = float(adj["delta_pct"])
255
+ cls = "pos" if delta > 0 else ("neg" if delta < 0 else "")
256
+ sign = "+" if delta >= 0 else ""
257
+ rows.append(
258
+ '<div class="val-row adj">'
259
+ f'<div class="lbl">{html.escape(adj["label"])}</div>'
260
+ f'<div class="num {cls}">{sign}{delta:.1f}%</div>'
261
+ "</div>"
262
+ )
263
+
264
+ delta_sum = sum(float(a["delta_pct"]) for a in adjustments)
265
+ factor = 1.0 + delta_sum / 100.0
266
+ low_final = int(low_raw * factor)
267
+ high_final = int(high_raw * factor)
268
+
269
+ final = (
270
+ '<div class="val-final">'
271
+ f'<div class="lbl">Final indicative range</div>'
272
+ f'<div class="num">{_range_money(low_final, high_final)}</div>'
273
+ "</div>"
274
+ )
275
+
276
+ return '<div class="val-panel">' + "".join(rows) + "</div>" + final
277
+
278
+
279
+ def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
280
+ sqft = subject.get("sqft")
281
+ m2 = subject.get("sqft_m2")
282
+ if sqft:
283
+ sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
284
+ return f"{int(sqft):,}", sub
285
+ return "—", "No EPC on file"
286
+
287
+
288
+ def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
289
+ current = subject.get("epc_current")
290
+ potential = subject.get("epc_potential")
291
+ lodgement = subject.get("epc_lodgement")
292
+ if not current:
293
+ return "—", "No EPC on file"
294
+ sub_bits = []
295
+ if potential:
296
+ sub_bits.append(f"potential {potential}")
297
+ if lodgement:
298
+ sub_bits.append(f"lodged {lodgement}")
299
+ return current, " · ".join(sub_bits) or "EPC register"
300
+
301
+
302
+ # ---------------- render ----------------
303
+
131
304
  def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
132
305
  template_path = template_path or (Path(__file__).parent / "template.html")
133
306
  tmpl = template_path.read_text()
134
307
 
135
- v = inputs["valuation"]
136
- a = inputs["area"]
137
- d = inputs["demand_trend"]
138
- r = d["rental"]
308
+ brand = inputs["brand"]
309
+ subject = inputs["subject"]
310
+ v = inputs["valuation"]
311
+ a = inputs["area"]
312
+ d = inputs["demand_trend"]
313
+ ms = inputs["market_summary"]
314
+ r = d["rental"]
315
+
316
+ out_dir.mkdir(parents=True, exist_ok=True)
317
+
318
+ subject_sqft, subject_sqft_sub = _kpi_subject_sqft(subject)
319
+ subject_epc, subject_epc_sub = _kpi_subject_epc(subject)
320
+
321
+ yoy = d.get("yoy_growth_pct")
322
+ if yoy is None and len(d.get("growth_series", [])) >= 2:
323
+ last, prev = d["growth_series"][-1]["price"], d["growth_series"][-2]["price"]
324
+ yoy = (last - prev) / prev * 100.0
325
+ yoy_str = f"{yoy:+.1f}%" if yoy is not None else "—"
326
+
327
+ psf = d.get("sales_per_month")
328
+ if psf is None:
329
+ psf = max(1, round(d["for_sale"] / 7)) # crude fallback only when not provided
139
330
 
140
331
  slots = {
332
+ # branding
333
+ "brand_name": brand["name"],
334
+ "brand_tagline": brand["tagline"],
335
+ "brand_primary": brand["primary"],
336
+ "brand_primary_dark": brand["primary_dark"],
337
+ "brand_accent": brand["accent"],
338
+ "brand_paper": brand["paper"],
339
+ "brand_paper_banded": brand["paper_banded"],
340
+ "brand_rule": brand["rule"],
341
+ "wordmark_light": _wordmark(brand, "light", out_dir),
342
+ "wordmark_dark": _wordmark(brand, "dark", out_dir),
343
+
344
+ # cover + header
345
+ "subject_hero_path": _hero_asset(subject, out_dir),
141
346
  "address": inputs["address"],
347
+ "address_short": _short_address(inputs["address"]),
142
348
  "postcode": inputs["postcode"],
143
349
  "generated": inputs["generated"],
144
350
 
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", [])),
351
+ # page 2 — subject valuation
352
+ "kpi_subject_sqft": subject_sqft,
353
+ "kpi_subject_sqft_sub": subject_sqft_sub,
354
+ "kpi_subject_beds": str(subject.get("beds") or "—"),
355
+ "kpi_subject_epc": subject_epc,
356
+ "kpi_subject_epc_sub": subject_epc_sub,
357
+ "kpi_subject_type": subject.get("type") or "—",
358
+ "kpi_subject_tenure": subject.get("tenure") or "Tenure unknown",
359
+ "valuation_block": _valuation_block(subject, v),
360
+ "subject_narrative": subject.get("narrative") or "",
361
+
362
+ # page 3 — market context
363
+ "summary_headline": ms["headline"],
364
+ "summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
365
+ "kpi_avg_asking": _money(v["avg_asking"]),
366
+ "kpi_avg_sold": _money(v["avg_sold"]),
367
+ "kpi_psf": _money(v["asking_psf"]),
368
+ "kpi_yoy": yoy_str,
369
+ "demand_rating": d["demand_rating"],
370
+ "gauge_position": ms["demand_gauge_position"],
371
+ "price_growth_note": ms["price_growth_note"],
372
+ "demand_note": ms["demand_note"],
373
+ "spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
374
+
375
+ # page 4 — comparables
376
+ "sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
377
+ "asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
378
+
379
+ # page 5 — agent listings + sources
380
+ "agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
172
381
  }
173
382
 
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"}
383
+ # Escape only the plain-text slots; HTML fragments pass through untouched.
384
+ text_slots = {
385
+ "address", "address_short", "postcode", "generated",
386
+ "brand_name", "brand_tagline",
387
+ "kpi_subject_beds", "kpi_subject_epc", "kpi_subject_epc_sub",
388
+ "kpi_subject_sqft", "kpi_subject_sqft_sub",
389
+ "kpi_subject_type", "kpi_subject_tenure",
390
+ "summary_headline", "demand_rating",
391
+ "price_growth_note", "demand_note", "subject_narrative",
392
+ }
177
393
  for k in text_slots:
178
- slots[k] = html.escape(slots[k])
394
+ slots[k] = html.escape(str(slots[k]))
179
395
 
180
396
  out = tmpl
181
397
  for k, val in slots.items():
@@ -187,7 +403,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
187
403
  "template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
188
404
  )
189
405
 
190
- out_dir.mkdir(parents=True, exist_ok=True)
191
406
  out_path = out_dir / "index.html"
192
407
  out_path.write_text(out)
193
408
  return out_path
@@ -212,11 +427,10 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
212
427
 
213
428
 
214
429
  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.
430
+ """pdfinfo first (handles /ObjStm-compressed page-object streams), with a
431
+ raw /Type /Page byte scan as fallback. The fallback is correct for current
432
+ Chrome --print-to-pdf output but would undercount if Chrome ever switches
433
+ to compressed page-object streams hence pdfinfo first when present.
220
434
  """
221
435
  pdfinfo = shutil.which("pdfinfo")
222
436
  if pdfinfo:
@@ -231,13 +445,6 @@ def _count_pages(pdf_path: Path) -> int:
231
445
 
232
446
 
233
447
  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
448
  n = _count_pages(pdf_path)
242
449
  if n != expected:
243
450
  raise RuntimeError(
@@ -256,7 +463,7 @@ def main():
256
463
  base = inputs["filename_stem"]
257
464
  render(inputs, out_dir)
258
465
  pdf = render_pdf(out_dir, f"{base}.pdf")
259
- pages = assert_page_count(pdf, 4)
466
+ pages = assert_page_count(pdf, 5)
260
467
  print(f"HTML → {out_dir/'index.html'}")
261
468
  print(f"PDF → {pdf} ({pages} pages)")
262
469