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}`);
}