spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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