Sporalize-api / src /agent-session.ts
Shoraky's picture
Validate agent action IDs before saving feedback
2cfbff5
Raw History Blame Contribute Delete
11.1 kB
import type { SupabaseClient } from "@supabase/supabase-js";
import { env } from "./config.js";
type ActionResult = { id: string; label: string; rawAnalytics?: Record<string, unknown>; inActionMetrics?: { name: string; value: unknown; unit?: string }[] };
type SessionResult = { id: string; playerId: string; actions: ActionResult[] };
// Scoped to one generation: reuse reads without serving stale coach annotations
// to a later request or retaining player data in a process-wide cache.
class ReferenceCache {
results = new Map<string, ActionResult[] | null>();
pages = new Map<string, any[]>();
}
function numericMetrics(action: ActionResult) {
const values = new Map<string, number>();
const metrics = [...Object.entries(action.rawAnalytics ?? {}).map(([name, value]) => ({ name, value })), ...(action.inActionMetrics ?? [])];
for (const metric of metrics) {
const name = metric.name.toLowerCase().replace(/[^a-z0-9]/g, "");
if (!name || name === "ballspeed" || /frames?$/.test(name)) continue;
if (typeof metric.value === "number" && Number.isFinite(metric.value)) values.set(name, metric.value);
}
return values;
}
export function actionDistance(current: ActionResult, reference: ActionResult) {
const target = numericMetrics(current);
const values = numericMetrics(reference);
if (!target.size) return null;
let error = 0;
let shared = 0;
for (const [name, value] of target) {
const other = values.get(name);
if (other === undefined) { error++; continue; }
error += ((value - other) / Math.max(Math.abs(value), Math.abs(other), 1)) ** 2;
shared++;
}
return shared ? Math.sqrt(error / target.size) : null;
}
function ageContext(profile: Record<string, unknown>) {
const value = profile.bdate;
if (typeof value !== "string") return profile;
const birth = new Date(`${value.slice(0, 10)}T00:00:00Z`);
if (Number.isNaN(birth.getTime())) return profile;
const now = new Date();
let age = now.getUTCFullYear() - birth.getUTCFullYear();
if (now.getUTCMonth() < birth.getUTCMonth() || (now.getUTCMonth() === birth.getUTCMonth() && now.getUTCDate() < birth.getUTCDate())) age--;
return { ...profile, ageYears: age };
}
export async function analysisResults(sessionId: string, userId: string, isAdmin = false): Promise<SessionResult> {
const response = await fetch(new URL(`/api/sessions/${sessionId}`, env.ANALYSIS_API_URL), {
headers: {
"x-sporalize-proxy-secret": env.ANALYSIS_PROXY_SECRET,
"x-sporalize-user-id": userId,
...(isAdmin ? { "x-sporalize-admin": "true" } : {}),
},
signal: AbortSignal.timeout(120000),
});
if (!response.ok) throw new Error(`Unable to load results for session ${sessionId} (${response.status})`);
return await response.json() as SessionResult;
}
export async function actionReferences(db: SupabaseClient, current: ActionResult, actionType: string, currentSession?: SessionResult, cache = new ReferenceCache()) {
const closest = [];
if (!numericMetrics(current).size) return [];
const referenceResults = cache.results;
if (currentSession) referenceResults.set(currentSession.id, currentSession.actions);
for (let from = 0; ; from += 100) {
const pageKey = JSON.stringify([actionType, from]);
let examples = cache.pages.get(pageKey);
if (!examples) {
const { data, error: examplesError } = await db.from("annotations")
.select("action_id,score,annotations_notes!inner(note),actions!inner(action_id,session_id,type,sessions!inner(user_id,player_id,players!inner(gender,bdate,height,weight,position)))")
.eq("actions.type", actionType)
.order("action_id").range(from, from + 99);
if (examplesError) throw new Error(`Unable to load human annotations: ${examplesError.message}`);
examples = data ?? [];
cache.pages.set(pageKey, examples);
}
const referenceRows = (examples ?? []).filter((row: any) =>
row.action_id !== current.id && (row.annotations_notes ?? []).some((note: { note: string }) => typeof note.note === "string" && note.note.trim().length > 0))
.map((row: any) => ({ ...row, actions: Array.isArray(row.actions) ? row.actions[0] : row.actions }));
const sessions = new Map<string, string>();
for (const row of referenceRows) {
const related = row.actions;
if (related?.session_id && related.sessions?.user_id && !referenceResults.has(related.session_id)) {
sessions.set(related.session_id, related.sessions.user_id);
}
}
const pending = [...sessions];
for (let offset = 0; offset < pending.length; offset += 5) {
await Promise.all(pending.slice(offset, offset + 5).map(async ([id, ownerId]) => {
try {
const result = await analysisResults(id, ownerId);
referenceResults.set(id, result.actions.map(({ id, label, rawAnalytics, inActionMetrics }) => ({ id, label, rawAnalytics, inActionMetrics })));
} catch (error) {
referenceResults.set(id, null);
console.warn("Skipping unavailable annotation reference results", { sessionId: id, error });
}
}));
}
for (const row of referenceRows) {
const related = row.actions;
const refPlayerValue = related?.sessions?.players;
const rawPlayer = Array.isArray(refPlayerValue) ? refPlayerValue[0] : refPlayerValue;
const refPlayer = rawPlayer ? ageContext(rawPlayer as Record<string, unknown>) : rawPlayer;
const refActions = related?.session_id ? referenceResults.get(related.session_id) : undefined;
const refAction = refActions?.find((item) => item.id === row.action_id);
if (!related || !refPlayer || !refAction || refAction.label.toLowerCase() !== actionType.toLowerCase()) continue;
const distance = actionDistance(current, refAction);
if (distance === null) continue;
closest.push({ distance, reference: { session_id: related.session_id, action_id: row.action_id, action_type: related.type, player: refPlayer,
results: { metrics: refAction.inActionMetrics ?? [], analytics: refAction.rawAnalytics ?? {} },
score: row.score, notes: (row.annotations_notes ?? []).map((note: { note: string }) => note.note).filter((note: string) => typeof note === "string" && note.trim()) } });
}
closest.sort((a, b) => a.distance - b.distance || a.reference.action_id.localeCompare(b.reference.action_id));
closest.splice(5);
if ((examples ?? []).length < 100) break;
}
return closest.map((item) => item.reference);
}
export async function generateSessionFeedback(db: SupabaseClient, sessionId: string, userId: string) {
if (!env.AGENT_API_URL || !env.AGENT_API_KEY) throw new Error("Agent service is not configured");
const { data: session, error: sessionError } = await db.from("sessions")
.select("session_id,player_id,players(player_id,bdate,height,weight,position,gender)")
.eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
if (sessionError || !session) throw new Error(`Unable to load completed session context: ${sessionError?.message ?? "session missing"}`);
const rawPlayer = Array.isArray(session.players) ? session.players[0] : session.players;
const player = rawPlayer ? ageContext(rawPlayer as Record<string, unknown>) : rawPlayer;
if (!player) throw new Error("Session player context is missing");
const result = await analysisResults(sessionId, userId);
const actionResults = new Map(result.actions.map((action) => [action.id, action]));
const { data: currentActions, error: actionsError } = await db.from("actions")
.select("action_id,type").eq("session_id", sessionId);
if (actionsError) throw new Error(`Unable to load completed actions: ${actionsError.message}`);
const actions = [];
const referenceCache = new ReferenceCache();
for (const action of currentActions ?? []) {
const current = actionResults.get(action.action_id);
if (!current) continue;
const references = await actionReferences(db, current, action.type, result, referenceCache);
actions.push({ action_id: action.action_id, action_type: action.type,
results: { metrics: current.inActionMetrics ?? [], analytics: current.rawAnalytics ?? {} }, references });
}
if (!actions.length) return;
const agentHeaders: Record<string, string> = {
"content-type": "application/json",
"x-api-key": env.AGENT_API_KEY,
"x-user-id": userId,
};
if (env.HF_TOKEN) agentHeaders.authorization = `Bearer ${env.HF_TOKEN}`;
const response = await fetch(new URL("/api/session/generate", env.AGENT_API_URL), {
method: "POST",
headers: agentHeaders,
body: JSON.stringify({ session_id: sessionId, player, actions }),
signal: AbortSignal.timeout(210000),
});
const payload = await response.json().catch(() => ({})) as {
actions?: { action_id?: string; actionId?: string; feedback: string[] }[];
session_feedback?: string[];
sessionFeedback?: string[];
detail?: string;
};
const sessionFeedback = payload.session_feedback ?? payload.sessionFeedback;
const generatedActions = Array.isArray(payload.actions) ? payload.actions.map((item) => ({
actionId: item?.action_id ?? item?.actionId,
feedback: item?.feedback,
})) : undefined;
const requestedIds = new Set(actions.map((action) => action.action_id));
const returnedIds = new Set(generatedActions?.map((item) => item.actionId));
const validText = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
if (!response.ok || !Array.isArray(generatedActions) || generatedActions.length !== requestedIds.size
|| returnedIds.size !== requestedIds.size
|| generatedActions.some((item) => !item.actionId || !requestedIds.has(item.actionId)
|| !Array.isArray(item.feedback) || item.feedback.length === 0 || item.feedback.some((text) => !validText(text)))
|| !Array.isArray(sessionFeedback) || sessionFeedback.length === 0 || sessionFeedback.some((text) => !validText(text))) {
throw new Error(payload.detail ?? `Agent generation failed (${response.status})`);
}
for (const item of generatedActions) {
const actionId = item.actionId!;
const { error } = await db.from("actions_feedbacks").delete().eq("action_id", actionId);
if (error) throw new Error(`Unable to replace generated action feedback: ${error.message}`);
if (item.feedback.length) {
const inserted = await db.from("actions_feedbacks").insert(item.feedback.map((feedback) => ({ action_id: actionId, feedback })));
if (inserted.error) throw new Error(`Unable to save generated action feedback: ${inserted.error.message}`);
}
}
const { error: clearError } = await db.from("sessions_feedbacks").delete().eq("session_id", sessionId);
if (clearError) throw new Error(`Unable to replace generated session feedback: ${clearError.message}`);
const { error: saveError } = await db.from("sessions_feedbacks").insert(sessionFeedback.map((feedback) => ({ session_id: sessionId, feedback })));
if (saveError) throw new Error(`Unable to save generated session feedback: ${saveError.message}`);
}