| import { spawn } from "node:child_process"; |
| import { resolve } from "node:path"; |
|
|
| const DEFAULT_SEARCH_URLS = { |
| brave: "https://api.search.brave.com/res/v1/web/search", |
| serpapi: "https://serpapi.com/search.json", |
| tavily: "https://api.tavily.com/search", |
| google_cse: "https://customsearch.googleapis.com/customsearch/v1", |
| }; |
|
|
| const TORONTO_OPEN_DATA_API = |
| "https://ckan0.cf.opendata.inter.prod-toronto.ca/api/3/action"; |
| const TORONTO_DATASETS = { |
| neighbourhoods: { |
| title: "City of Toronto Open Data: Neighbourhoods", |
| url: "https://open.toronto.ca/dataset/neighbourhoods/", |
| resourceId: "5e6095fc-1bef-4776-887c-28d37f722c51", |
| category: "official", |
| sourceType: "official_geography", |
| agentId: "recommendation", |
| reliability: "high", |
| snippet: "Live 158-neighbourhood boundary dataset used for Toronto social planning geography.", |
| }, |
| crimeRates: { |
| title: "Toronto Police / Toronto Open Data: Neighbourhood Crime Rates", |
| url: "https://open.toronto.ca/dataset/neighbourhood-crime-rates/", |
| resourceId: "d4160604-9f3e-4589-8821-9fd70fa350b3", |
| category: "safety", |
| sourceType: "official_safety", |
| agentId: "safety", |
| reliability: "high", |
| snippet: |
| "Annual reported crime counts and per-100,000 rates by Toronto neighbourhood.", |
| }, |
| activePermits: { |
| title: "City of Toronto Open Data: Building Permits - Active Permits", |
| url: "https://open.toronto.ca/dataset/building-permits-active-permits/", |
| resourceId: "6d0229af-bc54-46de-9c2b-26759b01dd05", |
| category: "development", |
| sourceType: "official_development", |
| agentId: "growth", |
| reliability: "high", |
| snippet: "Daily active building applications and permits for Toronto development context.", |
| }, |
| clearedPermits: { |
| title: "City of Toronto Open Data: Building Permits - Cleared Permits", |
| url: "https://open.toronto.ca/dataset/building-permits-cleared-permits/", |
| resourceId: "a96c0ba4-3026-402b-b09d-5b1268b8f810", |
| category: "development", |
| sourceType: "official_development", |
| agentId: "growth", |
| reliability: "high", |
| snippet: "Completed/closed building permits since 2017 for development momentum context.", |
| }, |
| ttcGtfs: { |
| title: "Toronto Open Data: Merged GTFS - TTC Routes and Schedules", |
| url: "https://open.toronto.ca/dataset/merged-gtfs-ttc-routes-and-schedules/", |
| resourceId: "c920e221-7a1c-488b-8c5b-6d8cd4e85eaf", |
| category: "transit", |
| sourceType: "official_transit", |
| agentId: "commute", |
| reliability: "high", |
| snippet: "TTC route definitions, stops, schedules, and stop locations in GTFS format.", |
| }, |
| statCanWds: { |
| title: "Statistics Canada Web Data Service", |
| url: "https://www.statcan.gc.ca/en/developers/wds", |
| category: "demographics", |
| sourceType: "official_demographics", |
| agentId: "recommendation", |
| reliability: "high", |
| snippet: |
| "Official Statistics Canada API for census, housing, income, labour, and population indicators.", |
| }, |
| googlePlaces: { |
| title: "Google Maps Platform: Places API", |
| url: "https://developers.google.com/maps/documentation/places/web-service/overview", |
| category: "reviews", |
| sourceType: "places_reviews", |
| agentId: "lifestyle", |
| reliability: "medium", |
| snippet: |
| "Location, place details, ratings, reviews, photos, and nearby amenity context through Google Places.", |
| }, |
| googleRoutes: { |
| title: "Google Maps Platform: Routes API", |
| url: "https://developers.google.com/maps/documentation/routes", |
| category: "transit", |
| sourceType: "routing", |
| agentId: "commute", |
| reliability: "medium", |
| snippet: |
| "Route computation for commute time estimates when a Google Maps Platform key is configured.", |
| }, |
| cmhcRentalMarket: { |
| title: "CMHC Housing Market Information Portal: Rental Market Data", |
| url: "https://www03.cmhc-schl.gc.ca/hmip-pimh/en", |
| category: "rent", |
| sourceType: "market_rent_report", |
| agentId: "affordability", |
| reliability: "medium", |
| snippet: |
| "CMHC rental-market data portal used as official Canadian rental market context before presenting rent claims.", |
| }, |
| rentalsCaRentReport: { |
| title: "Rentals.ca National Rent Report", |
| url: "https://rentals.ca/national-rent-report", |
| category: "rent", |
| sourceType: "market_rent_report", |
| agentId: "affordability", |
| reliability: "medium", |
| snippet: |
| "Canadian rent report used as market context for current rental conditions and trends.", |
| }, |
| }; |
|
|
| const officialDataCache = new Map(); |
|
|
| export async function runHousingResearch(localRun, env = process.env) { |
| if (env.RESEARCH_ENABLED === "0") { |
| return disabledResearch("RESEARCH_ENABLED is disabled", env); |
| } |
|
|
| const official = await runAuthoritativeResearch(localRun, env); |
| const provider = configuredSearchProvider(env); |
| if (provider === "disabled") { |
| const facts = deriveContextualFacts({ |
| targetNeighborhoods: official.targetNeighborhoods, |
| sources: official.sources, |
| facts: official.facts, |
| }); |
| return { |
| enabled: official.sources.length > 0, |
| provider: official.sources.length ? "official-open-data" : "disabled", |
| generatedAt: new Date().toISOString(), |
| targetNeighborhoods: official.targetNeighborhoods, |
| queries: [], |
| sources: official.sources, |
| facts, |
| limitations: [ |
| ...official.limitations, |
| missingSearchProviderMessage(env), |
| ], |
| }; |
| } |
|
|
| const queries = buildHousingResearchQueries(localRun, env); |
| const sources = [...official.sources]; |
| const queryReports = []; |
| const seenUrls = new Set(sources.map((source) => source.url)); |
| const maxSources = positiveInt(env.RESEARCH_MAX_SOURCES, 18); |
| const searchStartedAt = Date.now(); |
| const totalTimeoutMs = positiveInt(env.RESEARCH_TOTAL_TIMEOUT_MS, 45000); |
| let timeBudgetExhausted = false; |
|
|
| for (const query of queries) { |
| const elapsedMs = Date.now() - searchStartedAt; |
| if (elapsedMs >= totalTimeoutMs) { |
| timeBudgetExhausted = true; |
| queryReports.push({ |
| ...query, |
| resultCount: 0, |
| rawResultCount: 0, |
| error: `Research time budget exhausted after ${totalTimeoutMs}ms`, |
| }); |
| break; |
| } |
|
|
| const result = await searchWeb(query.query, provider, env, { |
| count: positiveInt(env.RESEARCH_RESULTS_PER_QUERY, 4), |
| timeoutMs: Math.max(1000, totalTimeoutMs - elapsedMs), |
| }); |
| const filteredResults = filterResultsForQuery(result.results, query); |
| queryReports.push({ |
| ...query, |
| resultCount: filteredResults.length, |
| rawResultCount: result.results.length, |
| error: result.error || null, |
| }); |
|
|
| for (const item of filteredResults) { |
| const url = normalizeUrl(item.url); |
| if (!url || seenUrls.has(url)) continue; |
| seenUrls.add(url); |
|
|
| sources.push({ |
| id: `S${sources.length + 1}`, |
| title: cleanText(item.title) || domainFromUrl(url), |
| url, |
| domain: domainFromUrl(url), |
| snippet: cleanText(item.snippet), |
| category: query.category, |
| neighborhood: query.neighborhood, |
| sourceType: query.sourceType, |
| agentId: query.agentId, |
| reliability: reliabilityFor(query.sourceType), |
| }); |
|
|
| if (sources.length >= maxSources) break; |
| } |
| if (sources.length >= maxSources) break; |
| } |
|
|
| const limitations = [...official.limitations, ...researchLimitations(provider)]; |
| if (timeBudgetExhausted) { |
| limitations.unshift( |
| `Web research stopped after ${totalTimeoutMs}ms to keep the agent response interactive.`, |
| ); |
| } |
|
|
| const targetNeighborhoods = localRun.ranked.slice(0, neighborhoodCount(env)).map((row) => row.name); |
| const facts = deriveContextualFacts({ |
| targetNeighborhoods, |
| sources, |
| facts: official.facts, |
| }); |
|
|
| return { |
| enabled: true, |
| provider, |
| generatedAt: new Date().toISOString(), |
| targetNeighborhoods, |
| queries: queryReports, |
| sources, |
| facts, |
| limitations, |
| }; |
| } |
|
|
| function deriveContextualFacts({ targetNeighborhoods, sources, facts }) { |
| const output = [...facts]; |
|
|
| for (const neighborhood of targetNeighborhoods) { |
| if (!findFact(output, neighborhood, "rent")) { |
| const rentSources = sourcesForNeighborhood(sources, neighborhood).filter((source) => |
| ["market_listing", "market_context", "market_rent_report"].includes(source.sourceType) || |
| source.agentId === "affordability", |
| ); |
| if (rentSources.length) { |
| output.push(sourceCoverageFact({ |
| id: `F-rent-coverage-${normalizeName(neighborhood)}`, |
| category: "rent", |
| neighborhood, |
| label: "Rent evidence", |
| value: `${rentSources.length} source${rentSources.length === 1 ? "" : "s"}`, |
| unit: "checked", |
| source: rentSources[0], |
| reliability: strongestReliability(rentSources), |
| detail: |
| "Market/listing sources were found for rental context. Exact rent stays hidden until listing/API values are parsed.", |
| })); |
| } |
| } |
|
|
| if (!findFact(output, neighborhood, "commute")) { |
| const commuteSources = sourcesForNeighborhood(sources, neighborhood).filter((source) => |
| ["routing", "official_transit"].includes(source.sourceType) || source.agentId === "commute", |
| ); |
| if (commuteSources.length) { |
| output.push(sourceCoverageFact({ |
| id: `F-commute-coverage-${normalizeName(neighborhood)}`, |
| category: "commute", |
| neighborhood, |
| label: "Commute evidence", |
| value: "Transit source", |
| unit: "available", |
| source: commuteSources[0], |
| reliability: strongestReliability(commuteSources), |
| detail: |
| "TTC/route data is available for commute research. Exact door-to-door time still needs a routing calculation.", |
| })); |
| } |
| } |
|
|
| if (!findFact(output, neighborhood, "growth")) { |
| const growthSources = sourcesForNeighborhood(sources, neighborhood).filter((source) => |
| ["official_development", "market_context"].includes(source.sourceType) || source.agentId === "growth", |
| ); |
| if (growthSources.length) { |
| output.push(sourceCoverageFact({ |
| id: `F-growth-coverage-${normalizeName(neighborhood)}`, |
| category: "growth", |
| neighborhood, |
| label: "Growth evidence", |
| value: `${growthSources.length} source${growthSources.length === 1 ? "" : "s"}`, |
| unit: "checked", |
| source: growthSources[0], |
| reliability: strongestReliability(growthSources), |
| detail: |
| "Permit/development sources were found for trend context. Numeric rent trend stays hidden until market data is computed.", |
| })); |
| } |
| } |
| } |
|
|
| return output; |
| } |
|
|
| function sourceCoverageFact({ id, category, neighborhood, label, value, unit, source, reliability, detail }) { |
| return { |
| id, |
| sourceId: source.id, |
| category, |
| neighborhood, |
| label, |
| value, |
| unit, |
| detail, |
| reliability, |
| generatedFrom: [source.sourceType], |
| }; |
| } |
|
|
| function sourcesForNeighborhood(sources, neighborhood) { |
| const target = normalizeName(neighborhood); |
| return sources.filter((source) => { |
| const sourceNeighborhood = normalizeName(source.neighborhood); |
| return ( |
| !sourceNeighborhood || |
| source.neighborhood.includes(",") || |
| sourceNeighborhood.includes(target) || |
| target.includes(sourceNeighborhood) |
| ); |
| }); |
| } |
|
|
| function findFact(facts, neighborhood, category) { |
| const target = normalizeName(neighborhood); |
| return facts.find((fact) => { |
| const factNeighborhood = normalizeName(fact.neighborhood); |
| return ( |
| fact.category === category && |
| (factNeighborhood === target || |
| factNeighborhood.includes(target) || |
| target.includes(factNeighborhood)) |
| ); |
| }); |
| } |
|
|
| function strongestReliability(sources) { |
| if (sources.some((source) => source.reliability === "high")) return "high"; |
| if (sources.some((source) => source.reliability === "medium")) return "medium"; |
| return "low"; |
| } |
|
|
| export function configuredSearchProvider(env = process.env) { |
| const requested = normalizeSearchProvider(env.SEARCH_PROVIDER || "auto"); |
| if (requested !== "auto") { |
| if (requested === "mcp_open_websearch" && mcpOpenWebSearchEnabled(env)) return "mcp_open_websearch"; |
| if (requested === "brave" && env.BRAVE_SEARCH_API_KEY) return "brave"; |
| if (requested === "serpapi" && env.SERPAPI_API_KEY) return "serpapi"; |
| if (requested === "tavily" && env.TAVILY_API_KEY) return "tavily"; |
| if (requested === "google_cse" && googleSearchConfigured(env)) { |
| return "google_cse"; |
| } |
| return "disabled"; |
| } |
|
|
| if (mcpOpenWebSearchEnabled(env)) return "mcp_open_websearch"; |
| if (googleSearchConfigured(env)) return "google_cse"; |
| if (env.SERPAPI_API_KEY) return "serpapi"; |
| if (env.BRAVE_SEARCH_API_KEY) return "brave"; |
| if (env.TAVILY_API_KEY) return "tavily"; |
| return "disabled"; |
| } |
|
|
| export function searchProviderStatus(env = process.env) { |
| const googleApiKeyConfigured = Boolean(googleSearchApiKey(env)); |
| const googleEngineConfigured = Boolean(googleSearchEngineId(env)); |
| const provider = configuredSearchProvider(env); |
|
|
| return { |
| provider, |
| requested: normalizeSearchProvider(env.SEARCH_PROVIDER || "auto"), |
| google: { |
| configured: googleApiKeyConfigured && googleEngineConfigured, |
| apiKeyConfigured: googleApiKeyConfigured, |
| engineConfigured: googleEngineConfigured, |
| missing: [ |
| googleApiKeyConfigured ? null : "GOOGLE_SEARCH_API_KEY", |
| googleEngineConfigured ? null : "GOOGLE_SEARCH_CX", |
| ].filter(Boolean), |
| aliases: { |
| apiKey: ["GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_API_KEY", "GOOGLE_API_KEY"], |
| engine: [ |
| "GOOGLE_SEARCH_CX", |
| "GOOGLE_SEARCH_ENGINE_ID", |
| "GOOGLE_CSE_ID", |
| "GOOGLE_PROGRAMMABLE_SEARCH_ENGINE_ID", |
| ], |
| }, |
| }, |
| providers: { |
| mcp_open_websearch: mcpOpenWebSearchEnabled(env), |
| google_cse: googleApiKeyConfigured && googleEngineConfigured, |
| serpapi: Boolean(env.SERPAPI_API_KEY), |
| brave: Boolean(env.BRAVE_SEARCH_API_KEY), |
| tavily: Boolean(env.TAVILY_API_KEY), |
| }, |
| }; |
| } |
|
|
| export async function runGoogleSearchProbe(queryText, env = process.env) { |
| const status = searchProviderStatus(env); |
| const query = String(queryText || "").trim() || "Toronto rental market neighbourhood reviews"; |
| if (!status.google.configured) { |
| return { |
| ok: false, |
| provider: "google_cse", |
| query, |
| results: [], |
| error: `Google Custom Search is missing ${status.google.missing.join(" and ")}.`, |
| status, |
| }; |
| } |
|
|
| const result = await searchGoogleCse(query, env, { |
| count: positiveInt(env.GOOGLE_SEARCH_TEST_RESULTS, 5), |
| }); |
|
|
| return { |
| ok: !result.error, |
| provider: "google_cse", |
| query, |
| results: result.results, |
| error: result.error || null, |
| status, |
| }; |
| } |
|
|
| function buildHousingResearchQueries(localRun, env) { |
| const neighborhoods = localRun.ranked.slice(0, neighborhoodCount(env)); |
| const budget = localRun.parsed.budget; |
| const cap = localRun.parsed.cap; |
| const maxQueries = positiveInt(env.RESEARCH_MAX_QUERIES, researchDepth(env) === "deep" ? 18 : 10); |
| const context = { |
| budget, |
| cap, |
| context: contextualPromptTerms(localRun.prompt), |
| destination: inferDestination(localRun.prompt), |
| }; |
| const prioritizedAgents = prioritizedResearchAgents(localRun.parsed); |
| const primaryQueries = []; |
| const secondaryQueries = []; |
|
|
| for (const neighborhood of neighborhoods) { |
| for (const agentId of prioritizedAgents) { |
| const agentQueries = queriesForAgent(agentId, neighborhood.name, context); |
| const [primary, ...secondary] = agentQueries; |
| if (primary) primaryQueries.push(primary); |
| secondaryQueries.push(...secondary); |
| } |
| } |
| const queries = [...primaryQueries, ...secondaryQueries]; |
|
|
| if (researchDepth(env) !== "deep") { |
| return interleaveByNeighborhood(queries).slice(0, maxQueries); |
| } |
| return interleaveByCategory(queries).slice(0, maxQueries); |
| } |
|
|
| async function runAuthoritativeResearch(localRun, env) { |
| if (env.OFFICIAL_DATA_ENABLED === "0") { |
| return { |
| targetNeighborhoods: [], |
| sources: [], |
| facts: [], |
| limitations: ["OFFICIAL_DATA_ENABLED is disabled."], |
| }; |
| } |
|
|
| const targetNeighborhoods = localRun.ranked |
| .slice(0, neighborhoodCount(env)) |
| .map((row) => row.name); |
| const sources = [ |
| sourceFromDataset("neighbourhoods", targetNeighborhoods), |
| sourceFromDataset("crimeRates", targetNeighborhoods), |
| sourceFromDataset("cmhcRentalMarket", targetNeighborhoods), |
| sourceFromDataset("rentalsCaRentReport", targetNeighborhoods), |
| sourceFromDataset("activePermits", targetNeighborhoods), |
| sourceFromDataset("clearedPermits", targetNeighborhoods), |
| sourceFromDataset("ttcGtfs", targetNeighborhoods), |
| sourceFromDataset("statCanWds", targetNeighborhoods), |
| ]; |
|
|
| if (env.GOOGLE_MAPS_API_KEY || env.GOOGLE_PLACES_API_KEY) { |
| sources.push(sourceFromDataset("googlePlaces", targetNeighborhoods)); |
| } |
| if (env.GOOGLE_MAPS_API_KEY || env.GOOGLE_ROUTES_API_KEY) { |
| sources.push(sourceFromDataset("googleRoutes", targetNeighborhoods)); |
| } |
|
|
| sources.forEach((source, index) => { |
| source.id = `S${index + 1}`; |
| }); |
|
|
| const facts = []; |
| const limitations = []; |
|
|
| try { |
| const crimeRows = await fetchCkanRecords(TORONTO_DATASETS.crimeRates.resourceId, env); |
| const crimeSource = sources.find((source) => source.sourceType === "official_safety"); |
| for (const neighborhood of targetNeighborhoods) { |
| const row = matchNeighborhoodRecord(neighborhood, crimeRows); |
| if (!row || !crimeSource) continue; |
| const fact = safetyFactFromCrimeRow(row, neighborhood, crimeSource.id); |
| if (fact) facts.push(fact); |
| } |
| } catch (error) { |
| limitations.push( |
| `Toronto crime-rate dataset could not be read: ${ |
| error instanceof Error ? error.message : "Unknown data error" |
| }`, |
| ); |
| } |
|
|
| |
| |
| |
| try { |
| facts.push(...(await fetchNeighborhoodGeoFacts(targetNeighborhoods, env))); |
| } catch { |
| |
| } |
|
|
| return { |
| targetNeighborhoods, |
| sources, |
| facts, |
| limitations, |
| }; |
| } |
|
|
| async function fetchNeighborhoodGeoFacts(neighborhoods, env) { |
| const facts = []; |
| const timeoutMs = positiveInt(env.WIKIPEDIA_TIMEOUT_MS, 8000); |
| const summaries = await Promise.all( |
| neighborhoods.map((neighborhood) => fetchWikipediaSummary(neighborhood, timeoutMs)), |
| ); |
|
|
| summaries.forEach((summary, index) => { |
| if (!summary?.coordinates) return; |
| facts.push({ |
| id: `F-geo-${normalizeName(neighborhoods[index])}`, |
| sourceId: "", |
| category: "geo", |
| neighborhood: neighborhoods[index], |
| label: "Approximate centre", |
| value: `${summary.coordinates.lat.toFixed(4)}, ${summary.coordinates.lon.toFixed(4)}`, |
| unit: "lat, lon", |
| detail: "Map placement only; not a user-facing housing claim.", |
| reliability: "medium", |
| generatedFrom: ["geo_lookup"], |
| lat: summary.coordinates.lat, |
| lon: summary.coordinates.lon, |
| }); |
| }); |
|
|
| return facts; |
| } |
|
|
| async function fetchWikipediaSummary(neighborhood, timeoutMs) { |
| const name = String(neighborhood || "").trim().replace(/\s+/g, " "); |
| if (!name) return null; |
| |
| |
| return ( |
| (await fetchWikipediaSummaryByTitle(name, timeoutMs)) || |
| (await fetchWikipediaSummaryByTitle(`${name}, Toronto`, timeoutMs)) |
| ); |
| } |
|
|
| async function fetchWikipediaSummaryByTitle(name, timeoutMs) { |
| const title = encodeURIComponent(name); |
| if (!title) return null; |
| const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${title}`; |
| try { |
| const response = await fetchWithTimeout(url, { |
| timeoutMs, |
| headers: { Accept: "application/json", "User-Agent": "6ixPulse/0.1 (housing research agent)" }, |
| }); |
| const data = await response.json(); |
| |
| if (data.type === "disambiguation" || !data.extract) return null; |
| const extract = cleanText(data.extract); |
| if (!/toronto|ontario/i.test(extract)) return null; |
| return { |
| title: data.title || name, |
| url: data.content_urls?.desktop?.page || `https://en.wikipedia.org/wiki/${title}`, |
| extract: extract.slice(0, 400), |
| coordinates: data.coordinates |
| ? { lat: Number(data.coordinates.lat), lon: Number(data.coordinates.lon) } |
| : null, |
| }; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function crimeRatesByLocation(neighborhoods, env = process.env) { |
| const out = {}; |
| if (env.OFFICIAL_DATA_ENABLED === "0" || !Array.isArray(neighborhoods) || !neighborhoods.length) return out; |
| try { |
| const rows = await fetchCkanRecords(TORONTO_DATASETS.crimeRates.resourceId, env); |
| for (const n of neighborhoods) { |
| const center = Array.isArray(n.center) ? n.center : null; |
| let row = center && Number.isFinite(center[0]) |
| ? rows.find((r) => pointInGeometry(center, r.geometry)) |
| : null; |
| if (!row) row = matchNeighborhoodRecord(n.name, rows); |
| if (!row) continue; |
| const fact = safetyFactFromCrimeRow(row, n.name, "police"); |
| if (!fact) continue; |
| const year = (String(fact.label).match(/20\d\d/) || [])[0]; |
| const countMatch = String(fact.detail).match(/^([\d,]+)/); |
| out[normalizeName(n.name)] = { |
| rate: Number(fact.value), |
| year, |
| count: countMatch ? Number(countMatch[1].replace(/,/g, "")) : null, |
| areaName: row.AREA_NAME, |
| }; |
| } |
| } catch { |
| |
| } |
| return out; |
| } |
|
|
| function pointInGeometry(point, geometry) { |
| let geo = geometry; |
| if (typeof geo === "string") { |
| try { |
| geo = JSON.parse(geo); |
| } catch { |
| return false; |
| } |
| } |
| if (!geo) return false; |
| const polygons = geo.type === "MultiPolygon" ? geo.coordinates : geo.type === "Polygon" ? [geo.coordinates] : []; |
| const [x, y] = point; |
| for (const polygon of polygons) { |
| const ring = polygon?.[0]; |
| if (Array.isArray(ring) && pointInRing(x, y, ring)) return true; |
| } |
| return false; |
| } |
|
|
| function pointInRing(x, y, ring) { |
| let inside = false; |
| for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { |
| const xi = ring[i][0]; |
| const yi = ring[i][1]; |
| const xj = ring[j][0]; |
| const yj = ring[j][1]; |
| if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; |
| } |
| return inside; |
| } |
|
|
| function sourceFromDataset(key, neighborhoods) { |
| const dataset = TORONTO_DATASETS[key]; |
| return { |
| id: "", |
| title: dataset.title, |
| url: dataset.url, |
| domain: domainFromUrl(dataset.url), |
| snippet: dataset.snippet, |
| category: dataset.category, |
| neighborhood: neighborhoods.join(", "), |
| sourceType: dataset.sourceType, |
| agentId: dataset.agentId, |
| reliability: dataset.reliability, |
| }; |
| } |
|
|
| async function fetchCkanRecords(resourceId, env) { |
| const cacheKey = `ckan:${resourceId}`; |
| const cached = officialDataCache.get(cacheKey); |
| if (cached && Date.now() - cached.createdAt < positiveInt(env.OFFICIAL_DATA_CACHE_MS, 600000)) { |
| return cached.records; |
| } |
|
|
| const url = new URL(`${env.TORONTO_OPEN_DATA_API || TORONTO_OPEN_DATA_API}/datastore_search`); |
| url.searchParams.set("resource_id", resourceId); |
| url.searchParams.set("limit", String(positiveInt(env.OFFICIAL_DATA_RECORD_LIMIT, 500))); |
|
|
| const response = await fetchWithTimeout(url, { |
| timeoutMs: positiveInt(env.RESEARCH_TIMEOUT_MS, 12000), |
| }); |
| const data = await response.json(); |
| const records = Array.isArray(data.result?.records) ? data.result.records : []; |
| officialDataCache.set(cacheKey, { createdAt: Date.now(), records }); |
| return records; |
| } |
|
|
| function matchNeighborhoodRecord(name, records) { |
| const target = normalizeName(name); |
| return ( |
| records.find((row) => normalizeName(row.AREA_NAME) === target) || |
| records.find((row) => normalizeName(row.AREA_NAME).includes(target)) || |
| records.find((row) => target.includes(normalizeName(row.AREA_NAME))) |
| ); |
| } |
|
|
| function safetyFactFromCrimeRow(row, requestedName, sourceId) { |
| const years = Object.keys(row) |
| .map((key) => key.match(/_RATE_(20\d{2})$/)?.[1]) |
| .filter(Boolean) |
| .map((year) => Number.parseInt(year, 10)); |
| const year = Math.max(...years); |
| if (!Number.isFinite(year)) return null; |
|
|
| const types = ["ASSAULT", "AUTOTHEFT", "BREAKENTER", "ROBBERY", "SHOOTING", "THEFTFROMMV"]; |
| const totalRate = types.reduce((sum, type) => { |
| const value = Number(row[`${type}_RATE_${year}`]); |
| return Number.isFinite(value) ? sum + value : sum; |
| }, 0); |
| const totalCount = types.reduce((sum, type) => { |
| const value = Number(row[`${type}_${year}`]); |
| return Number.isFinite(value) ? sum + value : sum; |
| }, 0); |
|
|
| return { |
| id: `F-safety-${normalizeName(requestedName)}`, |
| sourceId, |
| category: "safety", |
| neighborhood: row.AREA_NAME || requestedName, |
| label: `${year} selected reported-crime rate`, |
| value: Math.round(totalRate), |
| unit: "reported incidents per 100,000 residents", |
| detail: `${totalCount.toLocaleString()} selected reported incidents across assault, auto theft, break and enter, robbery, shootings, and theft from motor vehicle.`, |
| reliability: "high", |
| generatedFrom: types.flatMap((type) => [`${type}_${year}`, `${type}_RATE_${year}`]), |
| }; |
| } |
|
|
| function query(category, sourceType, neighborhood, text, agentId = "recommendation", domains = []) { |
| return { |
| id: "", |
| agentId, |
| category, |
| sourceType, |
| neighborhood, |
| query: text, |
| domains, |
| }; |
| } |
|
|
| function queriesForAgent(agentId, neighborhood, context) { |
| |
| |
| |
| const area = `${neighborhood} Toronto`; |
| const promptContext = context.context ? ` ${context.context}` : ""; |
| const budget = context.budget.toLocaleString(); |
| const destination = context.destination || "Union Station"; |
| const reddit = ["reddit.com/r/askTO", "reddit.com/r/TorontoRenting", "reddit.com/r/toronto"]; |
|
|
| if (agentId === "affordability") { |
| return [ |
| query("listings", "market_listing", neighborhood, `${area} 1 bedroom apartment for rent under $${budget}${promptContext}`, "affordability", ["rentals.ca", "realtor.ca", "condos.ca", "zumper.com", "apartments.com"]), |
| query("market", "market_context", neighborhood, `${area} average rent price one bedroom 2025 rental market report`, "affordability", ["rentals.ca", "zolo.ca", "housesigma.com", "wowa.ca"]), |
| ]; |
| } |
|
|
| if (agentId === "commute") { |
| return [ |
| query("official", "official_context", neighborhood, `${area} commute to ${destination} how long transit TTC minutes`, "commute", ["ttc.ca", "toronto.ca", "metrolinx.com", "gotransit.com"]), |
| query("community", "resident_discussion", neighborhood, `${area} commute to ${destination} reddit how long does it take`, "commute", reddit), |
| ]; |
| } |
|
|
| if (agentId === "safety") { |
| return [ |
| query("official", "official_context", neighborhood, `${area} neighbourhood crime rate is it safe to live`, "safety", ["toronto.ca", "torontopolice.on.ca", "data.torontopolice.on.ca"]), |
| query("community", "resident_discussion", neighborhood, `${area} is it a safe area reddit safety`, "safety", reddit), |
| ]; |
| } |
|
|
| if (agentId === "lifestyle") { |
| return [ |
| query("reviews", "local_reviews", neighborhood, `${area} best cafes restaurants parks walkability${promptContext}`, "lifestyle", ["blogto.com", "yelp.ca", "tripadvisor.ca", "torontolife.com"]), |
| query("community", "resident_discussion", neighborhood, `${area} what is it like to live reddit cafes parks vibe`, "lifestyle", reddit), |
| ]; |
| } |
|
|
| if (agentId === "growth") { |
| return [ |
| query("market", "market_context", neighborhood, `${area} new condo development construction property value trend`, "growth", ["urbantoronto.ca", "storeys.com", "renx.ca"]), |
| query("official", "official_context", neighborhood, `${area} building permits development planning growth`, "growth", ["toronto.ca", "open.toronto.ca"]), |
| ]; |
| } |
|
|
| return [ |
| query("community", "resident_discussion", neighborhood, `${area} pros and cons of living there rent commute safety`, "recommendation", reddit), |
| query("market", "market_context", neighborhood, `${area} neighbourhood guide what to know before renting`, "recommendation", ["torontolife.com", "blogto.com", "liv.rent"]), |
| ]; |
| } |
|
|
| function filterResultsForQuery(results, query) { |
| |
| |
| |
| |
| const relevant = results.filter((item) => mentionsTorontoArea(item, query.neighborhood)); |
| if (!relevant.length) return []; |
|
|
| const allowed = Array.isArray(query.domains) ? query.domains : []; |
| if (!allowed.length) return relevant; |
| |
| |
| const preferred = relevant.filter((item) => urlMatchesAllowedDomain(item.url, allowed)); |
| if (!preferred.length) return relevant; |
| const rest = relevant.filter((item) => !urlMatchesAllowedDomain(item.url, allowed)); |
| return [...preferred, ...rest]; |
| } |
|
|
| function mentionsTorontoArea(item, neighborhood) { |
| const hay = `${item.title || ""} ${item.snippet || ""} ${item.url || ""}`.toLowerCase(); |
| |
| |
| |
| if (hay.includes("toronto") || hay.includes("ontario") || hay.includes(" gta")) return true; |
| const phrase = String(neighborhood || "").toLowerCase().trim(); |
| return phrase.length > 4 && hay.includes(phrase); |
| } |
|
|
| function urlMatchesAllowedDomain(url, allowed) { |
| let parsed; |
| try { |
| parsed = new URL(url); |
| } catch { |
| return false; |
| } |
|
|
| const hostname = parsed.hostname.toLowerCase().replace(/^www\./, ""); |
| const path = parsed.pathname.toLowerCase(); |
|
|
| return allowed.some((entry) => { |
| const normalized = String(entry || "") |
| .trim() |
| .toLowerCase() |
| .replace(/^https?:\/\//, "") |
| .replace(/^www\./, ""); |
| if (!normalized) return false; |
| const [allowedHost, ...allowedPathParts] = normalized.split("/"); |
| const allowedPath = allowedPathParts.length ? `/${allowedPathParts.join("/")}` : ""; |
| const hostMatches = hostname === allowedHost || hostname.endsWith(`.${allowedHost}`); |
| if (!hostMatches) return false; |
| return !allowedPath || path.startsWith(allowedPath); |
| }); |
| } |
|
|
| async function searchWeb(queryText, provider, env, options) { |
| try { |
| if (provider === "mcp_open_websearch") return await searchMcpOpenWebSearch(queryText, env, options); |
| if (provider === "brave") return await searchBrave(queryText, env, options); |
| if (provider === "serpapi") return await searchSerpApi(queryText, env, options); |
| if (provider === "tavily") return await searchTavily(queryText, env, options); |
| if (provider === "google_cse") return await searchGoogleCse(queryText, env, options); |
| return { results: [], error: "Search provider is disabled" }; |
| } catch (error) { |
| return { |
| results: [], |
| error: error instanceof Error ? error.message : "Unknown search error", |
| }; |
| } |
| } |
|
|
| async function searchMcpOpenWebSearch(queryText, env, options) { |
| const result = await callMcpWebSearchTool(queryText, env, options); |
| return { |
| results: result.results.map((item) => ({ |
| title: item.title, |
| url: item.url, |
| snippet: item.snippet || `${item.engine || "MCP"} result`, |
| })), |
| }; |
| } |
|
|
| async function callMcpWebSearchTool(queryText, env, options) { |
| const configuredTimeoutMs = positiveInt(env.MCP_WEB_SEARCH_TIMEOUT_MS, 14000); |
| const timeoutMs = Math.min(configuredTimeoutMs, positiveInt(options.timeoutMs, configuredTimeoutMs)); |
| const command = env.MCP_WEB_SEARCH_COMMAND || process.execPath; |
| const args = mcpWebSearchArgs(env); |
| const toolPreference = env.MCP_WEB_SEARCH_TOOL || "web_search"; |
| const child = spawn(command, args, { |
| cwd: process.cwd(), |
| env: { ...process.env, ...env }, |
| stdio: ["pipe", "pipe", "pipe"], |
| }); |
|
|
| let nextId = 1; |
| let outputBuffer = ""; |
| let stderr = ""; |
| const pending = new Map(); |
| const timeout = setTimeout(() => { |
| child.kill("SIGTERM"); |
| for (const { reject } of pending.values()) reject(new Error("MCP web search timed out")); |
| pending.clear(); |
| }, timeoutMs); |
|
|
| child.stderr.on("data", (chunk) => { |
| stderr += chunk.toString("utf8"); |
| }); |
| child.stdout.on("data", (chunk) => { |
| outputBuffer += chunk.toString("utf8"); |
| let newlineIndex = outputBuffer.indexOf("\n"); |
| while (newlineIndex !== -1) { |
| const line = outputBuffer.slice(0, newlineIndex).trim(); |
| outputBuffer = outputBuffer.slice(newlineIndex + 1); |
| if (line) handleMcpLine(line, pending); |
| newlineIndex = outputBuffer.indexOf("\n"); |
| } |
| }); |
| child.on("error", (error) => { |
| for (const { reject } of pending.values()) reject(error); |
| pending.clear(); |
| }); |
|
|
| const request = (method, params = {}) => |
| new Promise((resolveRequest, rejectRequest) => { |
| const id = nextId++; |
| pending.set(id, { resolve: resolveRequest, reject: rejectRequest }); |
| child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); |
| }); |
|
|
| try { |
| await request("initialize", { |
| protocolVersion: "2025-06-18", |
| capabilities: {}, |
| clientInfo: { name: "6ixpulse", version: "0.1.0" }, |
| }); |
| child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); |
| const toolsResponse = await request("tools/list"); |
| const tools = Array.isArray(toolsResponse?.tools) ? toolsResponse.tools : []; |
| const toolName = |
| tools.find((tool) => tool.name === toolPreference)?.name || |
| tools.find((tool) => /search/i.test(tool.name))?.name || |
| toolPreference; |
| const callResponse = await request("tools/call", { |
| name: toolName, |
| arguments: { |
| query: queryText, |
| count: positiveInt(options.count, 6), |
| }, |
| }); |
| return parseMcpSearchResult(callResponse); |
| } finally { |
| clearTimeout(timeout); |
| child.stdin.end(); |
| child.kill("SIGTERM"); |
| if (stderr && env.MCP_WEB_SEARCH_DEBUG === "1") { |
| console.error(stderr.slice(0, 1000)); |
| } |
| } |
| } |
|
|
| function handleMcpLine(line, pending) { |
| let message; |
| try { |
| message = JSON.parse(line); |
| } catch { |
| return; |
| } |
| if (!message.id || !pending.has(message.id)) return; |
| const request = pending.get(message.id); |
| pending.delete(message.id); |
| if (message.error) { |
| request.reject(new Error(message.error.message || "MCP error")); |
| } else { |
| request.resolve(message.result); |
| } |
| } |
|
|
| function parseMcpSearchResult(callResponse) { |
| if (Array.isArray(callResponse?.structuredContent?.results)) { |
| return { results: normalizeMcpResults(callResponse.structuredContent.results) }; |
| } |
|
|
| const text = Array.isArray(callResponse?.content) |
| ? callResponse.content.map((item) => item?.text).filter(Boolean).join("\n") |
| : ""; |
| if (text) { |
| try { |
| const parsed = JSON.parse(text); |
| if (Array.isArray(parsed.results)) return { results: normalizeMcpResults(parsed.results) }; |
| } catch { |
| return { results: [] }; |
| } |
| } |
|
|
| return { results: [] }; |
| } |
|
|
| function normalizeMcpResults(results) { |
| return results |
| .map((item) => ({ |
| title: cleanText(item.title), |
| url: normalizeUrl(item.url), |
| snippet: cleanText(item.snippet), |
| engine: cleanText(item.engine), |
| })) |
| .filter((item) => item.title && item.url); |
| } |
|
|
| async function searchBrave(queryText, env, options) { |
| const url = new URL(env.BRAVE_SEARCH_URL || DEFAULT_SEARCH_URLS.brave); |
| url.searchParams.set("q", queryText); |
| url.searchParams.set("count", String(options.count)); |
| url.searchParams.set("country", env.SEARCH_COUNTRY || "CA"); |
| url.searchParams.set("search_lang", env.SEARCH_LANGUAGE || "en"); |
| url.searchParams.set("safesearch", "moderate"); |
|
|
| const response = await fetchWithTimeout(url, { |
| headers: { |
| Accept: "application/json", |
| "X-Subscription-Token": env.BRAVE_SEARCH_API_KEY, |
| }, |
| timeoutMs: positiveInt(env.RESEARCH_TIMEOUT_MS, 12000), |
| }); |
| const data = await response.json(); |
| return { |
| results: (data.web?.results || []).map((item) => ({ |
| title: item.title, |
| url: item.url, |
| snippet: item.description, |
| })), |
| }; |
| } |
|
|
| async function searchSerpApi(queryText, env, options) { |
| const url = new URL(env.SERPAPI_URL || DEFAULT_SEARCH_URLS.serpapi); |
| url.searchParams.set("engine", env.SERPAPI_ENGINE || "google"); |
| url.searchParams.set("q", queryText); |
| url.searchParams.set("api_key", env.SERPAPI_API_KEY); |
| url.searchParams.set("num", String(options.count)); |
| url.searchParams.set("gl", env.SEARCH_COUNTRY?.toLowerCase() || "ca"); |
| url.searchParams.set("hl", env.SEARCH_LANGUAGE || "en"); |
|
|
| const response = await fetchWithTimeout(url, { |
| timeoutMs: positiveInt(env.RESEARCH_TIMEOUT_MS, 12000), |
| }); |
| const data = await response.json(); |
| return { |
| results: (data.organic_results || []).map((item) => ({ |
| title: item.title, |
| url: item.link, |
| snippet: item.snippet, |
| })), |
| }; |
| } |
|
|
| async function searchTavily(queryText, env, options) { |
| const response = await fetchWithTimeout(env.TAVILY_URL || DEFAULT_SEARCH_URLS.tavily, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ |
| api_key: env.TAVILY_API_KEY, |
| query: queryText, |
| search_depth: researchDepth(env) === "deep" ? "advanced" : "basic", |
| max_results: options.count, |
| include_answer: false, |
| include_raw_content: false, |
| }), |
| timeoutMs: positiveInt(env.RESEARCH_TIMEOUT_MS, 12000), |
| }); |
| const data = await response.json(); |
| return { |
| results: (data.results || []).map((item) => ({ |
| title: item.title, |
| url: item.url, |
| snippet: item.content, |
| })), |
| }; |
| } |
|
|
| async function searchGoogleCse(queryText, env, options) { |
| const url = new URL(env.GOOGLE_CSE_URL || DEFAULT_SEARCH_URLS.google_cse); |
| url.searchParams.set("key", googleSearchApiKey(env)); |
| url.searchParams.set("cx", googleSearchEngineId(env)); |
| url.searchParams.set("q", queryText); |
| url.searchParams.set("num", String(Math.min(options.count, 10))); |
| url.searchParams.set("gl", env.SEARCH_COUNTRY?.toLowerCase() || "ca"); |
| url.searchParams.set("hl", env.SEARCH_LANGUAGE || "en"); |
| url.searchParams.set("lr", env.SEARCH_LANGUAGE_RESTRICT || "lang_en"); |
| url.searchParams.set("safe", env.SEARCH_SAFE || "active"); |
| url.searchParams.set("filter", env.SEARCH_DUPLICATE_FILTER || "1"); |
|
|
| const response = await fetchWithTimeout(url, { |
| timeoutMs: positiveInt(env.RESEARCH_TIMEOUT_MS, 12000), |
| }); |
| const data = await response.json(); |
| return { |
| results: (data.items || []).map((item) => ({ |
| title: item.title, |
| url: item.link, |
| snippet: item.snippet, |
| })), |
| }; |
| } |
|
|
| function normalizeSearchProvider(value) { |
| const provider = String(value || "auto") |
| .trim() |
| .toLowerCase() |
| .replace(/[-\s]+/g, "_"); |
| if ( |
| [ |
| "mcp", |
| "open_websearch", |
| "open_web_search", |
| "mcp_websearch", |
| "mcp_web_search", |
| "mcp_open_websearch", |
| "mcp_open_web_search", |
| ].includes(provider) |
| ) { |
| return "mcp_open_websearch"; |
| } |
| if (["google", "google_cse", "google_custom_search", "custom_search"].includes(provider)) { |
| return "google_cse"; |
| } |
| if (["serp", "serp_api"].includes(provider)) return "serpapi"; |
| return provider; |
| } |
|
|
| function mcpOpenWebSearchEnabled(env) { |
| return env.MCP_WEB_SEARCH_ENABLED !== "0"; |
| } |
|
|
| function mcpWebSearchArgs(env) { |
| if (env.MCP_WEB_SEARCH_ARGS) { |
| const raw = env.MCP_WEB_SEARCH_ARGS.trim(); |
| if (raw.startsWith("[")) { |
| try { |
| const parsed = JSON.parse(raw); |
| if (Array.isArray(parsed)) return parsed.map(String); |
| } catch { |
| return raw.split(/\s+/).filter(Boolean); |
| } |
| } |
| return raw.split(/\s+/).filter(Boolean); |
| } |
| return [resolve(process.cwd(), "server/open-websearch-mcp.mjs")]; |
| } |
|
|
| function prioritizedResearchAgents(parsed) { |
| const weights = { |
| affordability: parsed.weights.affordability, |
| commute: Math.max(parsed.weights.commute, parsed.weights.transit), |
| safety: parsed.weights.safety, |
| lifestyle: Math.max(parsed.weights.lifestyle, parsed.weights.amenities), |
| growth: parsed.weights.growth, |
| recommendation: 0.8, |
| }; |
|
|
| return Object.entries(weights) |
| .sort((a, b) => b[1] - a[1]) |
| .map(([agent]) => agent); |
| } |
|
|
| function inferDestination(prompt) { |
| const source = String(prompt || ""); |
| if (/union\s+station|near\s+union|\bunion\b/i.test(source)) return "Union Station"; |
| const workNear = source.match(/work(?:ing)?\s+(?:near|at|in)\s+([A-Za-z0-9 &.'-]{3,42})/i); |
| if (workNear) return cleanText(workNear[1]); |
| const commuteTo = source.match(/commut\w*\s+(?:to|near)\s+([A-Za-z0-9 &.'-]{3,42})/i); |
| if (commuteTo) return cleanText(commuteTo[1]); |
| return "Union Station"; |
| } |
|
|
| function contextualPromptTerms(prompt) { |
| const source = String(prompt || "").toLowerCase(); |
| const terms = []; |
| if (/studio|bachelor/.test(source)) terms.push("studio bachelor"); |
| if (/1\s*bed|one bed|one-bedroom|1-bedroom/.test(source)) terms.push("1 bedroom"); |
| if (/2\s*bed|two bed|two-bedroom|2-bedroom/.test(source)) terms.push("2 bedroom"); |
| if (/condo/.test(source)) terms.push("condo"); |
| if (/apartment|rental|rent/.test(source)) terms.push("apartment rental"); |
| if (/caf|coffee/.test(source)) terms.push("cafes coffee"); |
| if (/park|trail|green/.test(source)) terms.push("parks green space"); |
| if (/quiet|family/.test(source)) terms.push("quiet residential"); |
| if (/night|bar|restaurant|food/.test(source)) terms.push("restaurants nightlife"); |
| if (/safe|safety|crime/.test(source)) terms.push("safety crime"); |
| return [...new Set(terms)].join(" "); |
| } |
|
|
| function googleSearchConfigured(env) { |
| return Boolean(googleSearchApiKey(env) && googleSearchEngineId(env)); |
| } |
|
|
| function googleSearchApiKey(env) { |
| return env.GOOGLE_SEARCH_API_KEY || env.GOOGLE_CSE_API_KEY || env.GOOGLE_API_KEY || ""; |
| } |
|
|
| function googleSearchEngineId(env) { |
| return ( |
| env.GOOGLE_SEARCH_CX || |
| env.GOOGLE_SEARCH_ENGINE_ID || |
| env.GOOGLE_CSE_ID || |
| env.GOOGLE_PROGRAMMABLE_SEARCH_ENGINE_ID || |
| "" |
| ); |
| } |
|
|
| async function fetchWithTimeout(input, options = {}) { |
| const timeoutMs = options.timeoutMs || 12000; |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), timeoutMs); |
| try { |
| const response = await fetch(input, { |
| ...options, |
| signal: controller.signal, |
| }); |
| if (!response.ok) { |
| const body = await response.text().catch(() => ""); |
| throw new Error(`HTTP ${response.status}: ${body.slice(0, 220) || response.statusText}`); |
| } |
| return response; |
| } finally { |
| clearTimeout(timeout); |
| } |
| } |
|
|
| function disabledResearch(reason, env) { |
| return { |
| enabled: false, |
| provider: configuredSearchProvider(env), |
| generatedAt: new Date().toISOString(), |
| targetNeighborhoods: [], |
| queries: [], |
| sources: [], |
| facts: [], |
| limitations: [reason], |
| }; |
| } |
|
|
| function researchLimitations(provider) { |
| if (provider === "mcp_open_websearch") { |
| return [ |
| "Open-WebSearch MCP is optional; results can be blocked, rate-limited, incomplete, or stale.", |
| "Search snippets are source discovery only. User-facing rent, commute, safety, review, and market claims still require computed facts.", |
| "Use official feeds and licensed listing sources for production-grade housing decisions.", |
| ]; |
| } |
|
|
| return [ |
| `Search results come from ${provider}; snippets can be incomplete or stale.`, |
| "Listing pages, Google reviews, and social posts should be verified directly before making housing decisions.", |
| "Use official APIs or licensed feeds for listing, review, and social-source research.", |
| ]; |
| } |
|
|
| function missingSearchProviderMessage(env) { |
| const status = searchProviderStatus(env); |
| if (status.requested === "google_cse" && status.google.missing.length) { |
| return `Google Custom Search is selected but missing ${status.google.missing.join( |
| " and ", |
| )}. Add those values for listings, Reddit, review, and market-source research.`; |
| } |
|
|
| return "No web search provider is configured. Keep SEARCH_PROVIDER=disabled for official-data-only runs, or add GOOGLE_SEARCH_API_KEY + GOOGLE_SEARCH_CX, SERPAPI_API_KEY, BRAVE_SEARCH_API_KEY, or TAVILY_API_KEY."; |
| } |
|
|
| function reliabilityFor(sourceType) { |
| if (sourceType === "official_context") return "high"; |
| if (sourceType === "market_listing" || sourceType === "market_context") return "medium"; |
| if (sourceType === "local_reviews") return "medium"; |
| return "anecdotal"; |
| } |
|
|
| function researchDepth(env) { |
| return (env.RESEARCH_DEPTH || "deep").toLowerCase() === "standard" ? "standard" : "deep"; |
| } |
|
|
| function neighborhoodCount(env) { |
| return Math.max(1, Math.min(4, positiveInt(env.RESEARCH_NEIGHBORHOODS, researchDepth(env) === "deep" ? 3 : 2))); |
| } |
|
|
| function positiveInt(value, fallback) { |
| const parsed = Number.parseInt(value, 10); |
| return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; |
| } |
|
|
| function interleaveByNeighborhood(queries) { |
| return queries; |
| } |
|
|
| function interleaveByCategory(queries) { |
| const grouped = new Map(); |
| for (const item of queries) { |
| const key = item.agentId || item.category; |
| if (!grouped.has(key)) grouped.set(key, []); |
| grouped.get(key).push(item); |
| } |
| const output = []; |
| while ([...grouped.values()].some((items) => items.length)) { |
| for (const items of grouped.values()) { |
| const item = items.shift(); |
| if (item) output.push(item); |
| } |
| } |
| return output; |
| } |
|
|
| function normalizeUrl(url) { |
| if (!url || typeof url !== "string") return ""; |
| try { |
| const parsed = new URL(url); |
| parsed.hash = ""; |
| return parsed.toString(); |
| } catch { |
| return ""; |
| } |
| } |
|
|
| function domainFromUrl(url) { |
| try { |
| return new URL(url).hostname.replace(/^www\./, ""); |
| } catch { |
| return ""; |
| } |
| } |
|
|
| function cleanText(value) { |
| return String(value || "") |
| .replace(/<[^>]*>/g, " ") |
| .replace(/\s+/g, " ") |
| .trim(); |
| } |
|
|
| function normalizeName(value) { |
| return String(value || "") |
| .toLowerCase() |
| .replace(/&/g, "and") |
| .replace(/[^a-z0-9]+/g, "") |
| .trim(); |
| } |
|
|