Files
knur-app/lib/ai/claude.ts
Dominik Klarkowski 36407f534b init
2026-06-16 09:43:48 +02:00

315 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}