2026-06-16 09:43:48 +02:00
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" ;
2026-06-18 09:43:25 +02:00
import { getRunningActivity , listRunningActivities , type RunMetrics , type RunningActivity } from "@/lib/models/running" ;
2026-06-16 09:43:48 +02:00
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 } ;
2026-06-18 11:02:31 +02:00
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 ` ;
}
2026-06-18 09:43:25 +02:00
function buildRunMetricsSummary ( metrics : RunMetrics , totalDistanceM : number ) : string [ ] {
2026-06-18 11:02:31 +02:00
const { distanceKm , hrBpm , gcbLeftPct , paceSec } = metrics ;
if ( ! hrBpm && ! gcbLeftPct && ! paceSec ) return [ ] ;
2026-06-18 09:43:25 +02:00
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 ` ) ;
}
2026-06-18 11:02:31 +02:00
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 ) } ` ) ;
}
2026-06-18 09:43:25 +02:00
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 : [ ] ;
}
2026-06-16 09:43:48 +02:00
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 ) } ` ) ;
2026-06-18 09:43:25 +02:00
if ( activity . runMetrics ) {
const metricLines = buildRunMetricsSummary ( activity . runMetrics , activity . distanceM ) ;
if ( metricLines . length > 0 ) {
lines . push ( ` ` , . . . metricLines ) ;
}
}
2026-06-16 09:43:48 +02:00
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 (
2026-06-18 11:02:31 +02:00
userId : string ,
2026-06-16 09:43:48 +02:00
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" ) {
2026-06-18 11:02:31 +02:00
const activity = await getRunningActivity ( userId , targetId ) ;
2026-06-16 09:43:48 +02:00
if ( ! activity ) throw new Error ( "Nie znaleziono biegu." ) ;
2026-06-18 11:02:31 +02:00
const previousRuns = ( await listRunningActivities ( userId ) )
2026-06-16 09:43:48 +02:00
. filter ( ( run ) = > run . startTime < activity . startTime )
. slice ( 0 , PREVIOUS_RUNS_LIMIT ) ;
const previousRunsWithAnalysis : PreviousRun [ ] = await Promise . all (
previousRuns . map ( async ( run ) = > ( {
run ,
2026-06-18 11:02:31 +02:00
analysis : await getLatestAnalysisForTarget ( userId , "running" , run . _id ) ,
2026-06-16 09:43:48 +02:00
} ) )
) ;
prompt = buildRunningPrompt ( activity , previousRunsWithAnalysis ) ;
} else {
2026-06-18 11:02:31 +02:00
const workout = await getStrengthWorkout ( userId , targetId ) ;
2026-06-16 09:43:48 +02:00
if ( ! workout ) throw new Error ( "Nie znaleziono treningu." ) ;
2026-06-18 11:02:31 +02:00
const previousWorkouts = ( await listStrengthWorkouts ( userId ) )
2026-06-16 09:43:48 +02:00
. filter ( ( previous ) = > previous . date < workout . date )
. slice ( 0 , PREVIOUS_WORKOUTS_LIMIT ) ;
const previousWorkoutsWithAnalysis : PreviousWorkout [ ] = await Promise . all (
previousWorkouts . map ( async ( previous ) = > ( {
workout : previous ,
2026-06-18 11:02:31 +02:00
analysis : await getLatestAnalysisForTarget ( userId , "strength" , previous . _id ) ,
2026-06-16 09:43:48 +02:00
} ) )
) ;
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 ( {
2026-06-18 11:02:31 +02:00
userId ,
2026-06-16 09:43:48 +02:00
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" ) ;
}
2026-06-18 11:02:31 +02:00
export async function generateDashboardAnalysis ( userId : string ) : Promise < AiAnalysis > {
2026-06-16 09:43:48 +02:00
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 ( [
2026-06-18 11:02:31 +02:00
listRunningActivities ( userId ) . then ( ( r ) = > r . slice ( 0 , DASHBOARD_RUNS_LIMIT ) ) ,
listStrengthWorkouts ( userId ) . then ( ( w ) = > w . slice ( 0 , DASHBOARD_WORKOUTS_LIMIT ) ) ,
2026-06-16 09:43:48 +02:00
] ) ;
let wellness : DayWellness [ ] = [ ] ;
try {
2026-06-18 11:02:31 +02:00
const garminClient = await getAuthorizedClient ( userId ) ;
2026-06-16 09:43:48 +02:00
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 ) ;
2026-06-18 11:02:31 +02:00
return saveDashboardAnalysis ( userId , summary , tips , model ) ;
2026-06-16 09:43:48 +02:00
}