From 761bd2f0839b3b2668638c04b42a8450e087389a Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Sat, 28 Mar 2026 00:34:28 -0500 Subject: [PATCH] Block preliminary property assessor PDFs --- docs/property-assessor.md | 3 ++- skills/property-assessor/SKILL.md | 2 ++ skills/property-assessor/src/assessment.ts | 20 ++++++++++++++++++- skills/property-assessor/src/report-pdf.ts | 13 ++++++++++++ .../tests/assessment.test.ts | 16 ++++++++++----- .../tests/report-pdf.test.ts | 19 ++++++++++++++++++ 6 files changed, 66 insertions(+), 7 deletions(-) diff --git a/docs/property-assessor.md b/docs/property-assessor.md index 78d1f46..3381f7f 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -78,7 +78,8 @@ Current behavior: - returns a structured preliminary report payload - does not require recipient email(s) for the analysis-only run - asks for recipient email(s) only when PDF rendering is explicitly requested -- renders the fixed-template PDF when recipient email(s) are present +- does not render/send the PDF from a preliminary helper payload with `decision: pending` +- only renders the fixed-template PDF after a decision-grade verdict and fair-value range are actually present Important limitation: diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index bcddb5c..e343d49 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -96,6 +96,8 @@ scripts/property-assessor render-report --input "" --output - complete the analysis without requiring recipient email(s) - only stop and ask for recipient email(s) when the user is explicitly rendering or sending the PDF - render the PDF only after recipient email(s) are known +- do **not** render or send a PDF from the helper's preliminary payload while verdict is still `pending` or fair value is not established +- if comps, valuation, or decision-grade condition interpretation are still incomplete, return the preliminary payload and say that the PDF/send step must wait ## Public-record enrichment diff --git a/skills/property-assessor/src/assessment.ts b/skills/property-assessor/src/assessment.ts index 295b96e..efbc4ee 100644 --- a/skills/property-assessor/src/assessment.ts +++ b/skills/property-assessor/src/assessment.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { discoverListingSources, type ListingDiscoveryResult } from "./listing-discovery.js"; import { extractPhotoData, type PhotoExtractionResult, type PhotoSource } from "./photo-review.js"; import { resolvePublicRecords, type PublicRecordsResolution } from "./public-records.js"; -import { renderReportPdf, type ReportPayload } from "./report-pdf.js"; +import { + isDecisionGradeReportPayload, + renderReportPdf, + type ReportPayload +} from "./report-pdf.js"; export interface AssessPropertyOptions { address: string; @@ -442,6 +446,20 @@ export async function assessProperty( }; } + if (!isDecisionGradeReportPayload(reportPayload)) { + return { + ok: true, + needsAssessmentPurpose: false, + needsRecipientEmails: false, + pdfReady: false, + message: + "The report payload is still preliminary. Do not render or send the PDF until comps, valuation, and a decision-grade verdict are completed.", + outputPath: null, + reportPayload, + publicRecords + }; + } + const outputPath = options.output || path.join( diff --git a/skills/property-assessor/src/report-pdf.ts b/skills/property-assessor/src/report-pdf.ts index d26f113..5573beb 100644 --- a/skills/property-assessor/src/report-pdf.ts +++ b/skills/property-assessor/src/report-pdf.ts @@ -25,6 +25,14 @@ export interface ReportPayload { sourceLinks?: unknown; } +export function isDecisionGradeReportPayload(payload: ReportPayload): boolean { + const decision = String(payload.verdict?.decision || "").trim().toLowerCase(); + const fairValueRange = String(payload.verdict?.fairValueRange || "").trim().toLowerCase(); + if (!decision || decision === "pending") return false; + if (!fairValueRange || fairValueRange === "not established") return false; + return true; +} + function asStringArray(value: unknown): string[] { if (value == null) return []; if (typeof value === "string") { @@ -69,6 +77,11 @@ export function validateReportPayload(payload: ReportPayload): string[] { if (!address) { throw new ReportValidationError("The report payload must include subjectProperty.address."); } + if (!isDecisionGradeReportPayload(payload)) { + throw new ReportValidationError( + "The report payload is still preliminary. Stop and complete the decision-grade analysis before generating or sending the property assessment PDF." + ); + } return recipients; } diff --git a/skills/property-assessor/tests/assessment.test.ts b/skills/property-assessor/tests/assessment.test.ts index 4903200..5fc169c 100644 --- a/skills/property-assessor/tests/assessment.test.ts +++ b/skills/property-assessor/tests/assessment.test.ts @@ -198,8 +198,9 @@ test("assessProperty falls back to HAR when Zillow photo extraction fails", asyn ); }); -test("assessProperty renders a PDF when recipient email is present", async () => { +test("assessProperty does not render a PDF from a preliminary helper payload even when recipient email is present", async () => { const outputPath = path.join(os.tmpdir(), `property-assess-command-${Date.now()}.pdf`); + let renderCalls = 0; const result = await assessProperty( { address: "4141 Whiteley Dr, Corpus Christi, TX 78418", @@ -213,16 +214,21 @@ test("assessProperty renders a PDF when recipient email is present", async () => attempts: ["No listing sources discovered from the address."], zillowUrl: null, harUrl: null - }) + }), + renderReportPdfFn: async () => { + renderCalls += 1; + return outputPath; + } } ); assert.equal(result.ok, true); assert.equal(result.needsAssessmentPurpose, false); assert.equal(result.needsRecipientEmails, false); - assert.equal(result.outputPath, outputPath); - const stat = await fs.promises.stat(outputPath); - assert.ok(stat.size > 1000); + assert.equal(renderCalls, 0); + assert.equal(result.pdfReady, false); + assert.equal(result.outputPath, null); + assert.match(result.message, /preliminary|decision-grade|cannot render/i); }); test("assessProperty prioritizes student housing guidance over investment fallback keywords", async () => { diff --git a/skills/property-assessor/tests/report-pdf.test.ts b/skills/property-assessor/tests/report-pdf.test.ts index 6d06b8c..45d3b13 100644 --- a/skills/property-assessor/tests/report-pdf.test.ts +++ b/skills/property-assessor/tests/report-pdf.test.ts @@ -68,3 +68,22 @@ test("renderReportPdf requires recipient email", async () => { ReportValidationError ); }); + +test("renderReportPdf rejects a preliminary report with pending verdict", async () => { + const outputPath = path.join(os.tmpdir(), `property-assessor-preliminary-${Date.now()}.pdf`); + await assert.rejects( + () => + renderReportPdf( + { + ...samplePayload, + verdict: { + decision: "pending", + fairValueRange: "Not established", + offerGuidance: "Still needs comps and decision-grade analysis." + } + }, + outputPath + ), + /decision-grade|preliminary|pending/i + ); +});