Roadbound-BRR/supabase/functions/operations-stop-alias-enhance/index.ts

155 lines
5 KiB
TypeScript

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":"<code>","estimated":"<name>"}]}.
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);
}
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<string, Set<string>>();
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<string>();
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<string, string>();
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) {
aliases.push({
raw_stop_name: raw,
alias_stop_name: estimated,
source: "ai",
});
}
}
return json({ aliases });
});