Add property assessor assess command

This commit is contained in:
2026-03-27 22:35:57 -05:00
parent e6d987d725
commit c58a2a43c8
6 changed files with 438 additions and 11 deletions

View File

@@ -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 "<street-address>"
scripts/property-assessor assess --address "<street-address>" --recipient-email "<target@example.com>"
```
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 "<url>"`
- `node har-photos.js "<url>"`
@@ -200,6 +228,7 @@ Template reference:
Current renderer:
```bash
scripts/property-assessor assess --address "<street-address>" --recipient-email "<target@example.com>"
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```
@@ -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:

View File

@@ -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 "<street-address>"
scripts/property-assessor assess --address "<street-address>" --recipient-email "<target@example.com>"
scripts/property-assessor locate-public-records --address "<street-address>"
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```
`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 "<street-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 "<street-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 "<street-address>" --recipient-email "<target@example.com>"
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```

View File

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

View File

@@ -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<AssessPropertyResult> {
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
};
}

View File

@@ -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 "<address>" [--recipient-email "<email>"] [--output "<report.pdf>"] [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
locate-public-records --address "<address>" [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
render-report --input "<payload.json>" --output "<report.pdf>"
`);
@@ -27,6 +29,22 @@ async function main(): Promise<void> {
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");

View File

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