Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ These are stored in `.mcp.json` and shared with all team members.
- Implementation plans: `docs/plans/` (dated, versioned by phase)
- Decision records: `docs/research/` (dated ADR-style documents)
- **New features must be added to `docs/product-capabilities.md`** with a use-case ID (e.g., UC-RPT-05), user story, behavior description, and E2E coverage reference
- **Update `docs/architecture.md`** when adding or modifying: services, routes/endpoints, database tables, or `@vitals/shared` interfaces. The file tree, API endpoints table, and data model table must stay in sync with the code.
- Feature spec template: `.claude/skills/dev-pipeline/templates/feature-spec.md`

## Development Pipeline
Expand Down Expand Up @@ -158,6 +159,10 @@ Fix all HIGH and MEDIUM findings before proceeding.
acceptance criterion from Phase 0.

**Phase 8 — DOCUMENTATION**: Update affected project documentation.
Check each trigger: (1) new/changed services or file tree → update `docs/architecture.md` tree,
(2) new/changed routes → update `docs/architecture.md` API endpoints table,
(3) new/changed tables → update `docs/architecture.md` data model table,
(4) user-facing feature → update `docs/product-capabilities.md`.

**Phase 9 — COMMIT & PR**: Stage, commit, push, open PR.

Expand Down
27 changes: 24 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,24 @@ src/
│ ├── cronometer/ Nutrition + biometrics scraper
│ ├── hevy/ Workout API client
│ └── apple-health/ XML upload parser (Phase 3)
├── action-items/ Action item lifecycle (outcome measurer, lifecycle manager)
├── intelligence/ PHIE: correlation engine + trajectory projector
├── workout-plans/
│ ├── plan-parser.ts LLM structured output parser + regex fallback
│ ├── plan-schema.ts validatePlanData()
│ ├── exercise-metadata.ts getExerciseMeta() — 50+ exercise classification table
│ ├── tuner.ts AI plan fine-tuner (structured output → candidate selection)
│ ├── tuner-prompt-builder.ts Prompt assembly for tuner
│ └── rules/ Candidate generator + progression rules + safety caps
├── report-event-bus.ts In-process pub/sub for report status
├── report-runner.ts Background report orchestrator
└── ai/
├── claude-provider.ts AIProvider implementation (Claude)
├── gemini-provider.ts AIProvider implementation (Gemini)
├── claude-provider.ts AIProvider impl (Claude) — complete, completeStructured (tool_use), stream
├── gemini-provider.ts AIProvider impl (Gemini) — complete, completeStructured (responseSchema), stream
├── ai-service.ts Provider factory (AI_PROVIDER env)
├── retry-utils.ts completeWithRetry + completeStructuredWithRetry (exponential backoff)
├── conversation-service.ts Agentic loop: chat() + chatStream() (Phase 6A)
├── report-generator.ts Report orchestration (data fetch + AI call + save)
├── report-generator.ts Report orchestration (structured output → schema-guaranteed JSON)
├── prompt-builder.ts Data formatting + prompt assembly
├── prompt-loader.ts Loads .md prompt files at startup
├── prompts/ Prompt files
Expand Down Expand Up @@ -108,6 +118,10 @@ src/
| `conversations` | Chat conversation sessions (Phase 6A) | PK: UUID, FK: user_id |
| `messages` | Individual chat messages (Phase 6A) | role CHECK: user/assistant/tool, JSONB tool_calls |
| `action_items` | Persistent tracked action items from weekly reports (F3) | FK: weekly_reports(id) CASCADE; status CHECK with 7 states; 3 indexes |
| `workout_plans` | User workout plans with active version tracking | PK: UUID, FK: user_id; one active plan per user |
| `plan_versions` | Versioned plan data (JSONB PlanData) | FK: workout_plans(id); source: user/tuner; parent chain |
| `adjustment_batches` | AI tuner output batches | FK: plan_versions, weekly_reports; rationale + AI metadata |
| `plan_adjustments` | Individual exercise adjustments within a batch | FK: adjustment_batches; status: pending/accepted/rejected |
| `correlations` | PHIE: discovered Pearson correlations across nutrition/training/biometric data | Unique: `(user_id, factor_metric, factor_condition, outcome_metric)`; CHECK on `confidence_level`, `status`, `category`; `first_detected_at` preserved across re-runs |
| `projections` | PHIE: 30-day trajectory projections with OLS confidence bands | Unique: `(user_id, metric, projection_date)`; CHECK on `method` |

Expand Down Expand Up @@ -221,6 +235,13 @@ POST /api/reports/generate
| PATCH | `/api/action-items/:id/status` | X-API-Key | F3 |
| GET | `/api/correlations` | None | PHIE Phase 1 |
| GET | `/api/projections/:metric` | None | PHIE Phase 1 |
| POST | `/api/workout-plans` | X-API-Key | F2 |
| GET | `/api/workout-plans/current` | None | F2 |
| GET | `/api/workout-plans/:id/versions` | None | F2 |
| GET | `/api/workout-plans/versions/:versionId` | None | F2 |
| PUT | `/api/workout-plans/:id` | X-API-Key | F2 |
| POST | `/api/workout-plans/:id/tune` | X-API-Key | F2 |
| PATCH | `/api/workout-plans/adjustments/:batchId` | X-API-Key | F2 |

## Authentication

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/2026-04-13-phase1-structured-output.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Phase 1: Structured Output via tool_use

**Date:** 2026-04-13
**Status:** Approved — ready for implementation
**Status:** Implemented
**Reference:** `docs/research/2026-04-13-agent-sdk-integration-analysis.md`

## Context
Expand Down
10 changes: 0 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"dotenv": "^16.4.0",
"fastify": "^5.2.0",
"fastify-plugin": "^5.1.0",
"jsonrepair": "^3.13.3",
"pg": "^8.13.0"
},
"devDependencies": {
Expand Down
25 changes: 23 additions & 2 deletions packages/backend/src/routes/workout-plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,13 @@ export async function workoutPlanRoutes(
statusCode: 400,
});
} else if (rawText) {
planData = parseFreeTextPlan(rawText);
let aiProvider: AIProvider | undefined;
try {
aiProvider = createAIProvider(opts.env);
} catch {
// AI not configured — regex fallback will be used
}
planData = await parseFreeTextPlan(rawText, aiProvider);
} else {
return reply.code(400).send({
error: 'Bad Request',
Expand Down Expand Up @@ -175,7 +181,22 @@ export async function workoutPlanRoutes(
});
}

const planData = parseFreeTextPlan(rawText);
const RAW_TEXT_MAX_CHARS = 50_000;
if (rawText.length > RAW_TEXT_MAX_CHARS) {
return reply.code(413).send({
error: 'Payload Too Large',
message: 'Plan text too large',
statusCode: 413,
});
}

let aiProvider: AIProvider | undefined;
try {
aiProvider = createAIProvider(opts.env);
} catch {
// AI not configured — regex fallback will be used
}
const planData = await parseFreeTextPlan(rawText, aiProvider);
const version = await insertPlanVersion(app.db, plan.id, {
source: 'user',
parentVersionId: plan.activeVersionId,
Expand Down
110 changes: 36 additions & 74 deletions packages/backend/src/services/ai/__tests__/report-generator.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateWeeklyReport } from '../report-generator.js';
import type pg from 'pg';
import type { AIProvider, AICompletionResult } from '@vitals/shared';
import type { AIProvider } from '@vitals/shared';

vi.mock('../../../db/queries/measurements.js', () => ({
queryDailyNutritionSummary: vi
Expand Down Expand Up @@ -47,30 +47,40 @@ vi.mock('../../action-items/lifecycle-manager.js', () => ({
supersedeItems: vi.fn().mockResolvedValue(0),
}));

const validAIResponse = JSON.stringify({
const validAIData = {
summary: 'A productive week.',
insights: '- Calories on target\n- Protein slightly low',
biometricsOverview: '',
nutritionAnalysis: '',
trainingLoad: '',
crossDomainCorrelation: '',
whatsWorking: '',
hazards: '',
recommendations: '',
scorecard: {},
actionItems: [{ category: 'nutrition', priority: 'medium', text: 'Increase protein by 20g.' }],
});
};

const mockAIProvider: AIProvider = {
name: () => 'claude',
complete: vi.fn(),
completeWithTools: vi.fn(),
stream: vi.fn(),
complete: vi.fn().mockResolvedValue({
content: validAIResponse,
completeStructured: vi.fn().mockResolvedValue({
data: validAIData,
content: '',
model: 'claude-sonnet-4-20250514',
usage: { promptTokens: 500, completionTokens: 200, totalTokens: 700 },
} satisfies AICompletionResult),
}),
};

const mockPool = {} as pg.Pool;

describe('generateWeeklyReport', () => {
beforeEach(() => {
vi.clearAllMocks();
(mockAIProvider.complete as ReturnType<typeof vi.fn>).mockResolvedValue({
content: validAIResponse,
(mockAIProvider.completeStructured as ReturnType<typeof vi.fn>).mockResolvedValue({
data: validAIData,
content: '',
model: 'claude-sonnet-4-20250514',
usage: { promptTokens: 500, completionTokens: 200, totalTokens: 700 },
});
Expand Down Expand Up @@ -135,27 +145,24 @@ describe('generateWeeklyReport', () => {
expect(logAiGeneration).toHaveBeenCalledOnce();
});

it('handles malformed AI JSON with fallback', async () => {
(mockAIProvider.complete as ReturnType<typeof vi.fn>).mockResolvedValue({
content: 'This is not JSON at all.',
model: 'claude-sonnet-4-20250514',
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
});

const result = await generateWeeklyReport(
mockPool,
mockAIProvider,
'user-uuid',
new Date('2026-03-01'),
new Date('2026-03-07'),
it('propagates AI provider error when completeStructured fails', async () => {
(mockAIProvider.completeStructured as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('AI service error'),
);

expect(result.summary).toBeTruthy();
expect(result.actionItems).toEqual([]);
await expect(
generateWeeklyReport(
mockPool,
mockAIProvider,
'user-uuid',
new Date('2026-03-01'),
new Date('2026-03-07'),
),
).rejects.toThrow('AI service error');
});

it('parses structured sections from AI response', async () => {
const sectionsResponse = JSON.stringify({
const sectionsData = {
summary: 'Solid week with HRV concerns.',
biometricsOverview: '## Body Composition\nWeight stable at 67 kg.',
nutritionAnalysis: '## Daily Averages\nCalories: 2200 kcal.',
Expand All @@ -169,10 +176,11 @@ describe('generateWeeklyReport', () => {
recovery: { score: 4, notes: 'HRV dropping' },
},
actionItems: [{ category: 'nutrition', priority: 'high', text: 'Add 150 kcal' }],
});
};

(mockAIProvider.complete as ReturnType<typeof vi.fn>).mockResolvedValue({
content: sectionsResponse,
(mockAIProvider.completeStructured as ReturnType<typeof vi.fn>).mockResolvedValue({
data: sectionsData,
content: '',
model: 'claude-sonnet-4-20250514',
usage: { promptTokens: 1000, completionTokens: 500, totalTokens: 1500 },
});
Expand All @@ -189,53 +197,7 @@ describe('generateWeeklyReport', () => {
expect(result.sections!.biometricsOverview).toContain('Weight stable');
expect(result.sections!.scorecard.recovery.score).toBe(4);
expect(result.summary).toBe('Solid week with HRV concerns.');
// insights should be concatenated markdown for backward compat
expect(result.insights).toContain('Body Composition');
expect(result.insights).toContain('HRV drop');
});

it('recovers summary when AI response contains unescaped quotes in JSON strings', async () => {
// AI writes "adequate" with straight double quotes inside a JSON string value,
// which breaks JSON.parse() — jsonrepair should fix this.
const brokenJson =
'{"summary": "Recovery was "adequate" this week.", "actionItems": [], ' +
'"biometricsOverview": "Weight stable.", "nutritionAnalysis": "", ' +
'"trainingLoad": "", "crossDomainCorrelation": "", ' +
'"whatsWorking": "", "hazards": "", "recommendations": ""}';

(mockAIProvider.complete as ReturnType<typeof vi.fn>).mockResolvedValue({
content: brokenJson,
model: 'claude-sonnet-4-20250514',
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
});

const result = await generateWeeklyReport(
mockPool,
mockAIProvider,
'user-uuid',
new Date('2026-03-01'),
new Date('2026-03-07'),
);

expect(result.summary).not.toBe('AI-generated weekly summary.');
expect(result.summary).toContain('adequate');
});

it('strips markdown code fences from AI response', async () => {
(mockAIProvider.complete as ReturnType<typeof vi.fn>).mockResolvedValue({
content: '```json\n' + validAIResponse + '\n```',
model: 'claude-sonnet-4-20250514',
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
});

const result = await generateWeeklyReport(
mockPool,
mockAIProvider,
'user-uuid',
new Date('2026-03-01'),
new Date('2026-03-07'),
);

expect(result.summary).toBe('A productive week.');
});
});
44 changes: 44 additions & 0 deletions packages/backend/src/services/ai/claude-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AITool,
AIToolCompletionResult,
AIStreamChunk,
StructuredOutputConfig,
} from '@vitals/shared';

const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
Expand Down Expand Up @@ -93,6 +94,49 @@ export class ClaudeProvider implements AIProvider {
};
}

async completeStructured<T>(
messages: AIMessage[],
output: StructuredOutputConfig,
config?: Partial<AIProviderConfig>,
): Promise<{ data: T } & AICompletionResult> {
const model = config?.model || this.model;
const maxTokens = config?.maxTokens ?? this.maxTokens;

const systemMessage = messages.find((m) => m.role === 'system');

const tool: Anthropic.Tool = {
name: output.name,
description: output.description,
input_schema: output.schema as Anthropic.Tool['input_schema'],
};

const response = await this.client.messages.create({
model,
max_tokens: maxTokens,
system: systemMessage?.content,
messages: this.buildAnthropicMessages(messages),
tools: [tool],
tool_choice: { type: 'tool', name: output.name },
});

const toolBlock = response.content.find((block) => block.type === 'tool_use');
if (!toolBlock || toolBlock.type !== 'tool_use') {
throw new Error(`Expected tool_use block for "${output.name}" but got none`);
}

return {
data: (toolBlock as { type: 'tool_use'; id: string; name: string; input: unknown })
.input as T,
content: '',
model: response.model,
usage: {
promptTokens: response.usage.input_tokens,
completionTokens: response.usage.output_tokens,
totalTokens: response.usage.input_tokens + response.usage.output_tokens,
},
};
}

async completeWithTools(
messages: AIMessage[],
tools: AITool[],
Expand Down
Loading
Loading