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 stop names from a bus schedule display. Each entry follows this structure: * A short stop code (typically 4 letters, derived from the stop name by removing vowels or compressing syllables) * A 2-letter zone or bay code (ignore this; it is not needed) * An optional "T" indicating terminus (ignore for naming purposes) Your task is to infer the full stop names from the short codes. Guidelines: * Treat the code as a compressed version of a stop or locality name * Use pattern recognition rather than strict letter matching * Do not assume any specific country, region or city — infer purely from the codes and their sequence * Use the sequence of stops to inform your guesses (adjacent stops should make sense as a connected route) * If a code is ambiguous, prefer the interpretation that best fits the surrounding stops 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 }); });