@rubytech/create-maxy-code 0.1.59 → 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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/platform/plugins/docs/references/admin-session.md +2 -2
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/platform/plugins/epc/PLUGIN.md +85 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/platform/plugins/epc/mcp/package.json +21 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/platform/plugins/loop/PLUGIN.md +13 -1
- package/payload/platform/plugins/loop/mcp/dist/index.js +8 -7
- package/payload/platform/plugins/loop/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js +165 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
- package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js +11 -28
- package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/platform/plugins/loop/mcp/src/index.ts +10 -7
- package/payload/platform/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
- package/payload/platform/plugins/loop/mcp/src/lib/key-store.ts +221 -0
- package/payload/platform/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
- package/payload/platform/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
- package/payload/platform/plugins/loop/mcp/src/tools/key-register.ts +14 -39
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +23 -17
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -38
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +288 -81
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +184 -124
- package/payload/platform/plugins/property-data/PLUGIN.md +14 -1
- package/payload/platform/plugins/property-data/mcp/dist/index.js +14 -11
- package/payload/platform/plugins/property-data/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
- package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js +2 -2
- package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
- package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/platform/plugins/property-data/mcp/src/index.ts +16 -11
- package/payload/platform/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/platform/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
- package/payload/platform/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
- package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +2 -2
- package/payload/platform/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
- package/payload/platform/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/admin/SOUL.md +1 -1
- package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
- package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +13 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +8 -7
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js +165 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js +11 -28
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +10 -7
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/key-store.ts +221 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-register.ts +14 -39
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +23 -17
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -38
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +288 -81
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +184 -124
- package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +14 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +14 -11
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js +2 -2
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/index.ts +16 -11
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +2 -2
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
- package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
- package/payload/server/public/assets/{admin-nqCBFiSl.js → admin-DhN3G8W7.js} +51 -51
- package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
- package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
- package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
- package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +7 -19
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +0 -51
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +0 -1
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +0 -196
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +0 -1
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +0 -7
|
@@ -1,78 +1,118 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: property-preval
|
|
3
|
-
description: Use when an operator
|
|
3
|
+
description: Use when an operator wants a pre-valuation pack to pitch for a UK property listing — a 5-page A4 PDF that combines a market-data report for the postcode with the subject property's own valuation range, full-width sold + asking comparables, and the agent's recent successes as the sales close. Triggers include "preval <address>", "pre-valuation for <address>", "valuation pitch for <address>", "what's <address> worth and pitch us to list it", "pre-appraisal pack for <address>".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Property Preval
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
A 5-page A4 PDF that pitches the agent for the listing: cover (full-bleed subject hero), subject valuation (sqft × £/sqft band with operator adjustments), market context, full-width comparables (sold + asking stacked), and the agent's recent successes + disclaimer. **Preval is a sales pitch, not just a data sheet** — every page either tells the vendor what the property is worth or what the agent has recently delivered nearby.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Typography is fixed; only colours and logos come from the agent
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Same typographic lock as `property-market-report`:
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
14
|
+
- **Display** — Cormorant Garamond (italic for headlines, KPI values, prices, addresses)
|
|
15
|
+
- **Body** — Lora
|
|
16
|
+
- **Sans / labels** — Inter (eyebrows, KPI labels, chips, footer meta)
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
Never substitute the agent's body or display fonts even if their `DESIGN.md` specifies different families. The pack reads as an editorial / luxury document across every brand. The agent's identity comes through the **palette and logo only**.
|
|
18
19
|
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
-
A directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
|
|
20
|
+
## Inputs (never invented)
|
|
22
21
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
- **`address`** — full UK address ending in a valid postcode (e.g. `9 Phoenix Drive, Bishop's Stortford, CM23 2UJ`). Skill derives the postcode by matching the UK postcode regex on the final token group; stops if no match.
|
|
23
|
+
- **`brand`** — agent design tokens, same shape as property-market-report: `name`, `tagline`, hex tokens `primary` / `primary_dark` / `accent` / `paper` / `paper_banded` / `rule`. Optional `logo_light` / `logo_dark` filesystem paths. Resolved from the agent's `DESIGN.md` (e.g. `/estate-agents/muvin/DESIGN.md`); read the canonical hex values directly, never substitute "close" colours. Only colour tokens are consumed; typography/spacing/radius tokens in the agent's `DESIGN.md` are ignored by design.
|
|
24
|
+
- **`subject`** — paste-in details about the property being valued:
|
|
25
|
+
- `cover_hero` (filesystem path or URL to a photo of the subject — required for the cover).
|
|
26
|
+
- Optional `beds`, `type`, `tenure`, `condition` (well / average / needs work).
|
|
27
|
+
- `adjustments[]` — operator-curated array of `{label, delta_pct}` covering esoteric factors that shift the headline range (e.g. `{"label":"South-facing garden","delta_pct":3}`, `{"label":"Needs full reno","delta_pct":-8}`). Deltas sum linearly into the final range.
|
|
28
|
+
- `narrative` — one short paragraph the operator writes about the property.
|
|
29
|
+
- **Floor area is auto-pulled from the EPC register** via `real-agent-epc` (`epc-lookup-by-address`) and surfaced as `sqft_m2` + derived `sqft`. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), operator pastes `subject.sqft` directly. Without a floor area the renderer prints "Estimate requires floor area" and shows only the £/sqft benchmark — never invents a number.
|
|
30
|
+
- **`agent_listings`** — the agent's recent sold / under-offer / live listings to feature on the closing page. Up to 6 entries of `{url, address, price, beds, type, image, status, slug}` where `status ∈ {"Sold","Under offer","For sale"}`. Operator picks the comps that make the strongest pitch; this is *not* the PropertyData sample.
|
|
31
|
+
- **`market_summary`** — `headline` (one display-italic line), `paragraphs[]` (2–3 short paragraphs), `demand_gauge_position` (percentage string like `"62%"`), `price_growth_note`, `demand_note`. The operator drafts or accepts an LLM stub.
|
|
27
32
|
|
|
28
|
-
## Tools the skill calls
|
|
33
|
+
## Tools the skill calls
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
Parallel fan-out keyed on the derived postcode + the subject address:
|
|
31
36
|
|
|
37
|
+
**PropertyData (12 calls — credit cost 12):**
|
|
32
38
|
1. `property-data-prices` — asking £ distribution + raw asking comps.
|
|
33
39
|
2. `property-data-prices-per-sqf` — asking £/sqft.
|
|
34
40
|
3. `property-data-sold-prices` — Land Registry sold transactions + raw sold comps.
|
|
35
|
-
4. `property-data-sold-prices-per-sqf` — achieved £/sqft.
|
|
41
|
+
4. `property-data-sold-prices-per-sqf` — achieved £/sqft (plus 70pc band used for the valuation range).
|
|
36
42
|
5. `property-data-growth` — 7y price series.
|
|
37
43
|
6. `property-data-growth-psf` — 7y £/sqft series.
|
|
38
44
|
7. `property-data-demand` — for-sale, sales/mo, turnover, MoI, DoM, demand rating.
|
|
39
45
|
8. `property-data-demand-rent` — rental-side demand pillar.
|
|
40
|
-
9. `property-data-property-types` — mix
|
|
46
|
+
9. `property-data-property-types` — mix (detached / semi / terraced / flat).
|
|
41
47
|
10. `property-data-crime` — area crime rating + breakdown.
|
|
42
48
|
11. `property-data-flood-risk` — area flood risk (full postcode only).
|
|
43
|
-
12. `property-data-council-tax` — band D
|
|
49
|
+
12. `property-data-council-tax` — band D + per-property bands (full postcode only).
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
**EPC (1 call — free, rate-limited):**
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
- `property-data-planning-applications` — planning is a separate dev-precedent report.
|
|
53
|
+
13. `epc-lookup-by-address(address, postcode)` — subject floor area + energy ratings + lodgement date.
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
`address-match-uprn` and `planning-applications` are deliberately omitted (CRM hygiene and dev-precedent reports respectively).
|
|
56
|
+
|
|
57
|
+
## Output
|
|
58
|
+
|
|
59
|
+
Directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
|
|
60
|
+
|
|
61
|
+
- `index.html` — single self-contained file.
|
|
62
|
+
- `img/` — copied logos and the subject hero image.
|
|
63
|
+
- `<slug>-<date>.pdf` — exactly **5** A4 pages. Renderer asserts via `pdfinfo` and raises if ≠ 5.
|
|
64
|
+
|
|
65
|
+
Return the PDF path to the operator.
|
|
66
|
+
|
|
67
|
+
## Pages
|
|
68
|
+
|
|
69
|
+
1. **Cover** — full-bleed `subject.cover_hero` with vertical gradient scrim; light logo top-left; eyebrow "`<brand.name>` · Pre-valuation"; address in display-italic; postcode chip; four-up meta strip (Report · Address · Postcode · Generated). Lifted from property-market-report's cover contract.
|
|
70
|
+
2. **Subject valuation** — KPI strip (sqft, beds, EPC rating, type). Centre panel: headline range = `sqft × sold_psf_low–high` (Land Registry 70pc band) with `sqft × asking_psf` shown as a cross-check. Operator `adjustments[]` displayed as a stacked list (label · delta% · running £). Final range pill at the foot. If `sqft` missing the panel switches to the "requires floor area" mode (£/sqft benchmark only). One paragraph of `subject.narrative` under the panel.
|
|
71
|
+
3. **Market context** — KPI cube (avg asking, avg sold, £/sqft, YoY); pill + gauge for demand; 7y growth sparkline (£ solid + £/sqft dashed); summary paragraphs. Same patterns as property-market-report's page 2.
|
|
72
|
+
4. **Comparables (full-width, stacked)** — Nearest sold table (top 6) full-width across the page; Live asking tile grid (top 4) full-width below. Vertically stacked, never side-by-side — corrects the cramped two-column layout in the v1 preval where the asking tiles got pushed to ~40mm columns of italic addresses.
|
|
73
|
+
5. **Recently with `<brand.name>` + sources** — agent's own listings as a clickable tile grid (`<a>` per card, status badge: Sold / Under offer / For sale), then the sources / methodology / accuracy / investment block. Closes with `Real Agent · Generated <date> · <postcode>` in the footer.
|
|
51
74
|
|
|
52
75
|
## How to execute
|
|
53
76
|
|
|
54
|
-
1. Match the postcode out of the address (`/[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}/i`). Stop
|
|
55
|
-
2. Run the
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
1. Match the postcode out of the address (`/[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}/i`). Stop if no match.
|
|
78
|
+
2. Run the 13 fan-out tools (12 PropertyData + 1 EPC) in parallel. Failure handling differs by source:
|
|
79
|
+
- **PropertyData `key-not-registered`** → abort with the `property-data-key-register` prompt. The pack cannot render without market data.
|
|
80
|
+
- **PropertyData `http-429`** → back off, retry only the throttled tool. Do not re-run the whole fan-out.
|
|
81
|
+
- **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-500`, network failure, or MCP not configured on the account — is treated as a single class: **prompt the operator** with the relevant cause (e.g. "EPC API key not registered — register with `epc-key-register`, or paste subject.sqft to continue"; "No EPC on file for this address — paste subject.sqft to continue"; "EPC API unavailable — paste subject.sqft to continue"). If the operator pastes `subject.sqft`, the pack renders normally with the EPC-derived KPI cells switching to "—" + "No EPC on file" / "EPC API unavailable" sub-labels. If the operator declines to paste, the valuation panel switches to the "Estimate requires floor area" benchmark-only mode and the pack still renders the other four pages.
|
|
82
|
+
3. Compute valuation range:
|
|
83
|
+
- `sold_psf_low` = `sold-prices-per-sqf` `70pc_range[0]`
|
|
84
|
+
- `sold_psf_high` = `sold-prices-per-sqf` `70pc_range[1]`
|
|
85
|
+
- `low = sqft × sold_psf_low`, `mid = sqft × sold_psf`, `high = sqft × sold_psf_high`
|
|
86
|
+
- Apply `adjustments` linearly: `factor = 1 + Σ delta_pct / 100`. Final range = `[low × factor, high × factor]`, midpoint = `mid × factor`.
|
|
87
|
+
4. Assemble the `inputs.json` shape per `references/template-inputs.schema.json`.
|
|
88
|
+
5. `python3 references/render.py inputs.json`. Renderer fills the template, copies logos + hero into `img/`, drives Chrome `--print-to-pdf`, asserts page count == 5.
|
|
89
|
+
6. Return the PDF path.
|
|
59
90
|
|
|
60
|
-
##
|
|
91
|
+
## Adjustments — linear vs compound
|
|
61
92
|
|
|
62
|
-
|
|
63
|
-
|
|
93
|
+
Deltas sum linearly (`-8 + 3 = -5%`, applied as `× 0.95`). Compound interpretation (`× 0.92 × 1.03`) would give a slightly different number (-5.24%) but the operator presents the figures verbally as a sum, so the displayed maths must match the verbal claim. If a future operator wants compound adjustments, this is a deliberate skill-level rewrite, not a config switch.
|
|
94
|
+
|
|
95
|
+
## Observability
|
|
64
96
|
|
|
65
|
-
|
|
97
|
+
- `[preval] start address="<addr>" postcode=<pc>` at run start.
|
|
98
|
+
- `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
|
|
99
|
+
- Each fan-out call emits its own `[property-data] tool=<name>` or `[epc] tool=<name>` line in `server.log` (inherited contracts; no new instrumentation here).
|
|
66
100
|
|
|
67
101
|
## Failure signatures
|
|
68
102
|
|
|
69
|
-
- `pages=
|
|
103
|
+
- `pages=6` (or ≠ 5) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>` to see which page bled, then trim that panel's padding only.
|
|
70
104
|
- `start` with no matching `done` within 60s → renderer crashed. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
|
|
71
|
-
-
|
|
105
|
+
- `[epc] … status=http-404` → property has no EPC; prompt "no EPC on file for this address — paste subject.sqft to continue".
|
|
106
|
+
- `[epc] … status=key-not-registered` → prompt "EPC API key not registered — run `epc-key-register`, or paste subject.sqft to continue".
|
|
107
|
+
- `[epc] … status=http-429` | `http-5xx` | `bad-response` | `network-error` → prompt "EPC API unavailable — paste subject.sqft to continue". The MCP-not-installed case (no `[epc] …` line at all and the tool call surfaces as "tool not found") is the same prompt with cause "EPC MCP not configured on this account".
|
|
108
|
+
- `[property-data] … status=http-429` → 4-calls-in-10-seconds throttle. Retry only the throttled tool after the back-off; do not re-run the whole fan-out.
|
|
72
109
|
|
|
73
110
|
## Out of scope
|
|
74
111
|
|
|
75
|
-
- Multi-address batch preval (CSV in → many PDFs out)
|
|
76
|
-
- Loop CRM auto-publish of the PDF onto a contact's record
|
|
77
|
-
-
|
|
78
|
-
- Caching of MCP responses
|
|
112
|
+
- Multi-address batch preval (CSV in → many PDFs out).
|
|
113
|
+
- Loop CRM auto-publish of the PDF onto a contact's record.
|
|
114
|
+
- Pulling `agent_listings` from Loop CRM automatically — operator paste-in for now; CRM wire-up is a separate task.
|
|
115
|
+
- Caching of MCP responses (13 credits per pack is acceptable).
|
|
116
|
+
- A separate cover letter / pitch letter — the cover page *is* the pitch.
|
|
117
|
+
- Compound adjustments (linear sum is the contract; see above).
|
|
118
|
+
- Subject sqft from sources other than EPC and the operator paste-in fallback (e.g. Loop CRM floor plans, Rightmove sqft scrape) — separate task if pursued.
|
|
@@ -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
|
|
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
|
|
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"> </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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
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
|
|
175
|
-
text_slots = {
|
|
176
|
-
|
|
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
|
-
"""
|
|
216
|
-
raw
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
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
|
|