From 1f23eac52cfe96ebb3845722d5cc7f15787b1eab Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Fri, 27 Mar 2026 23:27:51 -0500 Subject: [PATCH] Defer property assessor email gate --- docs/property-assessor.md | 11 +++-- skills/property-assessor/SKILL.md | 6 ++- skills/property-assessor/src/assessment.ts | 28 +++++++++++- .../src/listing-discovery.ts | 34 ++------------ skills/property-assessor/src/photo-review.ts | 39 +++++++--------- .../tests/assessment.test.ts | 41 +++++++++++++++-- skills/web-automation/scripts/har-discover.js | 44 +++++++++++-------- skills/web-automation/scripts/har-photos.js | 21 +++++++-- .../web-automation/scripts/zillow-discover.js | 44 +++++++++++-------- .../web-automation/scripts/zillow-photos.js | 21 +++++++-- 10 files changed, 185 insertions(+), 104 deletions(-) diff --git a/docs/property-assessor.md b/docs/property-assessor.md index a9390ad..3f9678c 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -74,8 +74,10 @@ Current behavior: - automatically runs public-record / appraisal-district lookup - automatically tries to discover Zillow and HAR listing URLs from the address when no listing URL is provided - runs Zillow photo extraction first, then HAR as fallback when available +- reuses the OpenClaw web-automation logic in-process instead of spawning nested helper commands - returns a structured preliminary report payload -- asks for recipient email(s) before PDF generation +- 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 Important limitation: @@ -259,6 +261,9 @@ The fixed template includes: The report must not be rendered or sent unless target recipient email address(es) are known. +This requirement applies when the operator is actually rendering or sending the PDF. +It should not interrupt a normal analysis-only `assess` run. + If the prompt does not include recipient email(s), the skill should: - stop @@ -317,11 +322,11 @@ scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX Expected shape: - `needsAssessmentPurpose: false` -- `needsRecipientEmails: true` +- `needsRecipientEmails: false` - public-record / CAD jurisdiction included in the returned payload - `photoReview.imageUrls` populated when Zillow or HAR extraction succeeds - no PDF generated yet -- explicit message telling the operator to ask for target recipient email(s) +- explicit message saying the payload is ready and email is only needed when rendering or sending the PDF ### 3. Run public-record lookup directly diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index 3d4c9cc..1cd8bf1 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -91,7 +91,8 @@ scripts/property-assessor render-report --input "" --output - try to discover Zillow and HAR listing URLs from the address when no listing URL is provided - run the approval-safe Zillow/HAR photo extractor chain automatically - build a purpose-aware report payload -- stop and ask for recipient email(s) before rendering the PDF +- 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 ## Public-record enrichment @@ -361,6 +362,9 @@ The PDF report should include: Before rendering or sending the PDF, the skill must know the target recipient email address(es). +This gate applies to the PDF step, not the analysis step. +Do not interrupt a normal assessment run just because recipient email is missing if the user has not yet asked to render or send the PDF. + If the prompt does **not** include target email(s): - stop - ask the user for the target email address(es) diff --git a/skills/property-assessor/src/assessment.ts b/skills/property-assessor/src/assessment.ts index d7bd69f..7095c78 100644 --- a/skills/property-assessor/src/assessment.ts +++ b/skills/property-assessor/src/assessment.ts @@ -20,6 +20,7 @@ export interface AssessPropertyResult { ok: true; needsAssessmentPurpose: boolean; needsRecipientEmails: boolean; + pdfReady: boolean; message: string; outputPath: string | null; reportPayload: ReportPayload | null; @@ -58,6 +59,13 @@ function asStringArray(value: unknown): string[] { return [String(value).trim()].filter(Boolean); } +function shouldRenderPdf( + options: AssessPropertyOptions, + recipientEmails: string[] +): boolean { + return Boolean(options.output || recipientEmails.length); +} + function slugify(value: string): string { return value .toLowerCase() @@ -371,6 +379,7 @@ export async function assessProperty( ok: true, needsAssessmentPurpose: true, needsRecipientEmails: false, + pdfReady: false, message: "Missing assessment purpose. Stop and ask the user why they want this property assessed before producing a decision-grade analysis.", outputPath: null, @@ -403,12 +412,14 @@ export async function assessProperty( photoResolution.photoReview ); const recipientEmails = asStringArray(options.recipientEmails); + const renderPdf = shouldRenderPdf(options, recipientEmails); - if (!recipientEmails.length) { + if (renderPdf && !recipientEmails.length) { return { ok: true, needsAssessmentPurpose: false, needsRecipientEmails: true, + pdfReady: false, message: "Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.", outputPath: null, @@ -417,6 +428,20 @@ export async function assessProperty( }; } + if (!renderPdf) { + return { + ok: true, + needsAssessmentPurpose: false, + needsRecipientEmails: false, + pdfReady: true, + message: + "Assessment payload is ready to render later. Review the analysis now; recipient email is only needed when you want the PDF.", + outputPath: null, + reportPayload, + publicRecords + }; + } + const outputPath = options.output || path.join( @@ -429,6 +454,7 @@ export async function assessProperty( ok: true, needsAssessmentPurpose: false, needsRecipientEmails: false, + pdfReady: true, message: `Property assessment PDF rendered: ${renderedPath}`, outputPath: renderedPath, reportPayload, diff --git a/skills/property-assessor/src/listing-discovery.ts b/skills/property-assessor/src/listing-discovery.ts index 488369b..ff28c10 100644 --- a/skills/property-assessor/src/listing-discovery.ts +++ b/skills/property-assessor/src/listing-discovery.ts @@ -1,7 +1,5 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); +import { discoverHarListing } from "../../web-automation/scripts/har-discover.js"; +import { discoverZillowListing } from "../../web-automation/scripts/zillow-discover.js"; export interface ListingDiscoveryResult { attempts: string[]; @@ -9,37 +7,13 @@ export interface ListingDiscoveryResult { harUrl: string | null; } -function parseJsonOutput(raw: string, context: string): any { - const text = raw.trim(); - if (!text) { - throw new Error(`${context} produced no JSON output.`); - } - return JSON.parse(text); -} - -async function runDiscoveryScript( - scriptName: string, - address: string -): Promise<{ listingUrl: string | null; attempts: string[] }> { - const scriptPath = `/Users/stefano/.openclaw/workspace/skills/web-automation/scripts/${scriptName}`; - const { stdout } = await execFileAsync(process.execPath, [scriptPath, address], { - timeout: 120000, - maxBuffer: 2 * 1024 * 1024 - }); - const payload = parseJsonOutput(stdout, scriptName); - return { - listingUrl: typeof payload.listingUrl === "string" && payload.listingUrl.trim() ? payload.listingUrl.trim() : null, - attempts: Array.isArray(payload.attempts) ? payload.attempts.map(String) : [] - }; -} - export async function discoverListingSources(address: string): Promise { const attempts: string[] = []; let zillowUrl: string | null = null; let harUrl: string | null = null; try { - const result = await runDiscoveryScript("zillow-discover.js", address); + const result = await discoverZillowListing(address); zillowUrl = result.listingUrl; attempts.push(...result.attempts); } catch (error) { @@ -49,7 +23,7 @@ export async function discoverListingSources(address: string): Promise; } -function parseJsonOutput(raw: string, context: string): any { - const text = raw.trim(); - if (!text) { - throw new Error(`${context} produced no JSON output.`); - } - return JSON.parse(text); -} - export async function extractPhotoData( source: PhotoSource, url: string ): Promise { - const scriptMap: Record = { - zillow: "zillow-photos.js", - har: "har-photos.js" - }; - const scriptPath = `/Users/stefano/.openclaw/workspace/skills/web-automation/scripts/${scriptMap[source]}`; - const { stdout } = await execFileAsync(process.execPath, [scriptPath, url], { - timeout: 180000, - maxBuffer: 5 * 1024 * 1024 - }); - const payload = parseJsonOutput(stdout, scriptMap[source]); + if (source === "zillow") { + const payload = await extractZillowPhotos(url); + return { + source, + requestedUrl: String(payload.requestedUrl || url), + finalUrl: typeof payload.finalUrl === "string" ? payload.finalUrl : undefined, + expectedPhotoCount: typeof payload.expectedPhotoCount === "number" ? payload.expectedPhotoCount : null, + complete: Boolean(payload.complete), + photoCount: Number(payload.photoCount || 0), + imageUrls: Array.isArray(payload.imageUrls) ? payload.imageUrls.map(String) : [], + notes: Array.isArray(payload.notes) ? payload.notes.map(String) : [] + }; + } + + const payload = await extractHarPhotos(url); return { source, requestedUrl: String(payload.requestedUrl || url), diff --git a/skills/property-assessor/tests/assessment.test.ts b/skills/property-assessor/tests/assessment.test.ts index 157c1a2..320a90b 100644 --- a/skills/property-assessor/tests/assessment.test.ts +++ b/skills/property-assessor/tests/assessment.test.ts @@ -62,7 +62,7 @@ test("assessProperty asks for assessment purpose before building a decision-grad assert.equal(result.reportPayload, null); }); -test("assessProperty auto-discovers listing sources, runs Zillow photos first, and asks for recipient email", async () => { +test("assessProperty auto-discovers listing sources, runs Zillow photos first, and does not ask for email during analysis-only runs", async () => { const result = await assessProperty( { address: "4141 Whiteley Dr, Corpus Christi, TX 78418", @@ -97,9 +97,10 @@ test("assessProperty auto-discovers listing sources, runs Zillow photos first, a assert.equal(result.ok, true); assert.equal(result.needsAssessmentPurpose, false); - assert.equal(result.needsRecipientEmails, true); + assert.equal(result.needsRecipientEmails, false); assert.equal(result.outputPath, null); - assert.match(result.message, /target email/i); + assert.doesNotMatch(result.message, /target email/i); + assert.match(result.message, /ready to render|recipient email is only needed when you want the pdf/i); assert.equal(result.reportPayload?.subjectProperty?.address, samplePublicRecords.matchedAddress); assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District"); assert.equal(result.reportPayload?.publicRecords?.accountNumber, "14069438"); @@ -117,6 +118,40 @@ test("assessProperty auto-discovers listing sources, runs Zillow photos first, a assert.deepEqual(result.reportPayload?.recipientEmails, []); }); +test("assessProperty asks for recipient email only when PDF render is explicitly requested", async () => { + const result = await assessProperty( + { + address: "4141 Whiteley Dr, Corpus Christi, TX 78418", + assessmentPurpose: "investment property", + output: path.join(os.tmpdir(), `property-assess-missing-email-${Date.now()}.pdf`) + }, + { + resolvePublicRecordsFn: async () => samplePublicRecords, + discoverListingSourcesFn: async () => ({ + attempts: ["Zillow discovery located a property page from the address."], + zillowUrl: + "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/", + harUrl: null + }), + extractPhotoDataFn: async (source, url) => ({ + source, + requestedUrl: url, + finalUrl: url, + expectedPhotoCount: 29, + complete: true, + photoCount: 29, + imageUrls: ["https://photos.example/1.jpg"], + notes: [`${source} extractor succeeded.`] + }) + } + ); + + assert.equal(result.ok, true); + assert.equal(result.needsRecipientEmails, true); + assert.equal(result.outputPath, null); + assert.match(result.message, /target email/i); +}); + test("assessProperty falls back to HAR when Zillow photo extraction fails", async () => { const result = await assessProperty( { diff --git a/skills/web-automation/scripts/har-discover.js b/skills/web-automation/scripts/har-discover.js index eb90cbc..1f331da 100644 --- a/skills/web-automation/scripts/har-discover.js +++ b/skills/web-automation/scripts/har-discover.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; + import { createPageSession, dismissCommonOverlays, @@ -57,8 +59,8 @@ async function collectListingUrl(page) { }); } -async function main() { - const address = String(process.argv[2] || "").trim(); +export async function discoverHarListing(rawAddress) { + const address = String(rawAddress || "").trim(); const identity = parseAddressIdentity(address); const searchUrl = buildSearchUrl(address); const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); @@ -101,30 +103,36 @@ async function main() { } } - process.stdout.write( - `${JSON.stringify( - { - source: "har", - address, - searchUrl, - finalUrl: page.url(), - title: await page.title(), - listingUrl, - attempts, - }, - null, - 2 - )}\n` - ); + const result = { + source: "har", + address, + searchUrl, + finalUrl: page.url(), + title: await page.title(), + listingUrl, + attempts, + }; await context.close(); + return result; } catch (error) { try { await context.close(); } catch { // Ignore close errors after the primary failure. } + throw new Error(`HAR discovery failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function main() { + try { + const result = await discoverHarListing(process.argv[2]); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { fail("HAR discovery failed.", error instanceof Error ? error.message : String(error)); } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/skills/web-automation/scripts/har-photos.js b/skills/web-automation/scripts/har-photos.js index 99a99a7..3ace1ca 100644 --- a/skills/web-automation/scripts/har-photos.js +++ b/skills/web-automation/scripts/har-photos.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; + import { clickPhotoEntryPoint, collectRenderedImageCandidates, @@ -29,8 +31,8 @@ async function getAnnouncedPhotoCount(page) { }); } -async function main() { - const requestedUrl = parseTarget(process.argv[2]); +export async function extractHarPhotos(rawUrl) { + const requestedUrl = parseTarget(rawUrl); const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); try { @@ -68,16 +70,27 @@ async function main() { notes: ["Opened HAR all-photos flow and extracted large rendered image URLs from the photo page."], }; - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); await context.close(); + return result; } catch (error) { try { await context.close(); } catch { // Ignore close errors after the primary failure. } + throw new Error(error instanceof Error ? error.message : String(error)); + } +} + +async function main() { + try { + const result = await extractHarPhotos(process.argv[2]); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { fail("HAR photo extraction failed.", error instanceof Error ? error.message : String(error)); } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/skills/web-automation/scripts/zillow-discover.js b/skills/web-automation/scripts/zillow-discover.js index 0472e65..1311a8e 100644 --- a/skills/web-automation/scripts/zillow-discover.js +++ b/skills/web-automation/scripts/zillow-discover.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; + import { createPageSession, dismissCommonOverlays, @@ -61,8 +63,8 @@ async function collectListingUrl(page) { }); } -async function main() { - const address = String(process.argv[2] || "").trim(); +export async function discoverZillowListing(rawAddress) { + const address = String(rawAddress || "").trim(); const identity = parseAddressIdentity(address); const searchUrl = `https://www.zillow.com/homes/${encodeURIComponent(buildZillowAddressSlug(address))}_rb/`; const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); @@ -105,30 +107,36 @@ async function main() { } } - process.stdout.write( - `${JSON.stringify( - { - source: "zillow", - address, - searchUrl, - finalUrl: page.url(), - title: await page.title(), - listingUrl, - attempts, - }, - null, - 2 - )}\n` - ); + const result = { + source: "zillow", + address, + searchUrl, + finalUrl: page.url(), + title: await page.title(), + listingUrl, + attempts, + }; await context.close(); + return result; } catch (error) { try { await context.close(); } catch { // Ignore close errors after the primary failure. } + throw new Error(`Zillow discovery failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function main() { + try { + const result = await discoverZillowListing(process.argv[2]); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { fail("Zillow discovery failed.", error instanceof Error ? error.message : String(error)); } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/skills/web-automation/scripts/zillow-photos.js b/skills/web-automation/scripts/zillow-photos.js index 8beec05..b1d42bf 100644 --- a/skills/web-automation/scripts/zillow-photos.js +++ b/skills/web-automation/scripts/zillow-photos.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; + import { clickPhotoEntryPoint, createPageSession, @@ -102,8 +104,8 @@ async function collectZillowStructuredPhotoCandidates(page) { return extractZillowStructuredPhotoCandidatesFromNextDataScript(scriptText || ""); } -async function main() { - const requestedUrl = parseTarget(process.argv[2]); +export async function extractZillowPhotos(rawUrl) { + const requestedUrl = parseTarget(rawUrl); const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); try { @@ -167,16 +169,27 @@ async function main() { notes, }; - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); await context.close(); + return result; } catch (error) { try { await context.close(); } catch { // Ignore close errors after the primary failure. } + throw new Error(error instanceof Error ? error.message : String(error)); + } +} + +async function main() { + try { + const result = await extractZillowPhotos(process.argv[2]); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { fail("Zillow photo extraction failed.", error instanceof Error ? error.message : String(error)); } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +}