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

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