SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
7.9 KB · 165 lines typescript
Raw Blame History
1/**2 * Query interpretation for /search: pulls structured filters (country, operator, metro, status, min / max MW,3 * facility type, AI, entity = project, opening year) out of free text and returns the remaining text.4 * Pure function — unit-tested.5 */6import type { FacilityStatus, FacilityType, SearchResponse } from "@dci/core";7import { COUNTRY_ALIASES, normalizeStatus, parseMw } from "@dci/core";89export type Interpreted = NonNullable<SearchResponse["interpreted"]>;1011export interface InterpretOptions {12  /** operator candidates already matched by trigram against the whole query (best first) */13  operators?: Array<{ slug: string; name: string; similarity?: number }>;14  /** extra country names from the countries table: [{ iso2, name }] */15  countries?: Array<{ iso2: string; name: string }>;16  /** metro / market names (and aliases) from the metros table */17  metros?: Array<{ slug: string; name: string; aliases?: string[] }>;18}1920const STATUS_PHRASES: Array<[RegExp, FacilityStatus]> = [21  [/\b(under[- ]construction|being built|in construction|construction)\b/i, "under_construction"],22  [/\b(partially[- ]operational)\b/i, "partially_operational"],23  [/\b(operational|live|open(ed)?|existing|operating|in service)\b/i, "operational"],24  [/\b(planned|upcoming|future|announced|coming soon|pipeline|in development)\b/i, "announced"],25  [/\b(proposed)\b/i, "proposed"],26  [/\b(permitting|planning application|zoning)\b/i, "permitting"],27  [/\b(approved)\b/i, "approved"],28  [/\b(rumou?red)\b/i, "rumored"],29  [/\b(delayed|on hold|paused)\b/i, "delayed"],30  [/\b(cancell?ed|scrapped)\b/i, "cancelled"],31  [/\b(closed|decommissioned)\b/i, "closed"],32  [/\b(expansion)\b/i, "expansion"],33];3435const TYPE_PHRASES: Array<[RegExp, FacilityType]> = [36  [/\b(ai|artificial intelligence|gpu|ai[- ]ready)\b/i, "ai"],37  [/\b(hyperscale|hyperscaler)\b/i, "hyperscale"],38  [/\b(colocation|colo|retail colo)\b/i, "colocation"],39  [/\b(cloud region|cloud)\b/i, "cloud_region"],40  [/\b(edge)\b/i, "edge"],41  [/\b(carrier hotel)\b/i, "carrier_hotel"],42  [/\b(internet exchange|ixp?)\b/i, "internet_exchange"],43  [/\b(hpc|supercomput(er|ing))\b/i, "hpc"],44  [/\b(sovereign)\b/i, "sovereign_cloud"],45  [/\b(wholesale)\b/i, "wholesale"],46  [/\b(enterprise)\b/i, "enterprise"],47  [/\b(government)\b/i, "government"],48];4950const STOP = new Set(["data", "center", "centers", "centre", "centres", "datacenter", "datacenters", "datacentre", "datacentres", "dc", "facility", "facilities", "in", "at", "near", "the", "of", "and", "with", "over", "above", "more", "than", "least", "min", "minimum", "campus", "campuses", "site", "sites", "under", "below", "less", "max", "maximum", "up", "to", "by", "opening", "opens", "open", "project", "projects"]);5152const MW_UNIT = String.raw`(\d+(?:[.,]\d+)?\s*\+?\s*(?:gw|gigawatts?|mw|megawatts?))\b`;53const MAX_RE = new RegExp(String.raw`(?:<=?|under|below|less than|at most|max(?:imum)?|up to|smaller than|no more than)\s*${MW_UNIT}`, "i");54const MIN_RE = new RegExp(String.raw`(?:>=?|over|above|more than|at least|min(?:imum)?|\+|larger than|bigger than)?\s*${MW_UNIT}`, "i");55const AI_RE = /\b(ai|artificial intelligence|gpu|gpus|ai[- ]ready|ai[- ]factory|ai[- ]factories)\b/i;56const PROJECT_RE = /\b(projects?|pipeline projects?)\b/i;57const YEAR_RE = /\b(before|by|until|prior to|pre|after|from|since|post|opening|opens|open(?:ed|ing)? in|in|for|due)?\s*((?:19|20)\d{2})\b/i;5859function esc(s: string): string {60  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");61}6263function strip(text: string, re: RegExp): string {64  return text.replace(re, " ").replace(/\s+/g, " ").trim();65}6667export function interpretQuery(raw: string, opts: InterpretOptions = {}): Interpreted {68  const out: Interpreted = {};69  let text = raw.replace(/\s+/g, " ").trim();70  if (!text) return out;7172  // 1. Power figures: "<500 MW" / "under 500 MW" → maxMw; "100 MW", "over 50mw", "> 1 GW", "100+ MW" → minMw73  const maxMatch = text.match(MAX_RE);74  if (maxMatch) {75    const mw = parseMw(maxMatch[1]!.replace("+", ""));76    if (mw != null && mw > 0) out.maxMw = mw;77    text = strip(text, new RegExp(esc(maxMatch[0]), "i"));78  }79  const mwMatch = text.match(MIN_RE);80  if (mwMatch) {81    const mw = parseMw(mwMatch[1]!.replace("+", ""));82    if (mw != null && mw > 0) out.minMw = mw;83    text = strip(text, new RegExp(esc(mwMatch[0]), "i"));84  }8586  // 2. Opening year phrases: "opening 2028", "before 2028", "after 2027" (a year right after a MW figure was consumed above)87  const yearMatch = text.match(YEAR_RE);88  if (yearMatch) {89    const y = Number(yearMatch[2]);90    const cue = (yearMatch[1] ?? "").toLowerCase();91    if (y >= 1990 && y <= 2060) {92      out.year = y;93      out.yearOp = /^(before|by|until|prior to|pre)$/.test(cue) ? "before" : /^(after|from|since|post)$/.test(cue) ? "after" : "in";94      text = strip(text, new RegExp(esc(yearMatch[0]), "i"));95    }96  }9798  // 3. Operator (candidates supplied by the caller from a trigram lookup on the full query)99  for (const op of opts.operators ?? []) {100    const re = new RegExp(`(^|[^a-z0-9])${esc(op.name)}([^a-z0-9]|$)`, "i");101    if (re.test(text) || (op.similarity ?? 0) >= 0.8) {102      out.operator = op.slug;103      if (re.test(text)) text = strip(text, re);104      else if ((op.similarity ?? 0) >= 0.85) text = "";105      break;106    }107  }108109  // 4. Metro / market names (longest first, word boundaries, aliases included)110  if (opts.metros?.length) {111    const cands = opts.metros.flatMap((m) => [m.name, ...(m.aliases ?? [])].filter((n) => n && n.length >= 3).map((n) => ({ slug: m.slug, n }))).sort((a, b) => b.n.length - a.n.length);112    for (const c of cands) {113      const re = new RegExp(`(^|[^\\p{L}\\p{N}])${esc(c.n)}([^\\p{L}\\p{N}]|$)`, "iu");114      if (re.test(text)) { out.metro = c.slug; text = strip(text, re); break; }115    }116  }117118  // 5. Country: bare ISO2 token in caps, known aliases, or the countries table119  const iso2Token = raw.match(/(^|\s)([A-Z]{2})(\s|$)/);120  if (iso2Token && /^(US|UK|CA|DE|FR|NL|GB|IE|SG|JP|AU|IN|BR|ES|IT|SE|NO|FI|DK|PL|CH|AT|BE|PT|MX|ZA|AE|SA|KR|CN|HK|TW|MY|ID|TH|VN|PH|NZ|CL|AR|CO|IL|TR|QA|KE|NG|EG)$/.test(iso2Token[2]!)) {121    const code = iso2Token[2]! === "UK" ? "GB" : iso2Token[2]!;122    out.countryIso2 = code;123    text = strip(text, new RegExp(`(^|\\s)${iso2Token[2]}(\\s|$)`));124  }125  if (!out.countryIso2) {126    const lower = text.toLowerCase();127    const keys = Object.keys(COUNTRY_ALIASES).filter((k) => k !== "usa_" && k.length > 2).sort((a, b) => b.length - a.length);128    for (const k of keys) {129      const re = new RegExp(`(^|[^a-z])${esc(k)}([^a-z]|$)`, "i");130      if (re.test(lower)) { out.countryIso2 = COUNTRY_ALIASES[k]!; text = strip(text, re); break; }131    }132  }133  if (!out.countryIso2 && opts.countries?.length) {134    for (const c of [...opts.countries].sort((a, b) => b.name.length - a.name.length)) {135      const re = new RegExp(`(^|[^a-z])${esc(c.name)}([^a-z]|$)`, "i");136      if (re.test(text)) { out.countryIso2 = c.iso2; text = strip(text, re); break; }137    }138  }139140  // 6. Entity: "projects" (plural is the strongest signal) — "announced" / "planned" alone only set the status141  if (PROJECT_RE.test(text)) { out.entity = "project"; text = strip(text, PROJECT_RE); }142143  // 7. Status144  for (const [re, st] of STATUS_PHRASES) {145    if (re.test(text)) { out.status = normalizeStatus(st) ?? st; text = strip(text, re); break; }146  }147148  // 8. AI flag (kept alongside the facility type so callers can match is_ai / ai_evidence rather than the type column)149  if (AI_RE.test(text)) out.ai = true;150151  // 9. Facility type152  for (const [re, ty] of TYPE_PHRASES) {153    if (re.test(text)) { out.facilityType = ty; text = strip(text, re); break; }154  }155156  // 10. Remaining text (drop generic words)157  const rest = text.split(" ").filter((t) => t && !STOP.has(t.toLowerCase())).join(" ").trim();158  if (rest) out.text = rest;159  return out;160}161162export function hasFilters(i: Interpreted): boolean {163  return Boolean(i.countryIso2 || i.operator || i.metro || i.status || i.minMw != null || i.maxMw != null || i.facilityType || i.ai || i.entity || i.year != null);164}165