diff --git a/apps/api/src/modules/nutriphi/routes.ts b/apps/api/src/modules/nutriphi/routes.ts index 2fead3ed0..8480d950b 100644 --- a/apps/api/src/modules/nutriphi/routes.ts +++ b/apps/api/src/modules/nutriphi/routes.ts @@ -23,7 +23,12 @@ import { Hono } from 'hono'; import { generateObject } from 'ai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { MealAnalysisSchema } from '@mana/shared-types'; +import { + AI_SCHEMA_VERSION, + MealAnalysisSchema, + type AiResponseEnvelope, + type MealAnalysis, +} from '@mana/shared-types'; import { logger, type AuthVariables } from '@mana/shared-hono'; const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; @@ -36,6 +41,28 @@ const llm = createOpenAICompatible({ const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die Mahlzeit und gib strukturierte Nährwertdaten zurück. Schätze realistische Portionsgrößen und Kalorien. Antworte auf Deutsch.`; +/** + * Provider hints attached to the system message. Forward-compat: + * + * - anthropic.cacheControl: ephemeral system-prompt caching. NO-OP today + * because (a) we route to Gemini via mana-llm and (b) the prompt is + * ~50 tokens — well under Anthropic's 1024-token cache minimum. Becomes + * active automatically when mana-llm routes to Claude AND the prompt + * grows (e.g. once we attach per-user dietary preferences as system + * context, which would push us past the threshold). + * + * Kept here so the day we flip the backend, we don't have to revisit + * every route to enable caching — it just starts working. + */ +const SYSTEM_CACHE_HINT = { + anthropic: { cacheControl: { type: 'ephemeral' as const } }, +}; + +/** Wrap a validated AI object in the standard wire-format envelope. */ +function envelope(data: MealAnalysis): AiResponseEnvelope { + return { schemaVersion: AI_SCHEMA_VERSION, data }; +} + const routes = new Hono<{ Variables: AuthVariables }>(); // ─── Photo Upload (server-only: S3 storage via mana-media) ─── @@ -80,8 +107,12 @@ routes.post('/analysis/photo', async (c) => { const { object } = await generateObject({ model: llm(VISION_MODEL), schema: MealAnalysisSchema, - system: ANALYSIS_PROMPT, messages: [ + { + role: 'system', + content: ANALYSIS_PROMPT, + providerOptions: SYSTEM_CACHE_HINT, + }, { role: 'user', content: [ @@ -92,7 +123,7 @@ routes.post('/analysis/photo', async (c) => { ], temperature: 0.3, }); - return c.json(object); + return c.json(envelope(object)); } catch (err) { logger.error('nutriphi.photo_analysis_failed', { error: err instanceof Error ? err.message : String(err), @@ -111,11 +142,20 @@ routes.post('/analysis/text', async (c) => { const { object } = await generateObject({ model: llm(VISION_MODEL), schema: MealAnalysisSchema, - system: ANALYSIS_PROMPT, - prompt: `Analysiere diese Mahlzeit: ${description}`, + messages: [ + { + role: 'system', + content: ANALYSIS_PROMPT, + providerOptions: SYSTEM_CACHE_HINT, + }, + { + role: 'user', + content: `Analysiere diese Mahlzeit: ${description}`, + }, + ], temperature: 0.3, }); - return c.json(object); + return c.json(envelope(object)); } catch (err) { logger.error('nutriphi.text_analysis_failed', { error: err instanceof Error ? err.message : String(err), diff --git a/apps/api/src/modules/planta/routes.ts b/apps/api/src/modules/planta/routes.ts index b5e6a8d16..9b8de0f3d 100644 --- a/apps/api/src/modules/planta/routes.ts +++ b/apps/api/src/modules/planta/routes.ts @@ -12,7 +12,12 @@ import { Hono } from 'hono'; import { generateObject } from 'ai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { PlantIdentificationSchema } from '@mana/shared-types'; +import { + AI_SCHEMA_VERSION, + PlantIdentificationSchema, + type AiResponseEnvelope, + type PlantIdentification, +} from '@mana/shared-types'; import { logger, type AuthVariables } from '@mana/shared-hono'; const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; @@ -25,6 +30,17 @@ const llm = createOpenAICompatible({ const IDENTIFICATION_PROMPT = `Du bist ein Pflanzenexperte. Analysiere das Pflanzenfoto und liefere eine strukturierte Identifikation mit lateinischem Namen, deutschen Trivialnamen, Pflegehinweisen und einer Gesundheitseinschätzung. Antworte auf Deutsch.`; +// See nutriphi/routes.ts for the rationale: this is a forward-compat +// hint for Anthropic prompt caching, ignored by Gemini today. +const SYSTEM_CACHE_HINT = { + anthropic: { cacheControl: { type: 'ephemeral' as const } }, +}; + +/** Wrap a validated AI object in the standard wire-format envelope. */ +function envelope(data: PlantIdentification): AiResponseEnvelope { + return { schemaVersion: AI_SCHEMA_VERSION, data }; +} + const routes = new Hono<{ Variables: AuthVariables }>(); // ─── Photo Upload (server-only: S3 storage) ───────────────── @@ -70,8 +86,12 @@ routes.post('/analysis/identify', async (c) => { const { object } = await generateObject({ model: llm(VISION_MODEL), schema: PlantIdentificationSchema, - system: IDENTIFICATION_PROMPT, messages: [ + { + role: 'system', + content: IDENTIFICATION_PROMPT, + providerOptions: SYSTEM_CACHE_HINT, + }, { role: 'user', content: [ @@ -81,7 +101,7 @@ routes.post('/analysis/identify', async (c) => { }, ], }); - return c.json(object); + return c.json(envelope(object)); } catch (err) { logger.error('planta.analysis_failed', { error: err instanceof Error ? err.message : String(err), diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/ai-schemas.test.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/ai-schemas.test.ts new file mode 100644 index 000000000..1a4621b11 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/ai-schemas.test.ts @@ -0,0 +1,131 @@ +/** + * Sanity tests for the shared AI wire-format contract. + * + * The schemas themselves are mostly self-validating (Zod parses them at + * import time), so the focus here is the envelope contract: every + * frontend api.ts wraps its fetch result with the same shape, and a + * version drift between client and server should produce a clear, + * actionable error rather than silent corruption. + * + * Lives in the nutriphi module folder because nutriphi was the first + * consumer; the shared-types package itself has no test runner set up. + */ + +import { describe, expect, it } from 'vitest'; +import { + AI_SCHEMA_VERSION, + AiSchemaVersionMismatchError, + MealAnalysisSchema, + PlantIdentificationSchema, +} from '@mana/shared-types'; + +describe('AI_SCHEMA_VERSION', () => { + it('is a non-empty string constant', () => { + expect(typeof AI_SCHEMA_VERSION).toBe('string'); + expect(AI_SCHEMA_VERSION.length).toBeGreaterThan(0); + }); +}); + +describe('AiSchemaVersionMismatchError', () => { + it('captures both versions in the message', () => { + const err = new AiSchemaVersionMismatchError('99', '1' as typeof AI_SCHEMA_VERSION); + expect(err.message).toContain('99'); + expect(err.message).toContain('1'); + expect(err.received).toBe('99'); + expect(err.expected).toBe('1'); + }); + + it('defaults the expected version to AI_SCHEMA_VERSION', () => { + const err = new AiSchemaVersionMismatchError('42'); + expect(err.expected).toBe(AI_SCHEMA_VERSION); + }); + + it('is named so it can be discriminated in catch blocks', () => { + const err = new AiSchemaVersionMismatchError('99'); + expect(err.name).toBe('AiSchemaVersionMismatchError'); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe('MealAnalysisSchema', () => { + const valid = { + foods: [{ name: 'Apfel', quantity: '1 Stück', calories: 95 }], + totalNutrition: { + calories: 95, + protein: 0.5, + carbohydrates: 25, + fat: 0.3, + fiber: 4.4, + sugar: 19, + }, + description: 'Ein mittelgroßer Apfel', + confidence: 0.92, + }; + + it('accepts a complete payload', () => { + const parsed = MealAnalysisSchema.parse(valid); + expect(parsed.foods).toHaveLength(1); + expect(parsed.totalNutrition.calories).toBe(95); + }); + + it('fills in default empty arrays for warnings/suggestions', () => { + const parsed = MealAnalysisSchema.parse(valid); + expect(parsed.warnings).toEqual([]); + expect(parsed.suggestions).toEqual([]); + }); + + it('rejects invalid confidence (out of [0,1])', () => { + expect(() => MealAnalysisSchema.parse({ ...valid, confidence: 1.5 })).toThrow(); + expect(() => MealAnalysisSchema.parse({ ...valid, confidence: -0.1 })).toThrow(); + }); + + it('rejects missing required nutrition fields', () => { + const broken = { + ...valid, + totalNutrition: { calories: 95 }, // missing protein, carbs, etc. + }; + expect(() => MealAnalysisSchema.parse(broken)).toThrow(); + }); + + it('allows foods without quantity/calories (model may not always estimate)', () => { + const minimal = { + ...valid, + foods: [{ name: 'Käse' }], + }; + const parsed = MealAnalysisSchema.parse(minimal); + expect(parsed.foods[0].name).toBe('Käse'); + expect(parsed.foods[0].quantity).toBeUndefined(); + }); +}); + +describe('PlantIdentificationSchema', () => { + it('accepts a complete payload', () => { + const parsed = PlantIdentificationSchema.parse({ + scientificName: 'Monstera deliciosa', + commonNames: ['Fensterblatt', 'Köstliches Fensterblatt'], + confidence: 0.88, + healthAssessment: 'Gesund', + wateringAdvice: 'Alle 7 Tage', + lightAdvice: 'Hell, indirektes Licht', + generalTips: ['Hohe Luftfeuchtigkeit bevorzugt'], + }); + expect(parsed.scientificName).toBe('Monstera deliciosa'); + expect(parsed.commonNames).toHaveLength(2); + }); + + it('fills in default empty arrays for commonNames/generalTips', () => { + const parsed = PlantIdentificationSchema.parse({}); + expect(parsed.commonNames).toEqual([]); + expect(parsed.generalTips).toEqual([]); + }); + + it('accepts an empty object — every field is optional by design', () => { + const parsed = PlantIdentificationSchema.parse({}); + expect(parsed.scientificName).toBeUndefined(); + expect(parsed.confidence).toBeUndefined(); + }); + + it('rejects out-of-range confidence', () => { + expect(() => PlantIdentificationSchema.parse({ confidence: 2 })).toThrow(); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/api.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/api.ts index a721bc6f5..8d9d29729 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/api.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/api.ts @@ -10,11 +10,37 @@ import { authStore } from '$lib/stores/auth.svelte'; import { getManaApiUrl } from '$lib/api/config'; // Wire format is the single source of truth in @mana/shared-types — -// the backend validates AI responses with these same Zod schemas. -import type { MealAnalysis } from '@mana/shared-types'; +// the backend validates AI responses with these same Zod schemas and +// wraps them in an AiResponseEnvelope { schemaVersion, data }. +import { + AI_SCHEMA_VERSION, + AiSchemaVersionMismatchError, + type AiResponseEnvelope, + type MealAnalysis, +} from '@mana/shared-types'; export type MealAnalysisResult = MealAnalysis; +/** + * Decode an AI response envelope, asserting the schema version matches + * the one this client was compiled against. Throws if the server is on + * a different version (clears confusing "field is undefined" bugs in + * the wild — instead you get an actionable error in the network panel). + */ +function unwrapEnvelope(raw: unknown): T { + const env = raw as Partial> | null; + if (!env || typeof env !== 'object' || !('schemaVersion' in env)) { + throw new Error('AI response is not a versioned envelope'); + } + if (env.schemaVersion !== AI_SCHEMA_VERSION) { + throw new AiSchemaVersionMismatchError(String(env.schemaVersion)); + } + if (env.data === undefined) { + throw new Error('AI response envelope missing data field'); + } + return env.data as T; +} + export interface UploadMealPhotoResult { mediaId: string; publicUrl: string; @@ -62,7 +88,7 @@ export async function analyzeMealPhoto(photoUrl: string): Promise; + return unwrapEnvelope(await res.json()); } /** Run Gemini analysis on a free-text meal description. */ @@ -81,5 +107,5 @@ export async function analyzeMealText(description: string): Promise; + return unwrapEnvelope(await res.json()); } diff --git a/apps/mana/apps/web/src/lib/modules/planta/api.ts b/apps/mana/apps/web/src/lib/modules/planta/api.ts index 01d1560f2..3f7ea5483 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/api.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/api.ts @@ -9,11 +9,32 @@ import { authStore } from '$lib/stores/auth.svelte'; import { getManaApiUrl } from '$lib/api/config'; // Wire format is the single source of truth in @mana/shared-types — -// the backend validates AI responses with the same Zod schema. -import type { PlantIdentification } from '@mana/shared-types'; +// the backend validates AI responses with the same Zod schema and +// wraps them in an AiResponseEnvelope { schemaVersion, data }. +import { + AI_SCHEMA_VERSION, + AiSchemaVersionMismatchError, + type AiResponseEnvelope, + type PlantIdentification, +} from '@mana/shared-types'; export type IdentifyResult = PlantIdentification; +/** See nutriphi/api.ts for the rationale. */ +function unwrapEnvelope(raw: unknown): T { + const env = raw as Partial> | null; + if (!env || typeof env !== 'object' || !('schemaVersion' in env)) { + throw new Error('AI response is not a versioned envelope'); + } + if (env.schemaVersion !== AI_SCHEMA_VERSION) { + throw new AiSchemaVersionMismatchError(String(env.schemaVersion)); + } + if (env.data === undefined) { + throw new Error('AI response envelope missing data field'); + } + return env.data as T; +} + export interface UploadPhotoResult { storagePath: string; publicUrl: string; @@ -62,5 +83,5 @@ export async function identifyPlant(photoUrl: string): Promise { throw new Error(`Identify failed (${res.status}): ${body || res.statusText}`); } - return res.json() as Promise; + return unwrapEnvelope(await res.json()); } diff --git a/packages/shared-types/src/ai-schemas.ts b/packages/shared-types/src/ai-schemas.ts index 7893b0cd3..3f347717d 100644 --- a/packages/shared-types/src/ai-schemas.ts +++ b/packages/shared-types/src/ai-schemas.ts @@ -20,6 +20,57 @@ import { z } from 'zod'; +// ─── Wire-format versioning ────────────────────────────────────── +// +// All AI structured-output endpoints wrap their response as +// `{ schemaVersion, data }`. The version is bumped any time the data +// shape changes in a non-additive way. Frontend clients verify the +// version on receipt and throw if it doesn't match what they were +// compiled against — this prevents a stale browser cache from +// silently consuming a payload it can't decode. +// +// Bump rules: +// - Adding an optional field → no bump (forward-compatible) +// - Adding a required field → BUMP (old clients miss it) +// - Removing/renaming any field → BUMP (old clients break) +// - Changing a type → BUMP (zod parse fails on old client) +// +// History: +// 1 — initial schemas (foods/totalNutrition for nutriphi, +// scientificName/commonNames/etc for planta) + +export const AI_SCHEMA_VERSION = '1' as const; +export type AiSchemaVersion = typeof AI_SCHEMA_VERSION; + +/** + * Generic envelope used by every AI structured-output endpoint. + * Backend wraps the validated object in this; frontend api.ts + * unwraps it after checking schemaVersion === AI_SCHEMA_VERSION. + */ +export interface AiResponseEnvelope { + schemaVersion: AiSchemaVersion; + data: T; +} + +/** + * Thrown by frontend api.ts helpers when an envelope arrives with a + * schemaVersion the client wasn't compiled against. The error message + * includes both versions so it's obvious in the network panel which + * side is stale. + */ +export class AiSchemaVersionMismatchError extends Error { + constructor( + public readonly received: string, + public readonly expected: AiSchemaVersion = AI_SCHEMA_VERSION + ) { + super( + `AI wire-format version mismatch: received "${received}", expected "${expected}". ` + + `The client and server are out of sync — reload the page or redeploy.` + ); + this.name = 'AiSchemaVersionMismatchError'; + } +} + // ─── NutriPhi: meal photo / text analysis ──────────────────────── const AnalyzedFoodSchema = z.object({