315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
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<AiAnalysis> {
|
||
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<string, string> = {
|
||
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<AiAnalysis> {
|
||
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);
|
||
}
|