import type { SupabaseClient } from "@supabase/supabase-js"; import { env } from "./config.js"; type ActionResult = { id: string; label: string; rawAnalytics?: Record; 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(); pages = new Map(); } function numericMetrics(action: ActionResult) { const values = new Map(); 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) { 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 { 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(); 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) : 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) : 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 = { "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}`); }