Spaces:
Running
Running
File size: 11,105 Bytes
89c7f9c d4e2c1f 5b5b4de 89c7f9c 2b53b8f 89c7f9c 2b53b8f 89c7f9c d4e2c1f 5b5b4de d4e2c1f 5b5b4de d4e2c1f 5b5b4de d4e2c1f 5b5b4de 2e69b0d 5b5b4de 89c7f9c d4e2c1f 89c7f9c d4e2c1f 89c7f9c 6ae077e c6353c0 89c7f9c 6ae077e 89c7f9c 1e2bd68 89c7f9c a80fddd 2cfbff5 a80fddd 89c7f9c a80fddd 89c7f9c a80fddd 89c7f9c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | 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}`);
}
|