diff --git a/docs/property-assessor.md b/docs/property-assessor.md index b5d428d..78d1f46 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -119,6 +119,8 @@ scripts/property-assessor locate-public-records --address "" Current behavior: - uses the official Census geocoder +- when Census address matching misses, falls back to an external address geocoder and then resolves official Census geographies from coordinates +- retries fallback geocoding without the unit suffix when a condo/unit-qualified address is too specific for the fallback provider - returns matched address, county/state/FIPS, and block GEOID context - for Texas, returns: - Texas Comptroller county directory page diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index 1f3ce13..bcddb5c 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -140,6 +140,8 @@ When `--assessment-purpose` is present, it should also: This command currently: - resolves the address through the official Census geocoder +- when Census address matching misses, falls back to an external address geocoder and then resolves official Census geographies from coordinates +- retries the fallback geocoder without the unit suffix when a condo/unit-qualified address is too specific for the fallback provider - returns county/state/FIPS/GEOID context - for Texas, resolves the official Texas Comptroller county directory page - returns the county appraisal district and tax assessor/collector links when available diff --git a/skills/property-assessor/src/assessment.ts b/skills/property-assessor/src/assessment.ts index 7095c78..295b96e 100644 --- a/skills/property-assessor/src/assessment.ts +++ b/skills/property-assessor/src/assessment.ts @@ -127,16 +127,16 @@ function normalizePurpose(value: string): string { function getPurposeGuidance(purpose: string): PurposeGuidance { const normalized = purpose.toLowerCase(); - if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) { + if (/(daughter|son|college|student|school|campus)/i.test(normalized)) { return { label: purpose, - snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`, - like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.", - caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.", - comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.", - carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.", - diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.", - verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.` + snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.", + like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.", + caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.", + comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.", + carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.", + diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.", + verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.` }; } @@ -153,16 +153,16 @@ function getPurposeGuidance(purpose: string): PurposeGuidance { }; } - if (/(daughter|son|college|student|school|campus)/i.test(normalized)) { + if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) { return { label: purpose, - snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.", - like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.", - caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.", - comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.", - carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.", - diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.", - verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.` + snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`, + like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.", + caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.", + comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.", + carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.", + diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.", + verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.` }; } diff --git a/skills/property-assessor/src/public-records.ts b/skills/property-assessor/src/public-records.ts index e7650a3..27e5aff 100644 --- a/skills/property-assessor/src/public-records.ts +++ b/skills/property-assessor/src/public-records.ts @@ -1,5 +1,8 @@ export const CENSUS_GEOCODER_URL = "https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress"; +export const CENSUS_COORDINATES_URL = + "https://geocoding.geo.census.gov/geocoder/geographies/coordinates"; +export const NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search"; export const TEXAS_COUNTY_DIRECTORY_URL = "https://comptroller.texas.gov/taxes/property-tax/county-directory/"; export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes"; @@ -76,6 +79,28 @@ function stripHtml(value: string): string { return output.replace(/^,\s*|\s*,\s*$/g, ""); } +function buildFallbackAddressCandidates(address: string): string[] { + const normalized = collapseWhitespace(address); + if (!normalized) return []; + + const candidates = [normalized]; + const [streetPartRaw, ...restParts] = normalized.split(","); + const streetPart = collapseWhitespace(streetPartRaw); + const locality = restParts.map((part) => collapseWhitespace(part)).filter(Boolean).join(", "); + const strippedStreet = collapseWhitespace( + streetPart.replace( + /\s+(?:apt|apartment|unit|suite|ste)\s*[a-z0-9-]+$/i, + "" + ).replace(/\s+#\s*[a-z0-9-]+$/i, "") + ); + + if (strippedStreet && strippedStreet !== streetPart) { + candidates.push(locality ? `${strippedStreet}, ${locality}` : strippedStreet); + } + + return candidates; +} + function extractAnchorHref(fragment: string): string | null { const match = fragment.match(/]+href="([^"]+)"/i); if (!match) return null; @@ -87,6 +112,7 @@ function extractAnchorHref(fragment: string): string | null { async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{ match: any; censusGeocoderUrl: string; + usedFallbackGeocoder: boolean; }> { const query = new URLSearchParams({ address, @@ -97,10 +123,70 @@ async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{ const url = `${CENSUS_GEOCODER_URL}?${query.toString()}`; const payload = JSON.parse(await fetchText(url)); const matches = payload?.result?.addressMatches || []; - if (!matches.length) { + if (matches.length) { + return { + match: matches[0], + censusGeocoderUrl: url, + usedFallbackGeocoder: false + }; + } + + let fallbackMatch: any = null; + for (const candidateAddress of buildFallbackAddressCandidates(address)) { + const fallbackQuery = new URLSearchParams({ + q: candidateAddress, + format: "jsonv2", + limit: "1", + countrycodes: "us", + addressdetails: "1" + }); + const fallbackUrl = `${NOMINATIM_SEARCH_URL}?${fallbackQuery.toString()}`; + const fallbackPayload = JSON.parse(await fetchText(fallbackUrl)); + fallbackMatch = Array.isArray(fallbackPayload) ? fallbackPayload[0] : null; + if (fallbackMatch) { + break; + } + } + if (!fallbackMatch) { throw new PublicRecordsLookupError(`No Census geocoder match found for address: ${address}`); } - return { match: matches[0], censusGeocoderUrl: url }; + + const latitude = Number(fallbackMatch.lat); + const longitude = Number(fallbackMatch.lon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + throw new PublicRecordsLookupError( + `Fallback geocoder returned invalid coordinates for address: ${address}` + ); + } + + const coordinateQuery = new URLSearchParams({ + x: String(longitude), + y: String(latitude), + benchmark: "Public_AR_Current", + vintage: "Current_Current", + format: "json" + }); + const coordinateUrl = `${CENSUS_COORDINATES_URL}?${coordinateQuery.toString()}`; + const coordinatePayload = JSON.parse(await fetchText(coordinateUrl)); + const geographies = coordinatePayload?.result?.geographies; + if (!geographies) { + throw new PublicRecordsLookupError( + `Census coordinate geographies lookup failed for address: ${address}` + ); + } + + return { + match: { + matchedAddress: collapseWhitespace(fallbackMatch.display_name || address), + coordinates: { + x: longitude, + y: latitude + }, + geographies + }, + censusGeocoderUrl: coordinateUrl, + usedFallbackGeocoder: true + }; } async function findTexasCountyHref(countyName: string, fetchText: FetchLike): Promise { @@ -221,7 +307,10 @@ export async function resolvePublicRecords( } = {} ): Promise { const fetchText = options.fetchText || defaultFetchText; - const { match, censusGeocoderUrl } = await geocodeAddress(address, fetchText); + const { match, censusGeocoderUrl, usedFallbackGeocoder } = await geocodeAddress( + address, + fetchText + ); const geographies = match.geographies || {}; const state = (geographies.States || [{}])[0]; const county = (geographies.Counties || [{}])[0]; @@ -238,6 +327,11 @@ export async function resolvePublicRecords( "Try official address search first on the appraisal district site.", "If the listing exposes parcel/APN/account identifiers, use them as stronger search keys than ZPID or listing geo IDs." ]; + if (usedFallbackGeocoder) { + lookupRecommendations.push( + "The Census address lookup missed this address, so a fallback geocoder was used to obtain coordinates before resolving official Census geographies." + ); + } if (state.STUSAB === "TX" && county.NAME) { const offices = await fetchTexasCountyOffices(county.NAME, fetchText); diff --git a/skills/property-assessor/tests/assessment.test.ts b/skills/property-assessor/tests/assessment.test.ts index 320a90b..4903200 100644 --- a/skills/property-assessor/tests/assessment.test.ts +++ b/skills/property-assessor/tests/assessment.test.ts @@ -224,3 +224,45 @@ test("assessProperty renders a PDF when recipient email is present", async () => const stat = await fs.promises.stat(outputPath); assert.ok(stat.size > 1000); }); + +test("assessProperty prioritizes student housing guidance over investment fallback keywords", async () => { + const result = await assessProperty( + { + address: "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412", + assessmentPurpose: + "college housing for daughter attending TAMU-CC; prioritize proximity, safety/livability, and resale/rental fallback" + }, + { + resolvePublicRecordsFn: async () => samplePublicRecords, + discoverListingSourcesFn: async () => ({ + attempts: ["Zillow discovery located a property page from the address."], + zillowUrl: + "https://www.zillow.com/homedetails/1011-Ennis-Joslin-Rd-APT-235-Corpus-Christi-TX-78412/28848927_zpid/", + harUrl: null + }), + extractPhotoDataFn: async (source, url) => ({ + source, + requestedUrl: url, + finalUrl: url, + expectedPhotoCount: 20, + complete: true, + photoCount: 20, + imageUrls: ["https://photos.example/1.jpg"], + notes: [`${source} extractor succeeded.`] + }) + } + ); + + assert.match( + String(result.reportPayload?.verdict?.offerGuidance), + /daughter|student|practicality|safety/i + ); + assert.doesNotMatch( + String(result.reportPayload?.verdict?.offerGuidance), + /income property|investment property/i + ); + assert.match( + String(result.reportPayload?.carryView?.[0]), + /parent-risk|upkeep burden|renting/i + ); +}); diff --git a/skills/property-assessor/tests/public-records.test.ts b/skills/property-assessor/tests/public-records.test.ts index 41f1373..8945fd1 100644 --- a/skills/property-assessor/tests/public-records.test.ts +++ b/skills/property-assessor/tests/public-records.test.ts @@ -80,3 +80,119 @@ test("resolvePublicRecords uses Census and Texas county directory", async () => assert.equal(payload.sourceIdentifierHints.parcelId, "14069438"); assert.match(payload.lookupRecommendations.join(" "), /listing geo IDs as regional hints only/i); }); + +test("resolvePublicRecords falls back to coordinate geocoding when Census address lookup misses", async () => { + const coordinatePayload = { + result: { + geographies: { + States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }], + Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }], + "2020 Census Blocks": [{ GEOID: "483550031013005" }] + } + } + }; + + const fallbackFetchText = async (url: string): Promise => { + if (url.includes("geocoding.geo.census.gov") && url.includes("onelineaddress")) { + return JSON.stringify({ result: { addressMatches: [] } }); + } + if (url.includes("nominatim.openstreetmap.org/search")) { + return JSON.stringify([ + { + lat: "27.708000", + lon: "-97.360000", + display_name: "1011 Ennis Joslin Rd Apt 235, Corpus Christi, TX 78412" + } + ]); + } + if (url.includes("geocoding.geo.census.gov") && url.includes("geographies/coordinates")) { + return JSON.stringify(coordinatePayload); + } + if (url.endsWith("/county-directory/")) { + return countyIndexHtml; + } + if (url.endsWith("/county-directory/nueces.php")) { + return countyPageHtml; + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const payload = await resolvePublicRecords( + "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412", + { + fetchText: fallbackFetchText + } + ); + + assert.equal( + payload.matchedAddress, + "1011 Ennis Joslin Rd Apt 235, Corpus Christi, TX 78412" + ); + assert.equal(payload.county.name, "Nueces County"); + assert.equal(payload.state.code, "TX"); + assert.equal(payload.latitude, 27.708); + assert.equal(payload.longitude, -97.36); + assert.match( + payload.lookupRecommendations.join(" "), + /fallback geocoder/i + ); +}); + +test("resolvePublicRecords retries fallback geocoding without the unit suffix", async () => { + const seenFallbackQueries: string[] = []; + const coordinatePayload = { + result: { + geographies: { + States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }], + Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }], + "2020 Census Blocks": [{ GEOID: "483550031013005" }] + } + } + }; + + const retryingFetchText = async (url: string): Promise => { + if (url.includes("geocoding.geo.census.gov") && url.includes("onelineaddress")) { + return JSON.stringify({ result: { addressMatches: [] } }); + } + if (url.includes("nominatim.openstreetmap.org/search")) { + const query = new URL(url).searchParams.get("q") || ""; + seenFallbackQueries.push(query); + if (query.includes("APT 235")) { + return "[]"; + } + if (query === "1011 Ennis Joslin Rd, Corpus Christi, TX 78412") { + return JSON.stringify([ + { + lat: "27.6999080", + lon: "-97.3338107", + display_name: "Ennis Joslin Road, Corpus Christi, Nueces County, Texas, 78412, United States" + } + ]); + } + } + if (url.includes("geocoding.geo.census.gov") && url.includes("geographies/coordinates")) { + return JSON.stringify(coordinatePayload); + } + if (url.endsWith("/county-directory/")) { + return countyIndexHtml; + } + if (url.endsWith("/county-directory/nueces.php")) { + return countyPageHtml; + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const payload = await resolvePublicRecords( + "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412", + { + fetchText: retryingFetchText + } + ); + + assert.deepEqual(seenFallbackQueries, [ + "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412", + "1011 Ennis Joslin Rd, Corpus Christi, TX 78412" + ]); + assert.equal(payload.county.name, "Nueces County"); + assert.equal(payload.state.code, "TX"); +});