Public Access
1
0

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:
pguerrerox
2026-04-05 18:05:04 +00:00
parent a1ba5ee093
commit cc00a439bf
29 changed files with 1860 additions and 82 deletions
+103 -1
View File
@@ -23,6 +23,89 @@ export async function createSearchJob(db: DbClient, userId: string, payload: Run
return mapSearchJobRow(result.rows[0]);
}
export async function createSearchJobForCoordinates(
db: DbClient,
userId: string,
input: {
name: string;
city?: string | null;
address?: string | null;
postalCode?: string | null;
countryCode?: string | null;
radiusKm: number;
businessType: string;
keywords?: string | null;
lat: number;
lng: number;
deepResearchBatchId?: string | null;
postalAreaId?: string | null;
},
) {
const now = new Date().toISOString();
const result = await db.query<SearchJobRow>(
`
insert into public.search_jobs (
user_id,
deep_research_batch_id,
postal_area_id,
name,
city,
address,
postal_code,
country_code,
radius_km,
business_type,
keywords,
status,
total_results,
search_center_geom,
started_at,
created_at,
updated_at
)
values (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
'running',
0,
ST_SetSRID(ST_MakePoint($13, $12), 4326)::geography,
$14,
$14,
$14
)
returning id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
status, total_results, started_at, completed_at, created_at, updated_at
`,
[
userId,
input.deepResearchBatchId ?? null,
input.postalAreaId ?? null,
input.name,
input.city ?? null,
input.address ?? null,
input.postalCode ?? null,
input.countryCode ?? null,
input.radiusKm,
input.businessType,
input.keywords ?? null,
input.lat,
input.lng,
now,
],
);
return mapSearchJobRow(result.rows[0]);
}
export async function updateSearchJobCenter(db: DbClient, jobId: string, lat: number, lng: number) {
await db.query(
`
@@ -76,7 +159,11 @@ export async function upsertBusiness(db: DbClient, userId: string, business: Bus
values (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14, $15, $16,
case when $15 is not null and $16 is not null then ST_SetSRID(ST_MakePoint($16, $15), 4326)::geography else null end,
case
when $15::double precision is not null and $16::double precision is not null
then ST_SetSRID(ST_MakePoint($16::double precision, $15::double precision), 4326)::geography
else null::geography
end,
$17::jsonb, $18, $19, $20
)
on conflict (user_id, source, external_source_id)
@@ -161,6 +248,21 @@ export async function listSearchJobsForUser(db: DbClient, userId: string, limit
return result.rows.map(mapSearchJobRow);
}
export async function listSearchJobsForBatch(db: DbClient, userId: string, batchId: string) {
const result = await db.query<SearchJobRow>(
`
select id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
status, total_results, started_at, completed_at, created_at, updated_at
from public.search_jobs
where user_id = $1 and deep_research_batch_id = $2
order by created_at asc
`,
[userId, batchId],
);
return result.rows.map(mapSearchJobRow);
}
export async function getSearchJobForUser(db: DbClient, userId: string, jobId: string) {
const result = await db.query<SearchJobRow>(
`
+121 -35
View File
@@ -1,15 +1,76 @@
import type { Pool } from 'pg';
import { getEnv } from '../config/env.js';
import { buildBusinessPayload, collectPlaces, geocodeLocation } from './google-places.js';
import { completeSearchJob, createSearchJob, failSearchJob, updateSearchJobCenter, upsertBusiness, upsertSearchJobResult } from './repository.js';
import { completeSearchJob, createSearchJob, createSearchJobForCoordinates, failSearchJob, updateSearchJobCenter, upsertBusiness, upsertSearchJobResult } from './repository.js';
import type { RunSearchInput, RunSearchResult } from './types.js';
export async function runSearchForUser(db: Pool, userId: string, payload: RunSearchInput): Promise<RunSearchResult> {
function buildMatchedKeywords(keywords?: string) {
return keywords
? keywords
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean)
: [];
}
async function executeSearchJobAtCoordinates(
db: Pool,
input: {
jobId: string;
userId: string;
lat: number;
lng: number;
radiusKm: number;
businessType: string;
keywords?: string;
queryContextTerms?: string[];
},
) {
const env = getEnv();
if (!env.GOOGLE_MAPS_SERVER_KEY) {
throw new Error('GOOGLE_MAPS_SERVER_KEY is required for running research.');
}
const places = await collectPlaces({
apiKey: env.GOOGLE_MAPS_SERVER_KEY,
textQuery: [input.businessType, input.keywords, ...(input.queryContextTerms ?? [])].filter(Boolean).join(' '),
lat: input.lat,
lng: input.lng,
radiusKm: input.radiusKm,
});
const matchedKeywords = buildMatchedKeywords(input.keywords);
const capturedAt = new Date().toISOString();
let totalResults = 0;
for (const [index, place] of places.entries()) {
if (!place.id || !place.displayName?.text) {
continue;
}
const businessId = await upsertBusiness(db, input.userId, buildBusinessPayload(place, input.businessType));
await upsertSearchJobResult(db, {
userId: input.userId,
searchJobId: input.jobId,
businessId,
matchedKeywords: matchedKeywords.length > 0 ? matchedKeywords : null,
rank: index + 1,
capturedAt,
});
totalResults += 1;
}
return completeSearchJob(db, input.jobId, totalResults);
}
export async function runSearchForUser(db: Pool, userId: string, payload: RunSearchInput): Promise<RunSearchResult> {
const job = await createSearchJob(db, userId, payload);
const jobId = job.id;
try {
const env = getEnv();
if (!env.GOOGLE_MAPS_SERVER_KEY) {
throw new Error('GOOGLE_MAPS_SERVER_KEY is required for running research.');
}
@@ -17,49 +78,74 @@ export async function runSearchForUser(db: Pool, userId: string, payload: RunSea
const geocoded = await geocodeLocation(payload.location, env.GOOGLE_MAPS_SERVER_KEY);
await updateSearchJobCenter(db, jobId, geocoded.lat, geocoded.lng);
const places = await collectPlaces({
apiKey: env.GOOGLE_MAPS_SERVER_KEY,
textQuery: [payload.businessType, payload.keywords].filter(Boolean).join(' '),
const completedJob = await executeSearchJobAtCoordinates(db, {
jobId,
userId,
lat: geocoded.lat,
lng: geocoded.lng,
radiusKm: payload.radiusKm,
businessType: payload.businessType,
keywords: payload.keywords,
});
const matchedKeywords = payload.keywords
? payload.keywords
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean)
: [];
const capturedAt = new Date().toISOString();
let totalResults = 0;
for (const [index, place] of places.entries()) {
if (!place.id || !place.displayName?.text) {
continue;
}
const businessId = await upsertBusiness(db, userId, buildBusinessPayload(place, payload.businessType));
await upsertSearchJobResult(db, {
userId,
searchJobId: jobId,
businessId,
matchedKeywords: matchedKeywords.length > 0 ? matchedKeywords : null,
rank: index + 1,
capturedAt,
});
totalResults += 1;
}
const completedJob = await completeSearchJob(db, jobId, totalResults);
return {
job: completedJob,
totalResults,
totalResults: completedJob.totalResults,
};
} catch (error) {
await failSearchJob(db, jobId);
throw error;
}
}
export async function runSearchForPostalArea(db: Pool, userId: string, input: {
name: string;
city?: string | null;
address?: string | null;
postalCode?: string | null;
countryCode?: string | null;
radiusKm: number;
businessType: string;
keywords?: string;
lat: number;
lng: number;
deepResearchBatchId: string;
postalAreaId: string;
queryContextTerms?: string[];
}): Promise<RunSearchResult> {
const job = await createSearchJobForCoordinates(db, userId, {
name: input.name,
city: input.city,
address: input.address,
postalCode: input.postalCode,
countryCode: input.countryCode,
radiusKm: input.radiusKm,
businessType: input.businessType,
keywords: input.keywords,
lat: input.lat,
lng: input.lng,
deepResearchBatchId: input.deepResearchBatchId,
postalAreaId: input.postalAreaId,
});
try {
const completedJob = await executeSearchJobAtCoordinates(db, {
jobId: job.id,
userId,
lat: input.lat,
lng: input.lng,
radiusKm: input.radiusKm,
businessType: input.businessType,
keywords: input.keywords,
queryContextTerms: input.queryContextTerms,
});
return {
job: completedJob,
totalResults: completedJob.totalResults,
};
} catch (error) {
await failSearchJob(db, job.id);
throw error;
}
}