Spaces:
Running
Running
Commit ·
5b5b4de
1
Parent(s): 2b53b8f
Rank annotated action references by numeric similarity
Browse files- src/agent-session.ts +82 -31
- src/server.ts +3 -1
- 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
|
| 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
|
| 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 === "
|
|
|
|
| 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
|
|
|
|
| 55 |
}
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 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, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 100 |
-
measured.inActionMetrics.find((metric) => metric.name === "
|
|
|
|
|
|
|
| 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");
|