190 lines
6.2 KiB
TypeScript
190 lines
6.2 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);
|
|
}
|
|
|
|
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<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) {
|
|
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 });
|
|
});
|