diff --git a/docs/property-assessor.md b/docs/property-assessor.md index 9793400..627e60c 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -16,8 +16,9 @@ The skill is intended to: ## Standalone helper usage -This skill now ships with a small TypeScript helper package for two tasks: +This skill now ships with a small TypeScript helper package for three tasks: +- assembling an address-first preliminary assessment payload - locating official public-record jurisdiction from an address - rendering a fixed-template PDF report @@ -33,6 +34,10 @@ The wrapper script uses the skill-local Node dependencies under `node_modules/`. ## Commands ```bash +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --recipient-email "buyer@example.com" +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf + scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --parcel-id "14069438" scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --listing-geo-id "233290" @@ -45,12 +50,34 @@ scripts/property-assessor render-report --input examples/report-payload.example. Default operating sequence: 1. Normalize the address and property type. -2. Discover accessible listing and public-record sources for the same property. -3. Build a baseline fact set. -4. Review listing photos before making condition claims. -5. Pull same-building or nearby comps. -6. Underwrite carry costs and risk factors. -7. Render the final report as a fixed-template PDF. +2. Resolve public-record jurisdiction from the address. +3. Discover accessible listing sources for the same property. +4. Build a baseline fact set. +5. Review listing photos before making condition claims. +6. Pull same-building or nearby comps. +7. Underwrite carry costs and risk factors. +8. Render the final report as a fixed-template PDF. + +### `assess` + +```bash +scripts/property-assessor assess --address "" +scripts/property-assessor assess --address "" --recipient-email "" +``` + +Current behavior: + +- starts from the address +- automatically runs public-record / appraisal-district lookup +- returns a structured preliminary report payload +- asks for recipient email(s) before PDF generation +- renders the fixed-template PDF when recipient email(s) are present + +Important limitation: + +- this first implementation wires the address-first assessment spine +- it does not yet auto-run listing extraction, photo review, comps, or carry underwriting inside the helper itself +- those richer assessment steps are still governed by the skill workflow and the `web-automation` extractors ## Source priority @@ -178,6 +205,7 @@ For chat-driven runs, prefer file-based commands. Good: +- `scripts/property-assessor assess --address "..."` - `node check-install.js` - `node zillow-photos.js ""` - `node har-photos.js ""` @@ -200,6 +228,7 @@ Template reference: Current renderer: ```bash +scripts/property-assessor assess --address "" --recipient-email "" scripts/property-assessor render-report --input "" --output "" ``` @@ -271,7 +300,21 @@ npm install npm test ``` -### 2. Run public-record lookup +### 2. Run address-first assess without recipient email + +```bash +cd ~/.openclaw/workspace/skills/property-assessor +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" +``` + +Expected shape: + +- `needsRecipientEmails: true` +- public-record / CAD jurisdiction included in the returned payload +- no PDF generated yet +- explicit message telling the operator to ask for target recipient email(s) + +### 3. Run public-record lookup directly ```bash cd ~/.openclaw/workspace/skills/property-assessor @@ -285,7 +328,20 @@ Expected shape: - for Texas: Comptroller county directory link present - for Texas: appraisal district and tax assessor/collector contacts present -### 3. Run PDF render with the sample payload +### 4. Run assess with recipient email and render the PDF + +```bash +cd ~/.openclaw/workspace/skills/property-assessor +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf +``` + +Expected result: + +- `needsRecipientEmails: false` +- JSON success payload with `outputPath` +- a non-empty PDF written to `/tmp/property-assessment.pdf` + +### 5. Run PDF render with the sample payload ```bash cd ~/.openclaw/workspace/skills/property-assessor @@ -297,7 +353,7 @@ Expected result: - JSON success payload with `outputPath` - a non-empty PDF written to `/tmp/property-assessment.pdf` -### 4. Verify the email gate +### 6. Verify the email gate Run the renderer with a payload that omits `recipientEmails`. @@ -306,7 +362,7 @@ Expected result: - non-zero exit - explicit message telling the operator to stop and ask for target recipient email(s) -### 5. Verify the end-to-end skill behavior +### 7. Verify the end-to-end skill behavior When testing `property-assessor` itself, confirm the assessment: diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index ae7b8e9..90d0feb 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -56,6 +56,7 @@ Use `web_search` sparingly to discover alternate URLs, then return to `web-autom ## Helper runtime `property-assessor` now includes TypeScript helper commands for: +- address-first preliminary assessment assembly - public-record jurisdiction lookup - fixed-template PDF rendering @@ -66,6 +67,23 @@ cd ~/.openclaw/workspace/skills/property-assessor npm install ``` +Quick command summary: + +```bash +cd ~/.openclaw/workspace/skills/property-assessor +npm install +scripts/property-assessor assess --address "" +scripts/property-assessor assess --address "" --recipient-email "" +scripts/property-assessor locate-public-records --address "" +scripts/property-assessor render-report --input "" --output "" +``` + +`assess` is the address-first entrypoint. It should: +- resolve official public-record jurisdiction automatically from the address +- build the report payload skeleton +- stop and ask for recipient email(s) before rendering the PDF +- render the PDF only after recipient email(s) are known + ## Public-record enrichment Public-record / assessor data should be used when available and linked in the final result. @@ -85,6 +103,16 @@ npm install scripts/property-assessor locate-public-records --address "" ``` +When you want the helper to assemble the preliminary assessment payload in one step, use: + +```bash +cd ~/.openclaw/workspace/skills/property-assessor +npm install +scripts/property-assessor assess --address "" +``` + +This command should automatically include the public-record jurisdiction result in the returned assessment payload. + This command currently: - resolves the address through the official Census geocoder - returns county/state/FIPS/GEOID context @@ -292,6 +320,7 @@ Use the property-assessor helper CLI: ```bash cd ~/.openclaw/workspace/skills/property-assessor npm install +scripts/property-assessor assess --address "" --recipient-email "" scripts/property-assessor render-report --input "" --output "" ``` diff --git a/skills/property-assessor/package.json b/skills/property-assessor/package.json index 936914e..10c9493 100644 --- a/skills/property-assessor/package.json +++ b/skills/property-assessor/package.json @@ -4,6 +4,7 @@ "description": "Property assessor helpers for public-record lookup and fixed-template PDF rendering", "type": "module", "scripts": { + "assess": "tsx src/cli.ts assess", "locate-public-records": "tsx src/cli.ts locate-public-records", "render-report": "tsx src/cli.ts render-report", "test": "node --import tsx --test tests/*.test.ts" diff --git a/skills/property-assessor/src/assessment.ts b/skills/property-assessor/src/assessment.ts new file mode 100644 index 0000000..1a76956 --- /dev/null +++ b/skills/property-assessor/src/assessment.ts @@ -0,0 +1,227 @@ +import os from "node:os"; +import path from "node:path"; + +import { resolvePublicRecords, type PublicRecordsResolution } from "./public-records.js"; +import { renderReportPdf, type ReportPayload } from "./report-pdf.js"; + +export interface AssessPropertyOptions { + address: string; + recipientEmails?: string[] | string; + output?: string; + parcelId?: string; + listingGeoId?: string; + listingSourceUrl?: string; +} + +export interface AssessPropertyResult { + ok: true; + needsRecipientEmails: boolean; + message: string; + outputPath: string | null; + reportPayload: ReportPayload; + publicRecords: PublicRecordsResolution; +} + +interface AssessPropertyDeps { + resolvePublicRecordsFn?: typeof resolvePublicRecords; + renderReportPdfFn?: typeof renderReportPdf; +} + +function asStringArray(value: unknown): string[] { + if (value == null) return []; + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + if (Array.isArray(value)) { + return value.flatMap((item) => asStringArray(item)); + } + return [String(value).trim()].filter(Boolean); +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "property"; +} + +function pushLink( + target: Array<{ label: string; url: string }>, + label: string, + url: unknown +): void { + if (typeof url !== "string" || !url.trim()) return; + const normalized = url.trim(); + if (!target.some((item) => item.url === normalized)) { + target.push({ label, url: normalized }); + } +} + +function buildPublicRecordLinks( + publicRecords: PublicRecordsResolution +): Array<{ label: string; url: string }> { + const links: Array<{ label: string; url: string }> = []; + pushLink(links, "Census Geocoder", publicRecords.officialLinks.censusGeocoder); + pushLink( + links, + "Texas Comptroller County Directory", + publicRecords.officialLinks.texasCountyDirectory + ); + pushLink( + links, + "Texas Property Tax Portal", + publicRecords.officialLinks.texasPropertyTaxPortal + ); + pushLink(links, "Appraisal District Website", publicRecords.appraisalDistrict?.Website); + pushLink( + links, + "Appraisal District Directory Page", + publicRecords.appraisalDistrict?.directoryPage + ); + pushLink( + links, + "Tax Assessor / Collector Website", + publicRecords.taxAssessorCollector?.Website + ); + pushLink( + links, + "Tax Assessor / Collector Directory Page", + publicRecords.taxAssessorCollector?.directoryPage + ); + return links; +} + +export function buildAssessmentReportPayload( + options: AssessPropertyOptions, + publicRecords: PublicRecordsResolution +): ReportPayload { + const recipientEmails = asStringArray(options.recipientEmails); + const matchedAddress = publicRecords.matchedAddress || options.address; + const publicRecordLinks = buildPublicRecordLinks(publicRecords); + const sourceLinks = [...publicRecordLinks]; + + pushLink(sourceLinks, "Listing Source", options.listingSourceUrl); + + const jurisdiction = + publicRecords.county.name && publicRecords.appraisalDistrict + ? `${publicRecords.county.name} Appraisal District` + : publicRecords.county.name || publicRecords.state.name || "Official public-record jurisdiction"; + + const photoAttempts = options.listingSourceUrl + ? [ + `Listing source captured: ${options.listingSourceUrl}`, + "Photo review has not been run yet. Use the approval-safe Zillow/HAR extractor flow before making condition claims." + ] + : [ + "No listing source URL was provided to the assess helper.", + "Photo review has not been run yet. Use the approval-safe Zillow/HAR extractor flow before making condition claims." + ]; + + return { + recipientEmails, + reportTitle: "Property Assessment Report", + subtitle: "Preliminary address-first intake with public-record enrichment.", + subjectProperty: { + address: matchedAddress, + county: publicRecords.county.name || "N/A", + state: publicRecords.state.code || publicRecords.state.name || "N/A", + geoid: publicRecords.geoid || "N/A" + }, + verdict: { + decision: "pending", + fairValueRange: "Not established", + offerGuidance: + "Preliminary intake only. Official public-record jurisdiction is identified, but listing, photo, comp, and carry analysis still required before a buy/pass/offer conclusion." + }, + snapshot: [ + `Matched address: ${matchedAddress}`, + `Jurisdiction anchor: ${publicRecords.county.name || "Unknown county"}, ${publicRecords.state.code || publicRecords.state.name || "Unknown state"}.`, + publicRecords.geoid ? `Census block GEOID: ${publicRecords.geoid}.` : "Census block GEOID not returned." + ], + whatILike: [ + "Address-first flow avoids treating listing-site geo IDs as assessor record keys.", + publicRecords.appraisalDistrict + ? "Official appraisal-district contact and website were identified from public records." + : "Official public-record geography was identified." + ], + whatIDontLike: [ + "This first-pass assess helper does not yet include listing facts, comp analysis, or a completed photo review.", + "Do not make valuation or condition claims from this preliminary output alone." + ], + compView: [ + "Comp analysis not yet run. Pull same-building or nearby comps before setting fair value." + ], + carryView: [ + "Carry-cost underwriting not yet run. Add taxes, HOA, insurance, maintenance, and vacancy assumptions before decisioning." + ], + risksAndDiligence: publicRecords.lookupRecommendations, + photoReview: { + status: "not completed", + source: options.listingSourceUrl ? "listing source pending review" : "no listing source provided", + attempts: photoAttempts, + summary: + "Condition review is incomplete until listing photos are actually extracted and inspected." + }, + publicRecords: { + jurisdiction, + accountNumber: options.parcelId || publicRecords.sourceIdentifierHints.parcelId, + ownerName: undefined, + landValue: undefined, + improvementValue: undefined, + assessedTotalValue: undefined, + exemptions: undefined, + links: publicRecordLinks + }, + sourceLinks + }; +} + +export async function assessProperty( + options: AssessPropertyOptions, + deps: AssessPropertyDeps = {} +): Promise { + const resolvePublicRecordsFn = deps.resolvePublicRecordsFn || resolvePublicRecords; + const renderReportPdfFn = deps.renderReportPdfFn || renderReportPdf; + + const publicRecords = await resolvePublicRecordsFn(options.address, { + parcelId: options.parcelId, + listingGeoId: options.listingGeoId, + listingSourceUrl: options.listingSourceUrl + }); + + const reportPayload = buildAssessmentReportPayload(options, publicRecords); + const recipientEmails = asStringArray(options.recipientEmails); + + if (!recipientEmails.length) { + return { + ok: true, + needsRecipientEmails: true, + message: + "Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.", + outputPath: null, + reportPayload, + publicRecords + }; + } + + const outputPath = + options.output || + path.join( + os.tmpdir(), + `property-assessment-${slugify(publicRecords.matchedAddress || options.address)}-${Date.now()}.pdf` + ); + + const renderedPath = await renderReportPdfFn(reportPayload, outputPath); + return { + ok: true, + needsRecipientEmails: false, + message: `Property assessment PDF rendered: ${renderedPath}`, + outputPath: renderedPath, + reportPayload, + publicRecords + }; +} diff --git a/skills/property-assessor/src/cli.ts b/skills/property-assessor/src/cli.ts index ec0e834..10d4942 100644 --- a/skills/property-assessor/src/cli.ts +++ b/skills/property-assessor/src/cli.ts @@ -2,12 +2,14 @@ import minimist from "minimist"; +import { assessProperty } from "./assessment.js"; import { resolvePublicRecords } from "./public-records.js"; import { ReportValidationError, loadReportPayload, renderReportPdf } from "./report-pdf.js"; function usage(): void { process.stdout.write(`property-assessor\n Commands: + assess --address "
" [--recipient-email ""] [--output ""] [--parcel-id ""] [--listing-geo-id ""] [--listing-source-url ""] locate-public-records --address "
" [--parcel-id ""] [--listing-geo-id ""] [--listing-source-url ""] render-report --input "" --output "" `); @@ -27,6 +29,22 @@ async function main(): Promise { process.exit(0); } + if (command === "assess") { + if (!argv.address) { + throw new Error("Missing required option: --address"); + } + const payload = await assessProperty({ + address: argv.address, + recipientEmails: argv["recipient-email"], + output: argv.output, + parcelId: argv["parcel-id"], + listingGeoId: argv["listing-geo-id"], + listingSourceUrl: argv["listing-source-url"] + }); + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + if (command === "locate-public-records") { if (!argv.address) { throw new Error("Missing required option: --address"); diff --git a/skills/property-assessor/tests/assessment.test.ts b/skills/property-assessor/tests/assessment.test.ts new file mode 100644 index 0000000..56542c2 --- /dev/null +++ b/skills/property-assessor/tests/assessment.test.ts @@ -0,0 +1,96 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { assessProperty } from "../src/assessment.js"; +import type { PublicRecordsResolution } from "../src/public-records.js"; + +const samplePublicRecords: PublicRecordsResolution = { + requestedAddress: "4141 Whiteley Dr, Corpus Christi, TX 78418", + matchedAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418", + latitude: 27.6138, + longitude: -97.3024, + geoid: "483550031013005", + state: { + name: "Texas", + code: "TX", + fips: "48" + }, + county: { + name: "Nueces County", + fips: "355", + geoid: "48355" + }, + officialLinks: { + censusGeocoder: "https://geocoding.geo.census.gov/example", + texasCountyDirectory: + "https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php", + texasPropertyTaxPortal: "https://texas.gov/PropertyTaxes" + }, + appraisalDistrict: { + "Chief Appraiser": "Debra Morin, Interim", + Website: "http://www.ncadistrict.com/", + directoryPage: + "https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php" + }, + taxAssessorCollector: { + "Tax Assessor-Collector": "Kevin Kieschnick", + Website: "http://www.nuecesco.com" + }, + lookupRecommendations: [ + "Start from the official public-record jurisdiction instead of a listing-site geo ID." + ], + sourceIdentifierHints: { + parcelId: "14069438" + } +}; + +test("assessProperty auto-enriches public-record data from address and asks for recipient email", async () => { + const result = await assessProperty( + { + address: "4141 Whiteley Dr, Corpus Christi, TX 78418", + listingSourceUrl: "https://www.zillow.com/homedetails/example", + listingGeoId: "233290", + parcelId: "14069438" + }, + { + resolvePublicRecordsFn: async () => samplePublicRecords + } + ); + + assert.equal(result.ok, true); + assert.equal(result.needsRecipientEmails, true); + assert.equal(result.outputPath, null); + assert.match(result.message, /target email/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"); + assert.equal(result.reportPayload.photoReview?.status, "not completed"); + assert.match( + String(result.reportPayload.verdict?.offerGuidance), + /listing, photo, comp, and carry analysis still required/i + ); + assert.deepEqual(result.reportPayload.recipientEmails, []); +}); + +test("assessProperty renders a PDF when recipient email is present", async () => { + const outputPath = path.join(os.tmpdir(), `property-assess-command-${Date.now()}.pdf`); + const result = await assessProperty( + { + address: "4141 Whiteley Dr, Corpus Christi, TX 78418", + recipientEmails: ["buyer@example.com"], + output: outputPath + }, + { + resolvePublicRecordsFn: async () => samplePublicRecords + } + ); + + assert.equal(result.ok, true); + assert.equal(result.needsRecipientEmails, false); + assert.equal(result.outputPath, outputPath); + const stat = await fs.promises.stat(outputPath); + assert.ok(stat.size > 1000); +});