This commit is contained in:
Dominik Klarkowski
2026-06-16 09:43:48 +02:00
parent f0e87d8d11
commit 36407f534b
52 changed files with 3211 additions and 100 deletions

314
lib/ai/claude.ts Normal file
View File

@@ -0,0 +1,314 @@
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);
}