Defer property assessor email gate

This commit is contained in:
2026-03-27 23:27:51 -05:00
parent f8c998d579
commit 1f23eac52c
10 changed files with 185 additions and 104 deletions

View File

@@ -74,8 +74,10 @@ Current behavior:
- automatically runs public-record / appraisal-district lookup - 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 - 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 - 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 - 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 - renders the fixed-template PDF when recipient email(s) are present
Important limitation: 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. 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: If the prompt does not include recipient email(s), the skill should:
- stop - stop
@@ -317,11 +322,11 @@ scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX
Expected shape: Expected shape:
- `needsAssessmentPurpose: false` - `needsAssessmentPurpose: false`
- `needsRecipientEmails: true` - `needsRecipientEmails: false`
- public-record / CAD jurisdiction included in the returned payload - public-record / CAD jurisdiction included in the returned payload
- `photoReview.imageUrls` populated when Zillow or HAR extraction succeeds - `photoReview.imageUrls` populated when Zillow or HAR extraction succeeds
- no PDF generated yet - 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 ### 3. Run public-record lookup directly

View File

@@ -91,7 +91,8 @@ scripts/property-assessor render-report --input "<report-payload-json>" --output
- try to discover Zillow and HAR listing URLs from the address when no listing URL is provided - 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 - run the approval-safe Zillow/HAR photo extractor chain automatically
- build a purpose-aware report payload - 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 - render the PDF only after recipient email(s) are known
## Public-record enrichment ## 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). 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): If the prompt does **not** include target email(s):
- stop - stop
- ask the user for the target email address(es) - ask the user for the target email address(es)

View File

@@ -20,6 +20,7 @@ export interface AssessPropertyResult {
ok: true; ok: true;
needsAssessmentPurpose: boolean; needsAssessmentPurpose: boolean;
needsRecipientEmails: boolean; needsRecipientEmails: boolean;
pdfReady: boolean;
message: string; message: string;
outputPath: string | null; outputPath: string | null;
reportPayload: ReportPayload | null; reportPayload: ReportPayload | null;
@@ -58,6 +59,13 @@ function asStringArray(value: unknown): string[] {
return [String(value).trim()].filter(Boolean); return [String(value).trim()].filter(Boolean);
} }
function shouldRenderPdf(
options: AssessPropertyOptions,
recipientEmails: string[]
): boolean {
return Boolean(options.output || recipientEmails.length);
}
function slugify(value: string): string { function slugify(value: string): string {
return value return value
.toLowerCase() .toLowerCase()
@@ -371,6 +379,7 @@ export async function assessProperty(
ok: true, ok: true,
needsAssessmentPurpose: true, needsAssessmentPurpose: true,
needsRecipientEmails: false, needsRecipientEmails: false,
pdfReady: false,
message: message:
"Missing assessment purpose. Stop and ask the user why they want this property assessed before producing a decision-grade analysis.", "Missing assessment purpose. Stop and ask the user why they want this property assessed before producing a decision-grade analysis.",
outputPath: null, outputPath: null,
@@ -403,12 +412,14 @@ export async function assessProperty(
photoResolution.photoReview photoResolution.photoReview
); );
const recipientEmails = asStringArray(options.recipientEmails); const recipientEmails = asStringArray(options.recipientEmails);
const renderPdf = shouldRenderPdf(options, recipientEmails);
if (!recipientEmails.length) { if (renderPdf && !recipientEmails.length) {
return { return {
ok: true, ok: true,
needsAssessmentPurpose: false, needsAssessmentPurpose: false,
needsRecipientEmails: true, needsRecipientEmails: true,
pdfReady: false,
message: message:
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.", "Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.",
outputPath: null, 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 = const outputPath =
options.output || options.output ||
path.join( path.join(
@@ -429,6 +454,7 @@ export async function assessProperty(
ok: true, ok: true,
needsAssessmentPurpose: false, needsAssessmentPurpose: false,
needsRecipientEmails: false, needsRecipientEmails: false,
pdfReady: true,
message: `Property assessment PDF rendered: ${renderedPath}`, message: `Property assessment PDF rendered: ${renderedPath}`,
outputPath: renderedPath, outputPath: renderedPath,
reportPayload, reportPayload,

View File

@@ -1,7 +1,5 @@
import { execFile } from "node:child_process"; import { discoverHarListing } from "../../web-automation/scripts/har-discover.js";
import { promisify } from "node:util"; import { discoverZillowListing } from "../../web-automation/scripts/zillow-discover.js";
const execFileAsync = promisify(execFile);
export interface ListingDiscoveryResult { export interface ListingDiscoveryResult {
attempts: string[]; attempts: string[];
@@ -9,37 +7,13 @@ export interface ListingDiscoveryResult {
harUrl: string | null; 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<ListingDiscoveryResult> { export async function discoverListingSources(address: string): Promise<ListingDiscoveryResult> {
const attempts: string[] = []; const attempts: string[] = [];
let zillowUrl: string | null = null; let zillowUrl: string | null = null;
let harUrl: string | null = null; let harUrl: string | null = null;
try { try {
const result = await runDiscoveryScript("zillow-discover.js", address); const result = await discoverZillowListing(address);
zillowUrl = result.listingUrl; zillowUrl = result.listingUrl;
attempts.push(...result.attempts); attempts.push(...result.attempts);
} catch (error) { } catch (error) {
@@ -49,7 +23,7 @@ export async function discoverListingSources(address: string): Promise<ListingDi
} }
try { try {
const result = await runDiscoveryScript("har-discover.js", address); const result = await discoverHarListing(address);
harUrl = result.listingUrl; harUrl = result.listingUrl;
attempts.push(...result.attempts); attempts.push(...result.attempts);
} catch (error) { } catch (error) {

View File

@@ -1,7 +1,5 @@
import { execFile } from "node:child_process"; import { extractHarPhotos } from "../../web-automation/scripts/har-photos.js";
import { promisify } from "node:util"; import { extractZillowPhotos } from "../../web-automation/scripts/zillow-photos.js";
const execFileAsync = promisify(execFile);
export type PhotoSource = "zillow" | "har"; export type PhotoSource = "zillow" | "har";
@@ -21,28 +19,25 @@ export interface PhotoReviewResolution {
discoveredListingUrls: Array<{ label: string; url: string }>; discoveredListingUrls: Array<{ label: string; url: string }>;
} }
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( export async function extractPhotoData(
source: PhotoSource, source: PhotoSource,
url: string url: string
): Promise<PhotoExtractionResult> { ): Promise<PhotoExtractionResult> {
const scriptMap: Record<PhotoSource, string> = { if (source === "zillow") {
zillow: "zillow-photos.js", const payload = await extractZillowPhotos(url);
har: "har-photos.js" 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 scriptPath = `/Users/stefano/.openclaw/workspace/skills/web-automation/scripts/${scriptMap[source]}`; }
const { stdout } = await execFileAsync(process.execPath, [scriptPath, url], {
timeout: 180000, const payload = await extractHarPhotos(url);
maxBuffer: 5 * 1024 * 1024
});
const payload = parseJsonOutput(stdout, scriptMap[source]);
return { return {
source, source,
requestedUrl: String(payload.requestedUrl || url), requestedUrl: String(payload.requestedUrl || url),

View File

@@ -62,7 +62,7 @@ test("assessProperty asks for assessment purpose before building a decision-grad
assert.equal(result.reportPayload, null); 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( const result = await assessProperty(
{ {
address: "4141 Whiteley Dr, Corpus Christi, TX 78418", 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.ok, true);
assert.equal(result.needsAssessmentPurpose, false); assert.equal(result.needsAssessmentPurpose, false);
assert.equal(result.needsRecipientEmails, true); assert.equal(result.needsRecipientEmails, false);
assert.equal(result.outputPath, null); 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?.subjectProperty?.address, samplePublicRecords.matchedAddress);
assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District"); assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District");
assert.equal(result.reportPayload?.publicRecords?.accountNumber, "14069438"); 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, []); 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 () => { test("assessProperty falls back to HAR when Zillow photo extraction fails", async () => {
const result = await assessProperty( const result = await assessProperty(
{ {

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { pathToFileURL } from "node:url";
import { import {
createPageSession, createPageSession,
dismissCommonOverlays, dismissCommonOverlays,
@@ -57,8 +59,8 @@ async function collectListingUrl(page) {
}); });
} }
async function main() { export async function discoverHarListing(rawAddress) {
const address = String(process.argv[2] || "").trim(); const address = String(rawAddress || "").trim();
const identity = parseAddressIdentity(address); const identity = parseAddressIdentity(address);
const searchUrl = buildSearchUrl(address); const searchUrl = buildSearchUrl(address);
const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" });
@@ -101,9 +103,7 @@ async function main() {
} }
} }
process.stdout.write( const result = {
`${JSON.stringify(
{
source: "har", source: "har",
address, address,
searchUrl, searchUrl,
@@ -111,20 +111,28 @@ async function main() {
title: await page.title(), title: await page.title(),
listingUrl, listingUrl,
attempts, attempts,
}, };
null,
2
)}\n`
);
await context.close(); await context.close();
return result;
} catch (error) { } catch (error) {
try { try {
await context.close(); await context.close();
} catch { } catch {
// Ignore close errors after the primary failure. // 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)); 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();
}

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { pathToFileURL } from "node:url";
import { import {
clickPhotoEntryPoint, clickPhotoEntryPoint,
collectRenderedImageCandidates, collectRenderedImageCandidates,
@@ -29,8 +31,8 @@ async function getAnnouncedPhotoCount(page) {
}); });
} }
async function main() { export async function extractHarPhotos(rawUrl) {
const requestedUrl = parseTarget(process.argv[2]); const requestedUrl = parseTarget(rawUrl);
const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" });
try { try {
@@ -68,16 +70,27 @@ async function main() {
notes: ["Opened HAR all-photos flow and extracted large rendered image URLs from the photo page."], 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(); await context.close();
return result;
} catch (error) { } catch (error) {
try { try {
await context.close(); await context.close();
} catch { } catch {
// Ignore close errors after the primary failure. // 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)); 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();
}

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { pathToFileURL } from "node:url";
import { import {
createPageSession, createPageSession,
dismissCommonOverlays, dismissCommonOverlays,
@@ -61,8 +63,8 @@ async function collectListingUrl(page) {
}); });
} }
async function main() { export async function discoverZillowListing(rawAddress) {
const address = String(process.argv[2] || "").trim(); const address = String(rawAddress || "").trim();
const identity = parseAddressIdentity(address); const identity = parseAddressIdentity(address);
const searchUrl = `https://www.zillow.com/homes/${encodeURIComponent(buildZillowAddressSlug(address))}_rb/`; const searchUrl = `https://www.zillow.com/homes/${encodeURIComponent(buildZillowAddressSlug(address))}_rb/`;
const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" });
@@ -105,9 +107,7 @@ async function main() {
} }
} }
process.stdout.write( const result = {
`${JSON.stringify(
{
source: "zillow", source: "zillow",
address, address,
searchUrl, searchUrl,
@@ -115,20 +115,28 @@ async function main() {
title: await page.title(), title: await page.title(),
listingUrl, listingUrl,
attempts, attempts,
}, };
null,
2
)}\n`
);
await context.close(); await context.close();
return result;
} catch (error) { } catch (error) {
try { try {
await context.close(); await context.close();
} catch { } catch {
// Ignore close errors after the primary failure. // 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)); 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();
}

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { pathToFileURL } from "node:url";
import { import {
clickPhotoEntryPoint, clickPhotoEntryPoint,
createPageSession, createPageSession,
@@ -102,8 +104,8 @@ async function collectZillowStructuredPhotoCandidates(page) {
return extractZillowStructuredPhotoCandidatesFromNextDataScript(scriptText || ""); return extractZillowStructuredPhotoCandidatesFromNextDataScript(scriptText || "");
} }
async function main() { export async function extractZillowPhotos(rawUrl) {
const requestedUrl = parseTarget(process.argv[2]); const requestedUrl = parseTarget(rawUrl);
const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" });
try { try {
@@ -167,16 +169,27 @@ async function main() {
notes, notes,
}; };
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
await context.close(); await context.close();
return result;
} catch (error) { } catch (error) {
try { try {
await context.close(); await context.close();
} catch { } catch {
// Ignore close errors after the primary failure. // 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)); 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();
}