import { fail, handleOptions, json } from "../_shared/http.ts"; import { requireUser } from "../_shared/supabase.ts"; const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); const OPENAI_MODEL = Deno.env.get("OPENAI_MODEL") ?? "gpt-5.2"; type RequestBody = { channel_id?: string; stop_names?: string[]; }; type OpenAiAlias = { original?: string; estimated?: string; }; const prompt = `You are interpreting abbreviated station names from a rail replacement service display. Each entry follows this structure: * A 4-letter station code (derived from the real station name, often by removing vowels or compressing syllables) * A 2-letter stop code (ignore this; it is ambiguous and not needed) * An optional "T" indicating terminus (ignore for naming purposes) Your task is to infer the full station names from the 4-letter codes. Guidelines: * Treat the 4-letter code as a compressed version of a real station name (e.g. consonant-heavy, missing vowels, or merged syllables) * Use pattern recognition rather than strict decoding * Prefer real-world plausibility over perfect letter matching * Assume all stations are on the same rail corridor or geographically connected route * Use the sequence of stops to inform your guesses (adjacent stations should make sense geographically) * If a code is slightly irregular, prioritise what fits the route best over what matches the letters exactly Output: Return JSON with shape {"aliases":[{"original":"","estimated":""}]}. Return only valid JSON with no markdown or explanation.`; function extractCode(rawStopName: string): string { const firstToken = rawStopName.trim().split(/\s+/)[0] ?? ""; const normalized = firstToken.toUpperCase().replace(/[^A-Z0-9]/g, ""); return normalized.slice(0, 8); } function hasStandaloneTerminusToken(rawStopName: string): boolean { const tokens = rawStopName .trim() .split(/\s+/) .map((token) => token.toUpperCase()) .filter((token) => token.length > 0); if (tokens.length === 0) return false; return tokens[tokens.length - 1] === "T"; } function applyStandRule(rawStopName: string, estimatedName: string): string { if (!hasStandaloneTerminusToken(rawStopName)) return estimatedName; if (estimatedName.toLowerCase().endsWith(" stand")) return estimatedName; return `${estimatedName} Stand`; } Deno.serve(async (req) => { const preflight = handleOptions(req); if (preflight) return preflight; if (req.method !== "POST") return fail("Method not allowed", 405); if (!OPENAI_API_KEY) return fail("OPENAI_API_KEY is not configured", 500); const { client, user, error: userError } = await requireUser(req); if (!user) return fail(userError ?? "Unauthorized", 401); let body: RequestBody; try { body = await req.json(); } catch { return fail("Invalid JSON body"); } const channelId = (body.channel_id ?? "").trim(); const stopNames = Array.isArray(body.stop_names) ? body.stop_names : []; if (!channelId) return fail("channel_id is required"); if (stopNames.length == 0) return json({ aliases: [] }); if (stopNames.length > 1000) return fail("stop_names is too large"); const { data: channel, error: channelError } = await client .from("channels") .select("id, type") .eq("id", channelId) .eq("type", "operations") .maybeSingle(); if (channelError) return fail(channelError.message, 400); if (!channel) return fail("forbidden", 403); const rawByCode = new Map>(); for (const stopName of stopNames) { const raw = `${stopName ?? ""}`.trim(); if (!raw) continue; const code = extractCode(raw); if (!code) continue; const existing = rawByCode.get(code) ?? new Set(); existing.add(raw); rawByCode.set(code, existing); } const codes = [...rawByCode.keys()]; if (codes.length == 0) return json({ aliases: [] }); const openAiResponse = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${OPENAI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: OPENAI_MODEL, temperature: 0, response_format: { type: "json_object" }, messages: [ { role: "system", content: prompt }, { role: "user", content: JSON.stringify({ channel_id: channelId, codes, }), }, ], }), }); if (!openAiResponse.ok) { const details = await openAiResponse.text(); return fail(`OpenAI request failed: ${details}`, 502); } const completion = await openAiResponse.json(); const content = completion?.choices?.[0]?.message?.content; if (typeof content !== "string" || content.trim().length == 0) { return fail("OpenAI returned no content", 502); } let parsed: { aliases?: OpenAiAlias[] }; try { parsed = JSON.parse(content); } catch { return fail("OpenAI returned invalid JSON", 502); } const codeToEstimated = new Map(); for (const row of parsed.aliases ?? []) { const original = `${row.original ?? ""}`.trim().toUpperCase(); const estimated = `${row.estimated ?? ""}`.trim(); if (!original || !estimated) continue; codeToEstimated.set(original, estimated); } const aliases = []; for (const [code, rawStops] of rawByCode.entries()) { const estimated = codeToEstimated.get(code); if (!estimated) continue; for (const raw of rawStops) { const aliasWithStandRule = applyStandRule(raw, estimated); aliases.push({ raw_stop_name: raw, alias_stop_name: aliasWithStandRule, source: "ai", }); } } if (aliases.length > 0) { const upsertRows = aliases.map((a) => ({ channel_id: channelId, raw_stop_name: a.raw_stop_name, alias_stop_name: a.alias_stop_name, source: a.source, created_by: user.id, })); const { error: upsertError } = await client .from("operations_stop_aliases") .upsert(upsertRows, { onConflict: "channel_id,raw_stop_name_normalized", ignoreDuplicates: false }); if (upsertError) { console.error("[operations-stop-alias-enhance] failed to persist aliases:", upsertError.message); } } return json({ aliases }); });