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 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 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 (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( 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(targetId); if (!activity) throw new Error("Nie znaleziono biegu."); const previousRuns = (await listRunningActivities()) .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("running", run._id), })) ); prompt = buildRunningPrompt(activity, previousRunsWithAnalysis); } else { const workout = await getStrengthWorkout(targetId); if (!workout) throw new Error("Nie znaleziono treningu."); const previousWorkouts = (await listStrengthWorkouts()) .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("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({ 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(): 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().then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)), listStrengthWorkouts().then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)), ]); let wellness: DayWellness[] = []; try { const garminClient = await getAuthorizedClient(); 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(summary, tips, model); }