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 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

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
- 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)

View File

@@ -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,

View File

@@ -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<ListingDiscoveryResult> {
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<ListingDi
}
try {
const result = await runDiscoveryScript("har-discover.js", address);
const result = await discoverHarListing(address);
harUrl = result.listingUrl;
attempts.push(...result.attempts);
} catch (error) {

View File

@@ -1,7 +1,5 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
import { extractHarPhotos } from "../../web-automation/scripts/har-photos.js";
import { extractZillowPhotos } from "../../web-automation/scripts/zillow-photos.js";
export type PhotoSource = "zillow" | "har";
@@ -21,28 +19,25 @@ export interface PhotoReviewResolution {
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(
source: PhotoSource,
url: string
): Promise<PhotoExtractionResult> {
const scriptMap: Record<PhotoSource, string> = {
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),

View File

@@ -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(
{

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}