Spaces:
Running
Running
Download src/agent-session.ts from Shoraky/Sporalize-api: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/spaces/Shoraky/Sporalize-api/resolve/main/src/agent-session.ts
- Command line
-
hf download hf://spaces/Shoraky/Sporalize-api/src/agent-session.ts
-
curl -L -o agent-session.ts https://huggingface.co/spaces/Shoraky/Sporalize-api/resolve/main/src/agent-session.ts
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}`); | |
| } | |