feat: add deep research planning and postal batch runs
Add a dedicated Deep Research view with postal-area preview overlays, batch execution, and bundled map results. Also add postal dataset import tooling and fix local API networking and research insert issues needed to support the new workflow.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { getDbPool } from '../../server/src/db/pool.js';
|
||||
|
||||
async function run() {
|
||||
const pool = getDbPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('begin');
|
||||
await client.query('truncate table public.postal_area_neighbors');
|
||||
await client.query(`
|
||||
insert into public.postal_area_neighbors (postal_area_id, neighbor_postal_area_id)
|
||||
select source.id, neighbor.id
|
||||
from public.postal_areas source
|
||||
join public.postal_areas neighbor
|
||||
on source.country_code = neighbor.country_code
|
||||
and source.id <> neighbor.id
|
||||
and ST_Touches(source.geom, neighbor.geom)
|
||||
`);
|
||||
await client.query('commit');
|
||||
|
||||
const summary = await client.query<{ count: string }>('select count(*)::text as count from public.postal_area_neighbors');
|
||||
console.log(`Built ${summary.rows[0]?.count ?? '0'} postal adjacency links.`);
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
@@ -0,0 +1,121 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getDbPool } from '../../server/src/db/pool.js';
|
||||
import { getFeatureGeometry, getStringProperty, normalizePostalCode, readFeatureCollection, type PostalDatasetConfig } from './postal-import-utils.js';
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const datasetsRoot = path.resolve(currentDir, '../datasets/postal');
|
||||
|
||||
const datasetConfigs: PostalDatasetConfig[] = [
|
||||
{
|
||||
countryCode: 'US',
|
||||
label: 'US ZIP/ZCTA',
|
||||
filePath: process.env.POSTAL_US_DATASET_PATH || path.join(datasetsRoot, 'us_zcta.geojson'),
|
||||
postalCodeKeys: ['postal_code', 'zip', 'zcta', 'GEOID20', 'ZCTA5CE20', 'ZCTA5CE10'],
|
||||
displayNameKeys: ['display_name', 'name', 'NAMELSAD20', 'GEOID20', 'postal_code'],
|
||||
},
|
||||
{
|
||||
countryCode: 'CA',
|
||||
label: 'Canada FSA',
|
||||
filePath: process.env.POSTAL_CA_DATASET_PATH || path.join(datasetsRoot, 'ca_fsa.geojson'),
|
||||
postalCodeKeys: ['postal_code', 'fsa', 'CFSAUID', 'CFSAUID24'],
|
||||
displayNameKeys: ['display_name', 'name', 'CFSAUID', 'postal_code'],
|
||||
},
|
||||
];
|
||||
|
||||
async function importDataset(config: PostalDatasetConfig) {
|
||||
const pool = getDbPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const collection = await readFeatureCollection(config.filePath);
|
||||
let insertedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
await client.query('begin');
|
||||
|
||||
for (const [index, feature] of collection.features.entries()) {
|
||||
const rawPostalCode = getStringProperty(feature.properties, config.postalCodeKeys);
|
||||
|
||||
if (!rawPostalCode) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedPostalCode = normalizePostalCode(config.countryCode, rawPostalCode);
|
||||
if (!normalizedPostalCode) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayName = getStringProperty(feature.properties, config.displayNameKeys) || normalizedPostalCode;
|
||||
const geometry = getFeatureGeometry(feature, config.filePath, index);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.postal_areas (
|
||||
country_code,
|
||||
postal_code,
|
||||
display_name,
|
||||
normalized_postal_code,
|
||||
geom,
|
||||
centroid,
|
||||
search_radius_m,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
values (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON($5), 4326)),
|
||||
ST_Centroid(ST_SetSRID(ST_GeomFromGeoJSON($5), 4326))::geography,
|
||||
greatest(1000, round(sqrt(ST_Area(ST_SetSRID(ST_GeomFromGeoJSON($5), 4326)::geography) / pi()))::integer),
|
||||
$6::jsonb,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (country_code, normalized_postal_code)
|
||||
do update set
|
||||
postal_code = excluded.postal_code,
|
||||
display_name = excluded.display_name,
|
||||
geom = excluded.geom,
|
||||
centroid = excluded.centroid,
|
||||
search_radius_m = excluded.search_radius_m,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
config.countryCode,
|
||||
rawPostalCode.trim(),
|
||||
displayName,
|
||||
normalizedPostalCode,
|
||||
JSON.stringify(geometry),
|
||||
JSON.stringify(feature.properties ?? {}),
|
||||
],
|
||||
);
|
||||
|
||||
insertedCount += 1;
|
||||
}
|
||||
|
||||
await client.query('commit');
|
||||
console.log(`Imported ${insertedCount} ${config.label} areas from ${config.filePath}. Skipped ${skippedCount}.`);
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
for (const config of datasetConfigs) {
|
||||
await importDataset(config);
|
||||
}
|
||||
|
||||
await getDbPool().end();
|
||||
}
|
||||
|
||||
await run();
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dotenv/config';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
export type FeatureGeometry = {
|
||||
type: 'Polygon' | 'MultiPolygon';
|
||||
coordinates: unknown;
|
||||
};
|
||||
|
||||
export type GeoJsonFeature = {
|
||||
type: 'Feature';
|
||||
properties?: Record<string, unknown> | null;
|
||||
geometry?: FeatureGeometry | null;
|
||||
};
|
||||
|
||||
export type GeoJsonFeatureCollection = {
|
||||
type: 'FeatureCollection';
|
||||
features: GeoJsonFeature[];
|
||||
};
|
||||
|
||||
export type PostalDatasetConfig = {
|
||||
countryCode: 'US' | 'CA';
|
||||
label: string;
|
||||
filePath: string;
|
||||
postalCodeKeys: string[];
|
||||
displayNameKeys: string[];
|
||||
};
|
||||
|
||||
export async function readFeatureCollection(filePath: string) {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as GeoJsonFeatureCollection;
|
||||
|
||||
if (parsed.type !== 'FeatureCollection' || !Array.isArray(parsed.features)) {
|
||||
throw new Error(`Dataset at ${filePath} is not a valid GeoJSON FeatureCollection.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function normalizePostalCode(countryCode: 'US' | 'CA', rawPostalCode: string) {
|
||||
const trimmed = rawPostalCode.trim();
|
||||
|
||||
if (countryCode === 'US') {
|
||||
const digits = trimmed.replace(/\D/g, '').slice(0, 5);
|
||||
return digits.length === 5 ? digits : null;
|
||||
}
|
||||
|
||||
const compact = trimmed.replace(/\s+/g, '').toUpperCase();
|
||||
return compact.length >= 3 ? compact.slice(0, 3) : null;
|
||||
}
|
||||
|
||||
export function getStringProperty(properties: Record<string, unknown> | null | undefined, keys: string[]) {
|
||||
if (!properties) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = properties[key];
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getFeatureGeometry(feature: GeoJsonFeature, filePath: string, index: number) {
|
||||
if (!feature.geometry || (feature.geometry.type !== 'Polygon' && feature.geometry.type !== 'MultiPolygon')) {
|
||||
throw new Error(`Feature ${index} in ${filePath} is missing a Polygon or MultiPolygon geometry.`);
|
||||
}
|
||||
|
||||
return feature.geometry;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
console.log('Postal dataset import is not implemented yet.');
|
||||
console.log('Next step: add ZIP/ZCTA and Canada FSA import scripts into db/datasets and db/scripts.');
|
||||
Reference in New Issue
Block a user