abdelkader9090 commited on
Commit
5b5b4de
·
1 Parent(s): 2b53b8f

Rank annotated action references by numeric similarity

Browse files
Files changed (3) hide show
  1. src/agent-session.ts +82 -31
  2. src/server.ts +3 -1
  3. tests/agent-chat.test.ts +133 -13
src/agent-session.ts CHANGED
@@ -4,6 +4,32 @@ import { env } from "./config.js";
4
  type ActionResult = { id: string; label: string; rawAnalytics?: Record<string, unknown>; inActionMetrics?: { name: string; value: unknown; unit?: string }[] };
5
  type SessionResult = { id: string; playerId: string; actions: ActionResult[] };
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  function ageContext(profile: Record<string, unknown>) {
8
  const value = profile.bdate;
9
  if (typeof value !== "string") return profile;
@@ -28,6 +54,61 @@ export async function analysisResults(sessionId: string, userId: string, isAdmin
28
  return await response.json() as SessionResult;
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  export async function generateSessionFeedback(db: SupabaseClient, sessionId: string, userId: string) {
32
  if (!env.AGENT_API_URL || !env.AGENT_API_KEY) throw new Error("Agent service is not configured");
33
  const { data: session, error: sessionError } = await db.from("sessions")
@@ -38,11 +119,6 @@ export async function generateSessionFeedback(db: SupabaseClient, sessionId: str
38
  const player = rawPlayer ? ageContext(rawPlayer as Record<string, unknown>) : rawPlayer;
39
  if (!player) throw new Error("Session player context is missing");
40
  const result = await analysisResults(sessionId, userId);
41
- const { data: ownedSessions, error: ownedSessionsError } = await db.from("sessions")
42
- .select("session_id").eq("user_id", userId);
43
- if (ownedSessionsError) throw new Error(`Unable to load owned session references: ${ownedSessionsError.message}`);
44
- const ownedSessionIds = (ownedSessions ?? []).map((row) => row.session_id);
45
- if (!ownedSessionIds.length) return;
46
  const actionResults = new Map(result.actions.map((action) => [action.id, action]));
47
  const { data: currentActions, error: actionsError } = await db.from("actions")
48
  .select("action_id,type").eq("session_id", sessionId);
@@ -52,32 +128,7 @@ export async function generateSessionFeedback(db: SupabaseClient, sessionId: str
52
  for (const action of currentActions ?? []) {
53
  const current = actionResults.get(action.action_id);
54
  if (!current) continue;
55
- const { data: examples, error: examplesError } = await db.from("annotations")
56
- .select("action_id,score,annotations_notes(note),actions!inner(action_id,session_id,type,sessions!inner(player_id,players!inner(gender,bdate,height,weight,position)))")
57
- .eq("actions.type", action.type)
58
- .in("actions.session_id", ownedSessionIds)
59
- .neq("action_id", action.action_id)
60
- .limit(5);
61
- if (examplesError) throw new Error(`Unable to load human annotations: ${examplesError.message}`);
62
- const referenceRows = (examples ?? []).filter((row: any) =>
63
- row.score !== null || (row.annotations_notes ?? []).some((note: { note: string }) => typeof note.note === "string" && note.note.trim().length > 0));
64
- const referenceSessionIds = [...new Set(referenceRows.map((row: any) => row.actions?.session_id).filter(Boolean))] as string[];
65
- const referenceResults = new Map<string, SessionResult>();
66
- await Promise.all(referenceSessionIds.map(async (id) => {
67
- try { referenceResults.set(id, await analysisResults(id, userId)); }
68
- catch (error) { console.warn("Skipping unavailable annotation reference results", { sessionId: id, error }); }
69
- }));
70
- const references = referenceRows.flatMap((row: any) => {
71
- const related = Array.isArray(row.actions) ? row.actions[0] : row.actions;
72
- const refPlayerValue = related?.sessions?.players;
73
- const refPlayer = refPlayerValue ? ageContext(refPlayerValue as Record<string, unknown>) : refPlayerValue;
74
- const refSession = related?.session_id ? referenceResults.get(related.session_id) : undefined;
75
- const refAction = refSession?.actions.find((item) => item.id === row.action_id);
76
- if (!related || !refPlayer || !refAction) return [];
77
- return [{ action_id: row.action_id, action_type: related.type, player: refPlayer,
78
- results: { metrics: refAction.inActionMetrics ?? [], analytics: refAction.rawAnalytics ?? {} },
79
- score: row.score, notes: (row.annotations_notes ?? []).map((note: { note: string }) => note.note) }];
80
- });
81
  actions.push({ action_id: action.action_id, action_type: action.type,
82
  results: { metrics: current.inActionMetrics ?? [], analytics: current.rawAnalytics ?? {} }, references });
83
  }
 
4
  type ActionResult = { id: string; label: string; rawAnalytics?: Record<string, unknown>; inActionMetrics?: { name: string; value: unknown; unit?: string }[] };
5
  type SessionResult = { id: string; playerId: string; actions: ActionResult[] };
6
 
7
+ function numericMetrics(action: ActionResult) {
8
+ const values = new Map<string, number>();
9
+ const metrics = [...Object.entries(action.rawAnalytics ?? {}).map(([name, value]) => ({ name, value })), ...(action.inActionMetrics ?? [])];
10
+ for (const metric of metrics) {
11
+ const name = metric.name.toLowerCase().replace(/[^a-z0-9]/g, "");
12
+ if (!name || name === "ballspeed" || /frames?$/.test(name)) continue;
13
+ if (typeof metric.value === "number" && Number.isFinite(metric.value)) values.set(name, metric.value);
14
+ }
15
+ return values;
16
+ }
17
+
18
+ export function actionDistance(current: ActionResult, reference: ActionResult) {
19
+ const target = numericMetrics(current);
20
+ const values = numericMetrics(reference);
21
+ if (!target.size) return null;
22
+ let error = 0;
23
+ let shared = 0;
24
+ for (const [name, value] of target) {
25
+ const other = values.get(name);
26
+ if (other === undefined) { error++; continue; }
27
+ error += ((value - other) / Math.max(Math.abs(value), Math.abs(other), 1)) ** 2;
28
+ shared++;
29
+ }
30
+ return shared ? Math.sqrt(error / target.size) : null;
31
+ }
32
+
33
  function ageContext(profile: Record<string, unknown>) {
34
  const value = profile.bdate;
35
  if (typeof value !== "string") return profile;
 
54
  return await response.json() as SessionResult;
55
  }
56
 
57
+ export async function actionReferences(db: SupabaseClient, current: ActionResult, actionType: string, currentSession?: SessionResult) {
58
+ const closest = [];
59
+ if (!numericMetrics(current).size) return [];
60
+ const referenceResults = new Map<string, ActionResult[] | null>();
61
+ if (currentSession) referenceResults.set(currentSession.id, currentSession.actions);
62
+ for (let from = 0; ; from += 100) {
63
+ const { data: examples, error: examplesError } = await db.from("annotations")
64
+ .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)))")
65
+ .eq("actions.type", actionType)
66
+ .neq("action_id", current.id)
67
+ .order("action_id").range(from, from + 99);
68
+ if (examplesError) throw new Error(`Unable to load human annotations: ${examplesError.message}`);
69
+ const referenceRows = (examples ?? []).filter((row: any) =>
70
+ (row.annotations_notes ?? []).some((note: { note: string }) => typeof note.note === "string" && note.note.trim().length > 0))
71
+ .map((row: any) => ({ ...row, actions: Array.isArray(row.actions) ? row.actions[0] : row.actions }));
72
+ const sessions = new Map<string, string>();
73
+ for (const row of referenceRows) {
74
+ const related = row.actions;
75
+ if (related?.session_id && related.sessions?.user_id && !referenceResults.has(related.session_id)) {
76
+ sessions.set(related.session_id, related.sessions.user_id);
77
+ }
78
+ }
79
+ const pending = [...sessions];
80
+ for (let offset = 0; offset < pending.length; offset += 5) {
81
+ await Promise.all(pending.slice(offset, offset + 5).map(async ([id, ownerId]) => {
82
+ try {
83
+ const result = await analysisResults(id, ownerId);
84
+ referenceResults.set(id, result.actions.map(({ id, label, rawAnalytics, inActionMetrics }) => ({ id, label, rawAnalytics, inActionMetrics })));
85
+ } catch (error) {
86
+ referenceResults.set(id, null);
87
+ console.warn("Skipping unavailable annotation reference results", { sessionId: id, error });
88
+ }
89
+ }));
90
+ }
91
+ for (const row of referenceRows) {
92
+ const related = row.actions;
93
+ const refPlayerValue = related?.sessions?.players;
94
+ const rawPlayer = Array.isArray(refPlayerValue) ? refPlayerValue[0] : refPlayerValue;
95
+ const refPlayer = rawPlayer ? ageContext(rawPlayer as Record<string, unknown>) : rawPlayer;
96
+ const refActions = related?.session_id ? referenceResults.get(related.session_id) : undefined;
97
+ const refAction = refActions?.find((item) => item.id === row.action_id);
98
+ if (!related || !refPlayer || !refAction || refAction.label.toLowerCase() !== actionType.toLowerCase()) continue;
99
+ const distance = actionDistance(current, refAction);
100
+ if (distance === null) continue;
101
+ closest.push({ distance, reference: { session_id: related.session_id, action_id: row.action_id, action_type: related.type, player: refPlayer,
102
+ results: { metrics: refAction.inActionMetrics ?? [], analytics: refAction.rawAnalytics ?? {} },
103
+ score: row.score, notes: (row.annotations_notes ?? []).map((note: { note: string }) => note.note).filter((note: string) => typeof note === "string" && note.trim()) } });
104
+ }
105
+ closest.sort((a, b) => a.distance - b.distance || a.reference.action_id.localeCompare(b.reference.action_id));
106
+ closest.splice(5);
107
+ if ((examples ?? []).length < 100) break;
108
+ }
109
+ return closest.map((item) => item.reference);
110
+ }
111
+
112
  export async function generateSessionFeedback(db: SupabaseClient, sessionId: string, userId: string) {
113
  if (!env.AGENT_API_URL || !env.AGENT_API_KEY) throw new Error("Agent service is not configured");
114
  const { data: session, error: sessionError } = await db.from("sessions")
 
119
  const player = rawPlayer ? ageContext(rawPlayer as Record<string, unknown>) : rawPlayer;
120
  if (!player) throw new Error("Session player context is missing");
121
  const result = await analysisResults(sessionId, userId);
 
 
 
 
 
122
  const actionResults = new Map(result.actions.map((action) => [action.id, action]));
123
  const { data: currentActions, error: actionsError } = await db.from("actions")
124
  .select("action_id,type").eq("session_id", sessionId);
 
128
  for (const action of currentActions ?? []) {
129
  const current = actionResults.get(action.action_id);
130
  if (!current) continue;
131
+ const references = await actionReferences(db, current, action.type, result);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  actions.push({ action_id: action.action_id, action_type: action.type,
133
  results: { metrics: current.inActionMetrics ?? [], analytics: current.rawAnalytics ?? {} }, references });
134
  }
src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { pipeline } from "node:stream/promises";
9
  import { z } from "zod";
10
  import { authPublicOrigin, env } from "./config.js";
11
  import { analysisRouter } from "./analysis.js";
12
- import { analysisResults, generateSessionFeedback } from "./agent-session.js";
13
  import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
14
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
15
  import { registerProfileImageRoutes } from "./profile-images.js";
@@ -831,8 +831,10 @@ app.post("/api/agent/chat", async (req, res, next) => {
831
  const results = await analysisResults(input.sessionId, current.user.id, isAdmin);
832
  const measured = results.actions.find((item) => item.id === input.actionId);
833
  if (!measured) return res.status(404).json({ error: "Analysis results for this action are unavailable." });
 
834
  context.action = { ...action,
835
  results: { metrics: measured.inActionMetrics ?? [], analytics: measured.rawAnalytics ?? {} },
 
836
  feedback: (feedback ?? []).map((row) => row.feedback) };
837
  }
838
  }
 
9
  import { z } from "zod";
10
  import { authPublicOrigin, env } from "./config.js";
11
  import { analysisRouter } from "./analysis.js";
12
+ import { actionReferences, analysisResults, generateSessionFeedback } from "./agent-session.js";
13
  import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
14
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
15
  import { registerProfileImageRoutes } from "./profile-images.js";
 
831
  const results = await analysisResults(input.sessionId, current.user.id, isAdmin);
832
  const measured = results.actions.find((item) => item.id === input.actionId);
833
  if (!measured) return res.status(404).json({ error: "Analysis results for this action are unavailable." });
834
+ const references = await actionReferences(supabaseAdmin, measured, action.type, results);
835
  context.action = { ...action,
836
  results: { metrics: measured.inActionMetrics ?? [], analytics: measured.rawAnalytics ?? {} },
837
+ references,
838
  feedback: (feedback ?? []).map((row) => row.feedback) };
839
  }
840
  }
tests/agent-chat.test.ts CHANGED
@@ -5,27 +5,59 @@ import { createServer, type IncomingHttpHeaders } from "node:http";
5
  import type { AddressInfo } from "node:net";
6
  import { test } from "node:test";
7
 
8
- test("action chat sends current measurements on every message and preserves access checks", { timeout: 30_000 }, async () => {
9
  const owner = "11111111-1111-4111-8111-111111111111";
10
  const other = "22222222-2222-4222-8222-222222222222";
11
  const sessionId = "33333333-3333-4333-8333-333333333333";
12
  const actionId = "44444444-4444-4444-8444-444444444444";
13
  const otherActionId = "55555555-5555-4555-8555-555555555555";
 
 
14
  const analytics = { foot_region: "Toe", active_foot_speed: 1232.04, standing_foot_contact_seconds: 0,
15
- minimum_knee_angle: null, future_metric: false };
16
  const measured = { id: actionId, label: "Pass", rawAnalytics: analytics,
17
  inActionMetrics: Object.entries(analytics).map(([name, value]) => ({ name, value, unit: name === "active_foot_speed" ? "cm/s" : "" })) };
18
- const otherMeasured = { id: otherActionId, label: "Shoot", rawAnalytics: { ball_speed: 1303.31 },
19
- inActionMetrics: [{ name: "Ball Speed", value: 1303.31, unit: "cm/s" }] };
20
  let role = "user";
21
  let sessionOwner = owner;
22
  let resultsAvailable = true;
23
  let analysisStatus = 200;
24
  let analysisReads = 0;
25
  let analysisHeaders: IncomingHttpHeaders = {};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  type ChatPayload = { actions: unknown[]; message: string; history: unknown[];
27
- context: { action?: { action_id: string; results: { metrics: unknown[]; analytics: unknown }; feedback: string[]; annotations: unknown }; actions?: unknown[] } };
28
  const chats: ChatPayload[] = [];
 
29
  const upstream = createServer(async (req, res) => {
30
  const url = new URL(req.url!, "http://stub");
31
  res.setHeader("Content-Type", "application/json");
@@ -36,24 +68,54 @@ test("action chat sends current measurements on every message and preserves acce
36
  res.statusCode = analysisStatus;
37
  return res.end(JSON.stringify({ id: sessionId, actions: resultsAvailable ? [otherMeasured, measured] : [] }));
38
  }
 
 
 
 
 
 
 
 
 
 
 
 
39
  if (url.pathname === "/api/chat") {
40
  let body = "";
41
  for await (const chunk of req) body += chunk;
42
  chats.push(JSON.parse(body));
43
  return res.end(JSON.stringify({ reply: "Measured coaching reply." }));
44
  }
 
 
 
 
 
 
 
45
  const table = url.pathname.split("/").at(-1);
 
 
 
 
 
46
  let rows: Record<string, unknown>[] = table === "users" ? [{ user_id: owner, account_role: role }]
47
  : table === "users_subscriptions" ? [{ user_id: owner, subscription_id: "plan", startdate: "2000-01-01", enddate: "2099-01-01", plan: { subscription_id: "plan" } }]
48
- : table === "sessions" ? [{ session_id: sessionId, user_id: sessionOwner, name: "Practice", players: { fname: "Player" } }]
49
- : table === "actions" ? [actionId, otherActionId].map((id) => ({ action_id: id, session_id: sessionId, type: "Pass", start_frame: 750, end_frame: 780,
50
  annotations: [{ score: 7, annotations_notes: [{ note: "Coach note." }] }] }))
51
- : table === "actions_feedbacks" ? [{ action_id: actionId, feedback: "Saved action evaluation." }]
 
52
  : table === "sessions_feedbacks" ? [{ session_id: sessionId, feedback: "Saved session evaluation." }] : [];
 
53
  for (const [key, filter] of url.searchParams) {
54
- if (filter.startsWith("eq.")) rows = rows.filter((row) => String(row[key]) === filter.slice(3));
 
55
  }
56
- res.end(JSON.stringify(rows));
 
 
 
57
  });
58
  upstream.listen(0, "127.0.0.1");
59
  await once(upstream, "listening");
@@ -90,21 +152,39 @@ test("action chat sends current measurements on every message and preserves acce
90
  assert.equal((await request(selected)).status, 200);
91
  assert.equal(analysisReads, 1);
92
  assert.deepEqual(chats[0].context.action?.results, { metrics: measured.inActionMetrics, analytics });
93
- assert.deepEqual(chats[0].context.action?.feedback, ["Saved action evaluation."]);
 
 
 
 
 
 
 
 
 
 
 
94
  assert.deepEqual(chats[0].context.action?.annotations, [{ score: 7, annotations_notes: [{ note: "Coach note." }] }]);
95
  assert.equal(analysisHeaders["x-sporalize-user-id"], owner);
96
  assert.equal(analysisHeaders["x-sporalize-proxy-secret"], "x".repeat(32));
97
  assert.equal(analysisHeaders["x-sporalize-admin"], undefined);
98
 
99
- analytics.active_foot_speed = 1500.123456;
100
- measured.inActionMetrics.find((metric) => metric.name === "active_foot_speed")!.value = analytics.active_foot_speed;
 
 
101
  const history = [{ role: "user", content: "Explain these measurements." }, { role: "assistant", content: "Previous reply." }];
102
  assert.equal((await request({ ...selected, message: "What changed?", history })).status, 200);
103
  assert.equal(analysisReads, 2);
104
  assert.deepEqual(chats[1].context.action?.results, { metrics: measured.inActionMetrics, analytics });
 
 
 
 
105
  assert.deepEqual(chats[1].history, history);
106
  assert.equal((await request({ sessionId, actionId: otherActionId })).status, 200);
107
  assert.deepEqual(chats[2].context.action?.results, { metrics: otherMeasured.inActionMetrics, analytics: otherMeasured.rawAnalytics });
 
108
 
109
  const chatCount = chats.length;
110
  const readCount = analysisReads;
@@ -120,6 +200,22 @@ test("action chat sends current measurements on every message and preserves acce
120
  role = "user";
121
  sessionOwner = owner;
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  const successfulChats = chats.length;
124
  resultsAvailable = false;
125
  assert.equal((await request(selected)).status, 404);
@@ -134,6 +230,30 @@ test("action chat sends current measurements on every message and preserves acce
134
  assert.equal((await request({})).status, 200);
135
  assert.equal(analysisReads, failedReads, "general and session chats do not need action measurements");
136
  assert.equal(chats.at(-1)?.context.action, undefined);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  } finally {
138
  if (child.exitCode === null && child.signalCode === null) {
139
  const exited = once(child, "exit");
 
5
  import type { AddressInfo } from "node:net";
6
  import { test } from "node:test";
7
 
8
+ test("action chat sends measurements and annotated references on every message and preserves access checks", { timeout: 30_000 }, async () => {
9
  const owner = "11111111-1111-4111-8111-111111111111";
10
  const other = "22222222-2222-4222-8222-222222222222";
11
  const sessionId = "33333333-3333-4333-8333-333333333333";
12
  const actionId = "44444444-4444-4444-8444-444444444444";
13
  const otherActionId = "55555555-5555-4555-8555-555555555555";
14
+ const referenceSessionId = "77777777-7777-4777-8777-777777777777";
15
+ const foreignSessionId = "88888888-8888-4888-8888-888888888888";
16
  const analytics = { foot_region: "Toe", active_foot_speed: 1232.04, standing_foot_contact_seconds: 0,
17
+ ball_speed: 1303.31, touch_frame: 766, minimum_knee_angle: null, future_metric: false };
18
  const measured = { id: actionId, label: "Pass", rawAnalytics: analytics,
19
  inActionMetrics: Object.entries(analytics).map(([name, value]) => ({ name, value, unit: name === "active_foot_speed" ? "cm/s" : "" })) };
20
+ const otherMeasured = { id: otherActionId, label: "Shoot", rawAnalytics: { ball_speed: 1303.31, active_foot_speed: 1232.04 },
21
+ inActionMetrics: [{ name: "Ball Speed", value: 1303.31, unit: "cm/s" }, { name: "Active Foot Speed", value: 1232.04, unit: "cm/s" }] };
22
  let role = "user";
23
  let sessionOwner = owner;
24
  let resultsAvailable = true;
25
  let analysisStatus = 200;
26
  let analysisReads = 0;
27
  let analysisHeaders: IncomingHttpHeaders = {};
28
+ let savedFeedbackAvailable = false;
29
+ let referencesAvailable = true;
30
+ let annotationsFail = false;
31
+ let referenceResultsAvailable = true;
32
+ let referenceReads = 0;
33
+ let mutations = 0;
34
+ const referenceAnalytics = { active_foot: "left", ball_speed: 1303.31, active_foot_speed: 1232.04, standing_foot_contact_seconds: 0, touch_frame: 999999 };
35
+ const offsets: Record<string, number> = { "00-blank": 0, "02-reference": 0, "00-foreign": 5, "03-reference": 300,
36
+ "04-reference": 200, "05-reference": 100, "06-reference": 50, "07-reference": 20, "08-reference": 10, "zz-late": 1, "zz-wrong-report": 0 };
37
+ const referenceUsers = new Set<string>();
38
+ const reference = (id: string, type = "Pass", userId = owner, score: number | null = 7, notes = ["Reference coach note."]) => ({
39
+ action_id: id, score, annotations_notes: notes.map((note) => ({ note })),
40
+ actions: { action_id: id, session_id: userId === owner ? referenceSessionId : foreignSessionId, type,
41
+ sessions: { user_id: userId, players: { gender: "female", bdate: "2000-01-01", height: 170, weight: 65, position: "Forward" } } },
42
+ });
43
+ const examples = [reference(actionId), reference("00-foreign", "Pass", other, null), reference("shoot-reference", "Shoot"),
44
+ reference("00-blank", "Pass", owner, null, [" "]), reference("01-empty"), reference("01-missing"),
45
+ reference("02-reference", "Pass", owner, 0, []), reference("03-reference", "Pass", owner, null),
46
+ ...Array.from({ length: 5 }, (_, index) => reference(`${String(index + 4).padStart(2, "0")}-reference`)),
47
+ ...Array.from({ length: 110 }, (_, index) => reference(`far-${String(index).padStart(3, "0")}`)),
48
+ reference("zz-late", "Pass", other, 0), reference("zz-no-common"), reference("zz-wrong-report")];
49
+ const referenceReport = (item: ReturnType<typeof reference>) => {
50
+ const rawAnalytics: Record<string, unknown> = item.action_id === "01-empty" ? {} : item.action_id === "zz-no-common" ? { unrelated_metric: 42 }
51
+ : { ...referenceAnalytics, active_foot_speed: referenceAnalytics.active_foot_speed + (offsets[item.action_id] ?? 500) };
52
+ return { id: item.action_id, label: item.action_id === "zz-wrong-report" ? "Shoot" : item.actions.type, rawAnalytics,
53
+ inActionMetrics: Object.entries(rawAnalytics).map(([name, value]) => ({ name, value, unit: "" })) };
54
+ };
55
+ type Reference = { session_id: string; action_id: string; action_type: string; player: { gender: string }; score: number | null; notes: string[];
56
+ results: { metrics: unknown[]; analytics: unknown } };
57
  type ChatPayload = { actions: unknown[]; message: string; history: unknown[];
58
+ context: { action?: { action_id: string; results: { metrics: unknown[]; analytics: unknown }; references: Reference[]; feedback: string[]; annotations: unknown }; actions?: unknown[] } };
59
  const chats: ChatPayload[] = [];
60
+ const generations: { actions: { action_id: string; references: Reference[] }[] }[] = [];
61
  const upstream = createServer(async (req, res) => {
62
  const url = new URL(req.url!, "http://stub");
63
  res.setHeader("Content-Type", "application/json");
 
68
  res.statusCode = analysisStatus;
69
  return res.end(JSON.stringify({ id: sessionId, actions: resultsAvailable ? [otherMeasured, measured] : [] }));
70
  }
71
+ if ([`/api/sessions/${referenceSessionId}`, `/api/sessions/${foreignSessionId}`].includes(url.pathname)) {
72
+ referenceReads++;
73
+ const id = url.pathname.split("/").at(-1);
74
+ const expectedOwner = id === referenceSessionId ? owner : other;
75
+ referenceUsers.add(String(req.headers["x-sporalize-user-id"]));
76
+ if (req.headers["x-sporalize-user-id"] !== expectedOwner) {
77
+ res.statusCode = 403;
78
+ return res.end("{}");
79
+ }
80
+ res.statusCode = referenceResultsAvailable ? 200 : 503;
81
+ return res.end(JSON.stringify({ id, actions: examples.filter((item) => item.action_id !== "01-missing" && item.actions.session_id === id).map(referenceReport) }));
82
+ }
83
  if (url.pathname === "/api/chat") {
84
  let body = "";
85
  for await (const chunk of req) body += chunk;
86
  chats.push(JSON.parse(body));
87
  return res.end(JSON.stringify({ reply: "Measured coaching reply." }));
88
  }
89
+ if (url.pathname === "/api/session/generate") {
90
+ let body = "";
91
+ for await (const chunk of req) body += chunk;
92
+ const input = JSON.parse(body);
93
+ generations.push(input);
94
+ return res.end(JSON.stringify({ actions: input.actions.map((action: { action_id: string }) => ({ action_id: action.action_id, feedback: ["Generated feedback."] })), session_feedback: ["Session summary."] }));
95
+ }
96
  const table = url.pathname.split("/").at(-1);
97
+ if (req.method !== "GET") { mutations++; return res.end("[]"); }
98
+ if (table === "annotations" && annotationsFail) {
99
+ res.statusCode = 400;
100
+ return res.end(JSON.stringify({ message: "Annotations unavailable." }));
101
+ }
102
  let rows: Record<string, unknown>[] = table === "users" ? [{ user_id: owner, account_role: role }]
103
  : table === "users_subscriptions" ? [{ user_id: owner, subscription_id: "plan", startdate: "2000-01-01", enddate: "2099-01-01", plan: { subscription_id: "plan" } }]
104
+ : table === "sessions" ? [{ session_id: sessionId, user_id: sessionOwner, name: "Practice", players: { fname: "Player", gender: "male", bdate: "2000-01-01" } }]
105
+ : table === "actions" ? [actionId, otherActionId].map((id) => ({ action_id: id, session_id: sessionId, type: id === actionId ? "Pass" : "Shoot", start_frame: 750, end_frame: 780,
106
  annotations: [{ score: 7, annotations_notes: [{ note: "Coach note." }] }] }))
107
+ : table === "annotations" ? referencesAvailable ? examples : []
108
+ : table === "actions_feedbacks" ? savedFeedbackAvailable ? [{ action_id: actionId, feedback: "Saved action evaluation." }] : []
109
  : table === "sessions_feedbacks" ? [{ session_id: sessionId, feedback: "Saved session evaluation." }] : [];
110
+ const value = (row: Record<string, unknown>, key: string): unknown => key.split(".").reduce<unknown>((item, part) => (item as Record<string, unknown> | undefined)?.[part], row);
111
  for (const [key, filter] of url.searchParams) {
112
+ if (filter.startsWith("eq.")) rows = rows.filter((row) => String(value(row, key)) === filter.slice(3));
113
+ if (filter.startsWith("neq.")) rows = rows.filter((row) => String(value(row, key)) !== filter.slice(4));
114
  }
115
+ if (url.searchParams.get("order") === "action_id.asc") rows = [...rows].sort((a, b) => String(a.action_id).localeCompare(String(b.action_id)));
116
+ const from = Number(url.searchParams.get("offset") ?? 0);
117
+ const limit = Number(url.searchParams.get("limit") ?? rows.length);
118
+ res.end(JSON.stringify(rows.slice(from, from + limit)));
119
  });
120
  upstream.listen(0, "127.0.0.1");
121
  await once(upstream, "listening");
 
152
  assert.equal((await request(selected)).status, 200);
153
  assert.equal(analysisReads, 1);
154
  assert.deepEqual(chats[0].context.action?.results, { metrics: measured.inActionMetrics, analytics });
155
+ assert.deepEqual(chats[0].context.action?.feedback, []);
156
+ const expectedReferences = ["zz-late", "00-foreign", "08-reference", "07-reference", "06-reference"];
157
+ assert.deepEqual(chats[0].context.action?.references.map((item) => item.action_id), expectedReferences);
158
+ assert.ok(chats[0].context.action?.references.every((item) => item.action_type === "Pass" && item.player.gender === "female"));
159
+ const nearest = referenceReport(examples.find((item) => item.action_id === "zz-late")!);
160
+ assert.deepEqual(chats[0].context.action?.references[0].results, { metrics: nearest.inActionMetrics, analytics: nearest.rawAnalytics });
161
+ assert.equal(chats[0].context.action?.references[0].session_id, foreignSessionId, "comparisons include other accounts with their real session IDs");
162
+ assert.equal(chats[0].context.action?.references[0].score, 0);
163
+ assert.equal(chats[0].context.action?.references[1].score, null);
164
+ assert.deepEqual(chats[0].context.action?.references[1].notes, ["Reference coach note."]);
165
+ assert.equal(referenceReads, 2, "each session is fetched once across all candidate pages");
166
+ assert.deepEqual(referenceUsers, new Set([owner, other]));
167
  assert.deepEqual(chats[0].context.action?.annotations, [{ score: 7, annotations_notes: [{ note: "Coach note." }] }]);
168
  assert.equal(analysisHeaders["x-sporalize-user-id"], owner);
169
  assert.equal(analysisHeaders["x-sporalize-proxy-secret"], "x".repeat(32));
170
  assert.equal(analysisHeaders["x-sporalize-admin"], undefined);
171
 
172
+ analytics.ball_speed = 1500.123456;
173
+ measured.inActionMetrics.find((metric) => metric.name === "ball_speed")!.value = analytics.ball_speed;
174
+ savedFeedbackAvailable = true;
175
+ referenceAnalytics.ball_speed = 1400.75;
176
  const history = [{ role: "user", content: "Explain these measurements." }, { role: "assistant", content: "Previous reply." }];
177
  assert.equal((await request({ ...selected, message: "What changed?", history })).status, 200);
178
  assert.equal(analysisReads, 2);
179
  assert.deepEqual(chats[1].context.action?.results, { metrics: measured.inActionMetrics, analytics });
180
+ assert.deepEqual(chats[1].context.action?.feedback, ["Saved action evaluation."]);
181
+ const updatedNearest = referenceReport(examples.find((item) => item.action_id === "zz-late")!);
182
+ assert.deepEqual(chats[1].context.action?.references[0].results, { metrics: updatedNearest.inActionMetrics, analytics: updatedNearest.rawAnalytics });
183
+ assert.equal(referenceReads, 4, "each message gets current reference measurements");
184
  assert.deepEqual(chats[1].history, history);
185
  assert.equal((await request({ sessionId, actionId: otherActionId })).status, 200);
186
  assert.deepEqual(chats[2].context.action?.results, { metrics: otherMeasured.inActionMetrics, analytics: otherMeasured.rawAnalytics });
187
+ assert.deepEqual(chats[2].context.action?.references.map((item) => item.action_id), ["shoot-reference"]);
188
 
189
  const chatCount = chats.length;
190
  const readCount = analysisReads;
 
200
  role = "user";
201
  sessionOwner = owner;
202
 
203
+ referencesAvailable = false;
204
+ assert.equal((await request(selected)).status, 200);
205
+ assert.deepEqual(chats.at(-1)?.context.action?.references, []);
206
+ referencesAvailable = true;
207
+ referenceResultsAvailable = false;
208
+ const readsBeforeMissing = referenceReads;
209
+ assert.equal((await request(selected)).status, 200);
210
+ assert.deepEqual(chats.at(-1)?.context.action?.references, []);
211
+ assert.equal(referenceReads, readsBeforeMissing + 2, "unavailable reference results are not retried for each candidate page");
212
+ referenceResultsAvailable = true;
213
+ const chatsBeforeFailure = chats.length;
214
+ annotationsFail = true;
215
+ assert.equal((await request(selected)).status, 500);
216
+ assert.equal(chats.length, chatsBeforeFailure, "a DB failure must not be presented as an empty search");
217
+ annotationsFail = false;
218
+
219
  const successfulChats = chats.length;
220
  resultsAvailable = false;
221
  assert.equal((await request(selected)).status, 404);
 
230
  assert.equal((await request({})).status, 200);
231
  assert.equal(analysisReads, failedReads, "general and session chats do not need action measurements");
232
  assert.equal(chats.at(-1)?.context.action, undefined);
233
+ assert.equal(mutations, 0, "chat must not save or replace feedback");
234
+
235
+ analysisStatus = 200;
236
+ resultsAvailable = true;
237
+ Object.assign(process.env, { FRONTEND_URL: origin, BACKEND_URL: origin, SUPABASE_URL: upstreamOrigin,
238
+ SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", ANALYSIS_API_URL: upstreamOrigin,
239
+ ANALYSIS_PROXY_SECRET: "x".repeat(32), AGENT_API_URL: upstreamOrigin, AGENT_API_KEY: "a".repeat(32), HF_TOKEN: "" });
240
+ const { createClient } = await import("@supabase/supabase-js");
241
+ const { actionDistance, generateSessionFeedback } = await import("../src/agent-session.js");
242
+ const sample = (rawAnalytics: Record<string, unknown>) => ({ id: "sample", label: "Pass", rawAnalytics });
243
+ assert.equal(actionDistance(sample({ speed: 0, touch_frame: 1 }), sample({ speed: 0, touch_frame: 9999 })), 0);
244
+ assert.equal(actionDistance(sample({ missing: null, flag: false, region: "Toe" }), sample({ speed: 0 })), null);
245
+ assert.equal(actionDistance(sample({ speed: 10 }), sample({ speed: "10" })), null);
246
+ assert.equal(actionDistance(sample({ speed: 10 }), sample({ unrelated: 10 })), null);
247
+ assert.ok(actionDistance(sample({ speed: 1000, angle: 100 }), sample({ speed: 1010, angle: 101 }))!
248
+ < actionDistance(sample({ speed: 1000, angle: 100 }), sample({ speed: 1000 }))!, "missing values must not outrank complete close measurements");
249
+ assert.equal(actionDistance(sample({ ball_speed: 10, touch_frame: 1 }), sample({ ball_speed: 9999, touch_frame: 9999 })), null);
250
+ assert.equal(actionDistance(sample({ active_foot_speed: 10, ball_speed: 10, touch_frame: 1 }), { id: "metric", label: "Pass",
251
+ inActionMetrics: [{ name: "Active Foot Speed", value: 10 }, { name: "Ball Speed", value: 9999 }, { name: "Touch Frame", value: 9999 }] }), 0);
252
+ const db = createClient(upstreamOrigin, "test-service", { auth: { persistSession: false } });
253
+ await generateSessionFeedback(db, sessionId, owner);
254
+ assert.deepEqual(generations[0].actions.find((item) => item.action_id === actionId)?.references.map((item) => item.action_id), expectedReferences);
255
+ assert.deepEqual(generations[0].actions.find((item) => item.action_id === otherActionId)?.references.map((item) => item.action_id), ["shoot-reference"]);
256
+ assert.equal(mutations, 6, "automatic generation still persists feedback for both actions and the session");
257
  } finally {
258
  if (child.exitCode === null && child.signalCode === null) {
259
  const exited = once(child, "exit");