Shoraky commited on
Commit
89c7f9c
·
1 Parent(s): 3304e46

Add backend coaching agent integration

Browse files
Files changed (5) hide show
  1. .env.example +3 -0
  2. src/agent-session.ts +96 -0
  3. src/analysis.ts +5 -1
  4. src/config.ts +2 -0
  5. src/server.ts +67 -1
.env.example CHANGED
@@ -4,6 +4,9 @@ NODE_ENV=development
4
  FRONTEND_URL=http://localhost:8080
5
  BACKEND_URL=http://localhost:4000
6
  ANALYSIS_PROXY_SECRET=replace-with-a-random-secret-of-at-least-32-characters
 
 
 
7
  # Optional: use the same random secret in the Colab notebook to register its worker.
8
  # COLAB_WORKER_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
9
  SUPABASE_URL=https://your-project.supabase.co
 
4
  FRONTEND_URL=http://localhost:8080
5
  BACKEND_URL=http://localhost:4000
6
  ANALYSIS_PROXY_SECRET=replace-with-a-random-secret-of-at-least-32-characters
7
+ # Deploy the stateless Agent service separately and use its private URL/key here.
8
+ AGENT_API_URL=https://your-agent-service.example.com
9
+ AGENT_API_KEY=replace-with-the-Agent-service-secret
10
  # Optional: use the same random secret in the Colab notebook to register its worker.
11
  # COLAB_WORKER_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
12
  SUPABASE_URL=https://your-project.supabase.co
src/agent-session.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { env } from "./config.js";
3
+
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;
10
+ const birth = new Date(`${value.slice(0, 10)}T00:00:00Z`);
11
+ if (Number.isNaN(birth.getTime())) return profile;
12
+ const now = new Date();
13
+ let age = now.getUTCFullYear() - birth.getUTCFullYear();
14
+ if (now.getUTCMonth() < birth.getUTCMonth() || (now.getUTCMonth() === birth.getUTCMonth() && now.getUTCDate() < birth.getUTCDate())) age--;
15
+ return { ...profile, ageYears: age };
16
+ }
17
+
18
+ async function analysisResults(sessionId: string, userId: string): Promise<SessionResult> {
19
+ const response = await fetch(new URL(`/api/sessions/${sessionId}`, env.ANALYSIS_API_URL), {
20
+ headers: { "x-sporalize-proxy-secret": env.ANALYSIS_PROXY_SECRET, "x-sporalize-user-id": userId },
21
+ signal: AbortSignal.timeout(120000),
22
+ });
23
+ if (!response.ok) throw new Error(`Unable to load results for session ${sessionId} (${response.status})`);
24
+ return await response.json() as SessionResult;
25
+ }
26
+
27
+ export async function generateSessionFeedback(db: SupabaseClient, sessionId: string, userId: string) {
28
+ if (!env.AGENT_API_URL || !env.AGENT_API_KEY) throw new Error("Agent service is not configured");
29
+ const { data: session, error: sessionError } = await db.from("sessions")
30
+ .select("session_id,player_id,players(player_id,bdate,height,weight,position,gender)")
31
+ .eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
32
+ if (sessionError || !session) throw new Error(`Unable to load completed session context: ${sessionError?.message ?? "session missing"}`);
33
+ const rawPlayer = Array.isArray(session.players) ? session.players[0] : session.players;
34
+ const player = rawPlayer ? ageContext(rawPlayer as Record<string, unknown>) : rawPlayer;
35
+ if (!player) throw new Error("Session player context is missing");
36
+ const result = await analysisResults(sessionId, userId);
37
+ const actionResults = new Map(result.actions.map((action) => [action.id, action]));
38
+ const { data: currentActions, error: actionsError } = await db.from("actions")
39
+ .select("action_id,type").eq("session_id", sessionId);
40
+ if (actionsError) throw new Error(`Unable to load completed actions: ${actionsError.message}`);
41
+
42
+ const actions = [];
43
+ for (const action of currentActions ?? []) {
44
+ const current = actionResults.get(action.action_id);
45
+ if (!current) continue;
46
+ const { data: examples, error: examplesError } = await db.from("annotations")
47
+ .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)))")
48
+ .eq("actions.type", action.type)
49
+ .eq("actions.sessions.players.gender", player.gender)
50
+ .neq("action_id", action.action_id)
51
+ .limit(10);
52
+ if (examplesError) throw new Error(`Unable to load human annotations: ${examplesError.message}`);
53
+ const referenceRows = (examples ?? []).filter((row: any) =>
54
+ row.score !== null || (row.annotations_notes ?? []).some((note: { note: string }) => typeof note.note === "string" && note.note.trim().length > 0));
55
+ const referenceSessionIds = [...new Set(referenceRows.map((row: any) => row.actions?.session_id).filter(Boolean))] as string[];
56
+ const referenceResults = new Map<string, SessionResult>();
57
+ await Promise.all(referenceSessionIds.map(async (id) => {
58
+ try { referenceResults.set(id, await analysisResults(id, userId)); }
59
+ catch (error) { console.warn("Skipping unavailable annotation reference results", { sessionId: id, error }); }
60
+ }));
61
+ const references = referenceRows.flatMap((row: any) => {
62
+ const related = Array.isArray(row.actions) ? row.actions[0] : row.actions;
63
+ const refPlayerValue = related?.sessions?.players;
64
+ const refPlayer = refPlayerValue ? ageContext(refPlayerValue as Record<string, unknown>) : refPlayerValue;
65
+ const refSession = related?.session_id ? referenceResults.get(related.session_id) : undefined;
66
+ const refAction = refSession?.actions.find((item) => item.id === row.action_id);
67
+ if (!related || !refPlayer || !refAction) return [];
68
+ return [{ action_id: row.action_id, action_type: related.type, player: refPlayer,
69
+ results: { metrics: refAction.inActionMetrics ?? [], analytics: refAction.rawAnalytics ?? {} },
70
+ score: row.score, notes: (row.annotations_notes ?? []).map((note: { note: string }) => note.note) }];
71
+ });
72
+ actions.push({ action_id: action.action_id, action_type: action.type,
73
+ results: { metrics: current.inActionMetrics ?? [], analytics: current.rawAnalytics ?? {} }, references });
74
+ }
75
+ if (!actions.length) return;
76
+ const response = await fetch(new URL("/api/session/generate", env.AGENT_API_URL), {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json", "x-api-key": env.AGENT_API_KEY, "x-user-id": userId },
79
+ body: JSON.stringify({ session_id: sessionId, player, actions }),
80
+ signal: AbortSignal.timeout(150000),
81
+ });
82
+ const payload = await response.json().catch(() => ({})) as { actions?: { action_id: string; feedback: string[] }[]; session_feedback?: string[]; detail?: string };
83
+ if (!response.ok || !payload.actions || !payload.session_feedback) throw new Error(payload.detail ?? `Agent generation failed (${response.status})`);
84
+ for (const item of payload.actions) {
85
+ const { error } = await db.from("actions_feedbacks").delete().eq("action_id", item.action_id);
86
+ if (error) throw new Error(`Unable to replace generated action feedback: ${error.message}`);
87
+ if (item.feedback.length) {
88
+ const inserted = await db.from("actions_feedbacks").insert(item.feedback.map((feedback) => ({ action_id: item.action_id, feedback })));
89
+ if (inserted.error) throw new Error(`Unable to save generated action feedback: ${inserted.error.message}`);
90
+ }
91
+ }
92
+ const { error: clearError } = await db.from("sessions_feedbacks").delete().eq("session_id", sessionId);
93
+ if (clearError) throw new Error(`Unable to replace generated session feedback: ${clearError.message}`);
94
+ const { error: saveError } = await db.from("sessions_feedbacks").insert(payload.session_feedback.map((feedback) => ({ session_id: sessionId, feedback })));
95
+ if (saveError) throw new Error(`Unable to save generated session feedback: ${saveError.message}`);
96
+ }
src/analysis.ts CHANGED
@@ -30,7 +30,7 @@ export function actionRows(sessionId: string, reports: z.infer<typeof reportSche
30
  });
31
  }
32
 
33
- export function analysisRouter(db: SupabaseClient, secret: string) {
34
  const router = express.Router();
35
  router.use((req, res, next) => {
36
  const supplied = Buffer.from(req.header("x-sporalize-proxy-secret") ?? "");
@@ -189,6 +189,7 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
189
  const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
190
  if (error) return res.status(503).json({ error: "Unable to save analysis actions." });
191
  }
 
192
  }
193
  return res.json({ ok: true });
194
  }
@@ -200,6 +201,9 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
200
  console.error("Unable to save analysis results", { code: error.code, message: error.message });
201
  return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to save analysis" });
202
  }
 
 
 
203
  return res.json({ ok: true });
204
  } catch (error) { next(error); }
205
  });
 
30
  });
31
  }
32
 
33
+ export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?: (sessionId: string, userId: string) => Promise<void>) {
34
  const router = express.Router();
35
  router.use((req, res, next) => {
36
  const supplied = Buffer.from(req.header("x-sporalize-proxy-secret") ?? "");
 
189
  const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
190
  if (error) return res.status(503).json({ error: "Unable to save analysis actions." });
191
  }
192
+ if (onCompleted) void onCompleted(sessionId, res.locals.userId as string).catch((error) => console.error("Automatic Agent feedback generation failed", { sessionId, error }));
193
  }
194
  return res.json({ ok: true });
195
  }
 
201
  console.error("Unable to save analysis results", { code: error.code, message: error.message });
202
  return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to save analysis" });
203
  }
204
+ if (input.status === "completed" && onCompleted) {
205
+ void onCompleted(sessionId, res.locals.userId as string).catch((generationError) => console.error("Automatic Agent feedback generation failed", { sessionId, generationError }));
206
+ }
207
  return res.json({ ok: true });
208
  } catch (error) { next(error); }
209
  });
src/config.ts CHANGED
@@ -8,6 +8,8 @@ const envSchema = z.object({
8
  FRONTEND_URL: z.string().url(),
9
  BACKEND_URL: z.string().url(),
10
  ANALYSIS_API_URL: z.string().url().default("http://127.0.0.1:8000"),
 
 
11
  ANALYSIS_PROXY_SECRET: z.string().min(32),
12
  COLAB_WORKER_SECRET: z.string().optional(),
13
  SUPABASE_URL: z.string().url(),
 
8
  FRONTEND_URL: z.string().url(),
9
  BACKEND_URL: z.string().url(),
10
  ANALYSIS_API_URL: z.string().url().default("http://127.0.0.1:8000"),
11
+ AGENT_API_URL: z.string().url().optional(),
12
+ AGENT_API_KEY: z.string().min(16).optional(),
13
  ANALYSIS_PROXY_SECRET: z.string().min(32),
14
  COLAB_WORKER_SECRET: z.string().optional(),
15
  SUPABASE_URL: z.string().url(),
src/server.ts CHANGED
@@ -9,6 +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 { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
13
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
14
  import { registerProfileImageRoutes } from "./profile-images.js";
@@ -24,7 +25,8 @@ app.use(cors({
24
  credentials: true,
25
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
26
  }));
27
- app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET));
 
28
  app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
29
  app.use("/internal/colab-worker", colabWorkerRouter(env.ANALYSIS_PROXY_SECRET, env.COLAB_WORKER_SECRET));
30
  app.use(cookieParser());
@@ -735,6 +737,70 @@ app.get("/api/sessions/:sessionId/feedbacks", async (req, res, next) => {
735
  } catch (error) { next(error); }
736
  });
737
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
738
  app.put("/api/sessions/:sessionId/actions/:actionId/review", async (req, res, next) => {
739
  try {
740
  const current = await requireActiveSubscription(req, res);
 
9
  import { z } from "zod";
10
  import { authPublicOrigin, env } from "./config.js";
11
  import { analysisRouter } from "./analysis.js";
12
+ import { 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";
 
25
  credentials: true,
26
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
27
  }));
28
+ app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET,
29
+ (sessionId, userId) => generateSessionFeedback(supabaseAdmin, sessionId, userId)));
30
  app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
31
  app.use("/internal/colab-worker", colabWorkerRouter(env.ANALYSIS_PROXY_SECRET, env.COLAB_WORKER_SECRET));
32
  app.use(cookieParser());
 
737
  } catch (error) { next(error); }
738
  });
739
 
740
+ app.post("/api/agent/chat", async (req, res, next) => {
741
+ try {
742
+ const current = await authenticatedUser(req, res);
743
+ if (!current) return res.status(401).json({ error: "Authentication required." });
744
+ if (!env.AGENT_API_URL || !env.AGENT_API_KEY) {
745
+ return res.status(503).json({ error: "The coaching Agent is not configured." });
746
+ }
747
+ const input = z.object({
748
+ message: z.string().trim().min(1).max(6000),
749
+ history: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(6000) })).max(30).default([]),
750
+ sessionId: z.uuid().optional(), actionId: z.uuid().optional(), playerId: z.uuid().optional(),
751
+ }).strict().parse(req.body);
752
+ if (input.actionId && !input.sessionId) return res.status(400).json({ error: "A session is required for action context." });
753
+ const context: Record<string, unknown> = {};
754
+ if (input.sessionId) {
755
+ const { data: session, error } = await supabaseAdmin.from("sessions")
756
+ .select("session_id,user_id,name,player_id,players(fname,lname,bdate,gender,position,height,weight)")
757
+ .eq("session_id", input.sessionId).maybeSingle();
758
+ if (error) throw new Error(`Unable to verify chat session: ${error.message}`);
759
+ if (!session) return res.sendStatus(404);
760
+ if (session.user_id !== current.user.id && !await userIsAdmin(current.user.id) && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
761
+ return res.status(403).json({ error: "An active subscription is required to discuss shared sessions.", code: "subscription_required" });
762
+ }
763
+ context.session = { name: session.name, player: session.players };
764
+ const { data: sessionFeedback, error: sessionFeedbackError } = await supabaseAdmin.from("sessions_feedbacks")
765
+ .select("feedback").eq("session_id", input.sessionId).order("feedback");
766
+ if (sessionFeedbackError) throw new Error(`Unable to load chat feedback: ${sessionFeedbackError.message}`);
767
+ context.sessionFeedback = (sessionFeedback ?? []).map((row) => row.feedback);
768
+ if (!input.actionId) {
769
+ const { data: sessionActions, error: sessionActionsError } = await supabaseAdmin.from("actions")
770
+ .select("action_id,type,start_frame,end_frame,actions_feedbacks(feedback),annotations(score,annotations_notes(note))")
771
+ .eq("session_id", input.sessionId).limit(30);
772
+ if (sessionActionsError) throw new Error(`Unable to load session action context: ${sessionActionsError.message}`);
773
+ context.actions = sessionActions ?? [];
774
+ }
775
+ if (input.actionId) {
776
+ const { data: action, error: actionError } = await supabaseAdmin.from("actions")
777
+ .select("action_id,type,start_frame,end_frame,annotations(score,annotations_notes(note))").eq("session_id", input.sessionId).eq("action_id", input.actionId).maybeSingle();
778
+ if (actionError) throw new Error(`Unable to verify chat action: ${actionError.message}`);
779
+ if (!action) return res.sendStatus(404);
780
+ const { data: feedback, error: feedbackError } = await supabaseAdmin.from("actions_feedbacks")
781
+ .select("feedback").eq("action_id", input.actionId).order("feedback");
782
+ if (feedbackError) throw new Error(`Unable to load chat feedback: ${feedbackError.message}`);
783
+ context.action = { ...action, feedback: (feedback ?? []).map((row) => row.feedback) };
784
+ }
785
+ }
786
+ if (input.playerId && !input.sessionId) {
787
+ const { data: player, error } = await supabaseAdmin.from("players")
788
+ .select("fname,lname,bdate,height,weight,position,gender").eq("player_id", input.playerId).maybeSingle();
789
+ if (error) throw new Error(`Unable to load chat player context: ${error.message}`);
790
+ if (!player) return res.sendStatus(404);
791
+ context.player = player;
792
+ }
793
+ const upstream = await fetch(new URL("/api/chat", env.AGENT_API_URL), {
794
+ method: "POST", headers: { "content-type": "application/json", "x-api-key": env.AGENT_API_KEY, "x-user-id": current.user.id },
795
+ body: JSON.stringify({ actions: [], context, message: input.message, history: input.history }),
796
+ signal: AbortSignal.timeout(95000),
797
+ });
798
+ const result = await upstream.json().catch(() => ({}));
799
+ if (!upstream.ok) return res.status(upstream.status).json({ error: typeof result.detail === "string" ? result.detail : "The coaching Agent could not answer." });
800
+ return res.json(result);
801
+ } catch (error) { next(error); }
802
+ });
803
+
804
  app.put("/api/sessions/:sessionId/actions/:actionId/review", async (req, res, next) => {
805
  try {
806
  const current = await requireActiveSubscription(req, res);