import Anthropic from "@anthropic-ai/sdk"; import { ObjectId } from "mongodb"; import { formatDate, formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format"; import { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness"; import { getAuthorizedClient } from "@/lib/garmin/client"; import { getRunningActivity, listRunningActivities, type RunMetrics, type RunningActivity } from "@/lib/models/running"; import { getStrengthWorkout, listStrengthWorkouts, type StrengthWorkout } from "@/lib/models/strength"; import { getLatestAnalysisForTarget, saveAiAnalysis, saveDashboardAnalysis, type AiAnalysis, type AiAnalysisTargetType, } from "@/lib/models/analysis"; const DEFAULT_MODEL = "claude-sonnet-4-6"; const PREVIOUS_RUNS_LIMIT = 5; const PREVIOUS_WORKOUTS_LIMIT = 2; const DASHBOARD_RUNS_LIMIT = 6; const DASHBOARD_WORKOUTS_LIMIT = 4; const DASHBOARD_WELLNESS_DAYS = 7; const PROMPT_INSTRUCTIONS = `Odpowiedz wyłącznie w formacie JSON (bez dodatkowego tekstu, bez markdown): {"summary": "krótkie podsumowanie treningu po polsku (2-3 zdania)", "tips": ["wskazówka 1", "wskazówka 2", "wskazówka 3"]} Podaj od 2 do 4 konkretnych, praktycznych wskazówek na kolejne treningi. Jeśli podano dane z poprzednich treningów, odnieś się do progresu (np. zmiana dystansu, tempa, ciężarów czy powtórzeń względem poprzednich sesji).`; type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null }; type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null }; function secToMinKm(sec: number): string { const m = Math.floor(sec / 60); const s = Math.round(sec % 60); return `${m}:${s.toString().padStart(2, "0")} min/km`; } function buildRunMetricsSummary(metrics: RunMetrics, totalDistanceM: number): string[] { const { distanceKm, hrBpm, gcbLeftPct, paceSec } = metrics; if (!hrBpm && !gcbLeftPct && !paceSec) return []; const n = distanceKm.length; if (n < 8) return []; const maxDist = Math.max(...distanceKm); const totalKm = totalDistanceM / 1000; const useIndex = maxDist === 0; const position = (i: number) => (useIndex ? i / n : distanceKm[i] / maxDist); const kmLabel = (i: number) => useIndex ? ((i / n) * totalKm).toFixed(1) : distanceKm[i].toFixed(1); const avg = (vals: number[]) => vals.length ? Math.round(vals.reduce((s, v) => s + v, 0) / vals.length) : null; const lines = [`Dane w trakcie biegu (4 kwartyle):`]; for (let q = 0; q < 4; q++) { const from = q / 4; const to = (q + 1) / 4; const idx = Array.from({ length: n }, (_, i) => i).filter( (i) => position(i) >= from && position(i) < to ); if (idx.length === 0) continue; const parts = [`${kmLabel(idx[0])}–${kmLabel(idx[idx.length - 1])} km`]; if (hrBpm) { const vals = idx.map((i) => hrBpm[i]).filter((v) => v > 0); const a = avg(vals); if (a !== null) parts.push(`HR śr. ${a} bpm`); } if (paceSec) { const vals = idx.map((i) => paceSec[i]).filter((v) => v > 0 && v < 1800); const a = avg(vals); if (a !== null) parts.push(`tempo śr. ${secToMinKm(a)}`); } if (gcbLeftPct) { const vals = idx.map((i) => gcbLeftPct[i]).filter((v) => v > 0); if (vals.length > 0) { const mean = vals.reduce((s, v) => s + v, 0) / vals.length; parts.push(`balans L/P ${mean.toFixed(1)}%/${(100 - mean).toFixed(1)}%`); } } lines.push(`- ${parts.join(", ")}`); } return lines.length > 1 ? lines : []; } function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun[]): string { const lines = [ `Przeanalizuj poniższy bieg i podaj krótkie podsumowanie oraz wskazówki potreningowe.`, ``, `Nazwa: ${activity.name}`, `Data: ${formatDate(activity.startTime)}`, `Dystans: ${formatDistance(activity.distanceM)}`, `Czas: ${formatDuration(activity.durationSec)}`, `Tempo: ${formatPace(activity.avgPaceSecPerKm)}`, ]; if (activity.avgHr) lines.push(`Średnie tętno: ${Math.round(activity.avgHr)} bpm`); if (activity.maxHr) lines.push(`Maksymalne tętno: ${Math.round(activity.maxHr)} bpm`); if (activity.avgCadence) lines.push(`Kadencja: ${Math.round(activity.avgCadence)} kroków/min`); if (activity.elevationGainM) lines.push(`Suma podejść: ${Math.round(activity.elevationGainM)} m`); if (activity.calories) lines.push(`Spalone kalorie: ${Math.round(activity.calories)} kcal`); if (activity.vo2Max) lines.push(`VO2max: ${Math.round(activity.vo2Max)}`); if (activity.avgGroundContactTimeMs) lines.push(`Czas kontaktu z podłożem: ${Math.round(activity.avgGroundContactTimeMs)} ms`); if (activity.avgVerticalOscillationCm) lines.push(`Oscylacja wertykalna: ${activity.avgVerticalOscillationCm.toFixed(1)} cm`); if (activity.avgVerticalRatioPct) lines.push(`Wskaźnik wertykalny: ${activity.avgVerticalRatioPct.toFixed(1)}%`); if (activity.avgStrideLengthCm) lines.push(`Długość kroku: ${activity.avgStrideLengthCm.toFixed(0)} cm`); if (activity.avgGroundContactBalanceLeftPct) { lines.push( `Balans kontaktu z podłożem (L/P): ${activity.avgGroundContactBalanceLeftPct.toFixed(1)}% / ${(100 - activity.avgGroundContactBalanceLeftPct).toFixed(1)}%` ); } if (activity.avgPowerW) lines.push(`Moc średnia: ${Math.round(activity.avgPowerW)} W`); if (activity.avgRespirationRate) lines.push(`Częstość oddechów: ${activity.avgRespirationRate.toFixed(1)}/min`); if (activity.aerobicTrainingEffect) lines.push(`Efekt treningowy aerobowy: ${activity.aerobicTrainingEffect.toFixed(1)}`); if (activity.anaerobicTrainingEffect) lines.push(`Efekt treningowy anaerobowy: ${activity.anaerobicTrainingEffect.toFixed(1)}`); if (activity.runMetrics) { const metricLines = buildRunMetricsSummary(activity.runMetrics, activity.distanceM); if (metricLines.length > 0) { lines.push(``, ...metricLines); } } if (previousRuns.length > 0) { lines.push(``, `Poprzednie biegi (od najnowszego):`); for (const { run, analysis } of previousRuns) { lines.push( `- ${formatDateShort(run.startTime)}: ${formatDistance(run.distanceM)}, ${formatDuration(run.durationSec)}, tempo ${formatPace(run.avgPaceSecPerKm)}` ); if (analysis) { lines.push(` Poprzednia analiza AI: ${analysis.summary}`); if (analysis.tips.length > 0) { lines.push(` Wskazówki z poprzedniej analizy: ${analysis.tips.join(" | ")}`); } } } } lines.push(``, PROMPT_INSTRUCTIONS); return lines.join("\n"); } function formatExerciseSets(exercise: StrengthWorkout["exercises"][number]): string { return exercise.sets .map((set) => { const weight = set.weightKg !== undefined ? `${set.weightKg} kg` : "bez obciążenia"; return `${weight} × ${set.reps ?? "?"}`; }) .join(", "); } function buildStrengthPrompt(workout: StrengthWorkout, previousWorkouts: PreviousWorkout[]): string { const lines = [ `Przeanalizuj poniższy trening siłowy i podaj krótkie podsumowanie oraz wskazówki potreningowe.`, ``, `Nazwa: ${workout.name}`, `Data: ${formatDate(workout.date)}`, ]; if (workout.notes) lines.push(`Notatki: ${workout.notes}`); lines.push(``, `Ćwiczenia:`); for (const exercise of workout.exercises) { lines.push(`- ${exercise.name}: ${formatExerciseSets(exercise)}`); if (exercise.notes) lines.push(` Notatka: ${exercise.notes}`); } if (previousWorkouts.length > 0) { lines.push(``, `Poprzednie treningi (od najnowszego):`); for (const { workout: previous, analysis } of previousWorkouts) { lines.push(`${formatDateShort(previous.date)} - ${previous.name}:`); for (const exercise of previous.exercises) { lines.push(` - ${exercise.name}: ${formatExerciseSets(exercise)}`); } if (analysis) { lines.push(` Poprzednia analiza AI: ${analysis.summary}`); if (analysis.tips.length > 0) { lines.push(` Wskazówki z poprzedniej analizy: ${analysis.tips.join(" | ")}`); } } } } lines.push(``, PROMPT_INSTRUCTIONS); return lines.join("\n"); } function parseAnalysisResponse(text: string): { summary: string; tips: string[] } { try { const match = text.match(/\{[\s\S]*\}/); const parsed = JSON.parse(match ? match[0] : text); const summary = typeof parsed.summary === "string" ? parsed.summary : text; const tips = Array.isArray(parsed.tips) ? parsed.tips.filter((tip: unknown) => typeof tip === "string") : []; return { summary, tips }; } catch { return { summary: text, tips: [] }; } } export async function generateAnalysis( userId: string, targetType: AiAnalysisTargetType, targetId: string ): Promise { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) { throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji."); } let prompt: string; if (targetType === "running") { const activity = await getRunningActivity(userId, targetId); if (!activity) throw new Error("Nie znaleziono biegu."); const previousRuns = (await listRunningActivities(userId)) .filter((run) => run.startTime < activity.startTime) .slice(0, PREVIOUS_RUNS_LIMIT); const previousRunsWithAnalysis: PreviousRun[] = await Promise.all( previousRuns.map(async (run) => ({ run, analysis: await getLatestAnalysisForTarget(userId, "running", run._id), })) ); prompt = buildRunningPrompt(activity, previousRunsWithAnalysis); } else { const workout = await getStrengthWorkout(userId, targetId); if (!workout) throw new Error("Nie znaleziono treningu."); const previousWorkouts = (await listStrengthWorkouts(userId)) .filter((previous) => previous.date < workout.date) .slice(0, PREVIOUS_WORKOUTS_LIMIT); const previousWorkoutsWithAnalysis: PreviousWorkout[] = await Promise.all( previousWorkouts.map(async (previous) => ({ workout: previous, analysis: await getLatestAnalysisForTarget(userId, "strength", previous._id), })) ); prompt = buildStrengthPrompt(workout, previousWorkoutsWithAnalysis); } const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL; const client = new Anthropic({ apiKey }); const message = await client.messages.create({ model, max_tokens: 1024, messages: [{ role: "user", content: prompt }], }); const textBlock = message.content.find((block) => block.type === "text"); const text = textBlock && textBlock.type === "text" ? textBlock.text : ""; const { summary, tips } = parseAnalysisResponse(text); return saveAiAnalysis({ userId, targetType, targetId: new ObjectId(targetId), summary, tips, model, }); } function formatHrvStatus(status: string): string { const map: Record = { BALANCED: "zrównoważone", UNBALANCED: "niezrównoważone", LOW: "niskie", POOR: "złe", }; return map[status] ?? status; } function buildDashboardPrompt( runs: RunningActivity[], workouts: StrengthWorkout[], wellness: DayWellness[] ): string { const lines = [ `Jesteś asystentem sportowym analizującym pełny obraz kondycji i stanu treningowego zawodnika.`, `Na podstawie poniższych danych oceń: poziom zmęczenia i regeneracji, balans między treningiem siłowym a biegowym, trendy wydolnościowe oraz gotowość do kolejnych treningów.`, ``, ]; if (runs.length > 0) { lines.push(`BIEGI (${runs.length} ostatnich sesji, od najnowszej):`); for (const run of runs) { const parts = [ formatDateShort(run.startTime), formatDistance(run.distanceM), `tempo ${formatPace(run.avgPaceSecPerKm)}`, ]; if (run.avgHr) parts.push(`HR śr. ${Math.round(run.avgHr)} bpm`); if (run.vo2Max) parts.push(`VO2max ${Math.round(run.vo2Max)}`); if (run.aerobicTrainingEffect) parts.push(`TE aerobowy ${run.aerobicTrainingEffect.toFixed(1)}`); lines.push(`- ${parts.join(", ")}`); } lines.push(``); } if (workouts.length > 0) { lines.push(`TRENINGI SIŁOWE (${workouts.length} ostatnich sesji, od najnowszej):`); for (const workout of workouts) { lines.push(`- ${formatDateShort(workout.date)} — ${workout.name}:`); for (const exercise of workout.exercises) { const topSet = exercise.sets.reduce( (best, set) => (set.weightKg ?? 0) > (best.weightKg ?? 0) ? set : best, exercise.sets[0] ); const summary = topSet ? `maks. ${topSet.weightKg ?? "—"} kg × ${topSet.reps ?? "?"} (${exercise.sets.length} serie)` : `${exercise.sets.length} serie`; lines.push(` · ${exercise.name}: ${summary}`); } } lines.push(``); } const wellnessWithData = wellness.filter( (d) => d.sleepScore || d.avgOvernightHrv || d.sleepDurationMin ); if (wellnessWithData.length > 0) { lines.push(`SEN I HRV (ostatnie ${wellness.length} dni):`); for (const day of wellness) { const parts: string[] = [day.date]; if (day.sleepDurationMin) { const h = Math.floor(day.sleepDurationMin / 60); const m = day.sleepDurationMin % 60; parts.push(`sen ${h}h ${m}min`); } if (day.sleepScore) parts.push(`wynik snu ${day.sleepScore}/100`); if (day.avgOvernightHrv) { parts.push(`HRV ${Math.round(day.avgOvernightHrv)} ms${day.hrvStatus ? ` (${formatHrvStatus(day.hrvStatus)})` : ""}`); } if (day.restingHr) parts.push(`HR spoczynkowe ${day.restingHr} bpm`); if (typeof day.bodyBatteryChange === "number") { parts.push(`Body Battery ${day.bodyBatteryChange > 0 ? "+" : ""}${day.bodyBatteryChange}`); } if (parts.length > 1) lines.push(`- ${parts.join(", ")}`); } lines.push(``); } lines.push( `Odpowiedz wyłącznie w formacie JSON (bez dodatkowego tekstu, bez markdown):`, `{"summary": "ocena ogólnego stanu kondycji i regeneracji po polsku (3-4 zdania)", "tips": ["wskazówka 1", "wskazówka 2", "wskazówka 3"]}`, `Podaj 3-5 konkretnych, praktycznych wskazówek dotyczących planowania kolejnych treningów, regeneracji i zdrowia.`, `Uwzględnij trendy HRV i jakości snu przy ocenie gotowości do wysiłku.` ); return lines.join("\n"); } export async function generateDashboardAnalysis(userId: string): Promise { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji."); const [runs, workouts] = await Promise.all([ listRunningActivities(userId).then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)), listStrengthWorkouts(userId).then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)), ]); let wellness: DayWellness[] = []; try { const garminClient = await getAuthorizedClient(userId); wellness = await fetchRecentWellness(garminClient, DASHBOARD_WELLNESS_DAYS); } catch { // Wellness data not available, proceed without it } const prompt = buildDashboardPrompt(runs, workouts, wellness); const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL; const anthropic = new Anthropic({ apiKey }); const message = await anthropic.messages.create({ model, max_tokens: 1024, messages: [{ role: "user", content: prompt }], }); const textBlock = message.content.find((b) => b.type === "text"); const text = textBlock && textBlock.type === "text" ? textBlock.text : ""; const { summary, tips } = parseAnalysisResponse(text); return saveDashboardAnalysis(userId, summary, tips, model); }