feat(shared-types): add Zod schemas for AI structured outputs

Introduces packages/shared-types/src/ai-schemas.ts as the single source
of truth for the wire format between mana-api and the unified Mana app.

Two schemas:
  - MealAnalysisSchema (foods, totalNutrition, description, confidence,
    warnings, suggestions) — consumed by nutriphi /analysis/photo and
    /analysis/text routes
  - PlantIdentificationSchema (scientificName, commonNames, confidence,
    health/watering/light advice, generalTips) — consumed by planta
    /analysis/identify

Both schemas include .describe() annotations on every field. The Vercel
AI SDK passes these through to the model as part of the structured-output
prompt, which materially improves accuracy on Gemini Vision (the model
sees both the field name AND the German-language hint about what to put
there).

Schemas use plain .optional() rather than .nullable() because
generateObject() guides the model with strict schema adherence — it
won't emit JSON null for missing fields, just omit them.

Deps wired up:
  - apps/api: + ai@6, + @ai-sdk/openai-compatible@2, + @mana/shared-types
  - apps/mana/apps/web: + zod (for z.infer of the shared schemas)
  - packages/shared-types: + zod (for the schema definitions themselves)

All three on zod ^3.23 to stay in lockstep with the existing
apps/api zod usage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 16:59:28 +02:00
parent 63e1ef8233
commit c2a75bb8e1
6 changed files with 454 additions and 325 deletions

View file

@ -12,10 +12,13 @@
"db:push": "drizzle-kit push"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.41",
"@mana/media-client": "workspace:*",
"@mana/shared-hono": "workspace:*",
"@mana/shared-storage": "workspace:*",
"@mana/shared-types": "workspace:^",
"@mozilla/readability": "^0.5.0",
"ai": "^6.0.154",
"drizzle-orm": "^0.38.0",
"hono": "^4.7.0",
"jsdom": "^25.0.0",

View file

@ -81,7 +81,8 @@
"suncalc": "^1.9.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.0",
"svelte-sonner": "^1.0.5"
"svelte-sonner": "^1.0.5",
"zod": "^3.25.76"
},
"type": "module"
}

View file

@ -15,5 +15,8 @@
},
"devDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"zod": "^3.25.76"
}
}

View file

@ -0,0 +1,71 @@
/**
* Shared Zod schemas for AI structured-output endpoints.
*
* Single source of truth for the wire format between mana-api and the
* unified Mana app. Backend routes use these schemas with the Vercel
* AI SDK's `generateObject` to enforce a strict shape on Gemini Vision
* responses; the frontend imports the inferred TypeScript types via
* `z.infer<typeof Schema>` so changes here propagate end-to-end.
*
* Why a Zod schema instead of a plain TS interface?
* - Runtime validation: if Gemini hallucinates a field, we throw
* before the data hits the IndexedDB write path
* - Provider-portable structured outputs: the AI SDK translates the
* same schema into OpenAI strict json_schema, Anthropic tool-use,
* or Gemini response_schema depending on which backend mana-llm
* routes to
* - Single source of truth: backend and frontend share one definition,
* no manual type-drift between Hono routes and Svelte stores
*/
import { z } from 'zod';
// ─── NutriPhi: meal photo / text analysis ────────────────────────
const AnalyzedFoodSchema = z.object({
name: z.string().describe('The food item name in German'),
quantity: z
.string()
.optional()
.describe('Approximate portion description, e.g. "1 Tasse" or "200g"'),
calories: z.number().optional().describe('Estimated calories for this item alone'),
});
const NutritionDataSchema = z.object({
calories: z.number().describe('Total kcal for the entire meal'),
protein: z.number().describe('Protein in grams'),
carbohydrates: z.number().describe('Carbohydrates in grams'),
fat: z.number().describe('Fat in grams'),
fiber: z.number().describe('Dietary fiber in grams'),
sugar: z.number().describe('Sugar in grams'),
});
export const MealAnalysisSchema = z.object({
foods: z.array(AnalyzedFoodSchema).describe('Each individual food item the model could identify'),
totalNutrition: NutritionDataSchema.describe('Sum across all foods'),
description: z.string().describe('A short, human-readable summary of the meal in German'),
confidence: z.number().min(0).max(1).describe('How confident the model is in its analysis, 0..1'),
warnings: z
.array(z.string())
.default([])
.describe('Optional health-related warnings (e.g. "high in sugar")'),
suggestions: z.array(z.string()).default([]).describe('Optional suggestions to improve the meal'),
});
export type MealAnalysis = z.infer<typeof MealAnalysisSchema>;
export type AnalyzedFood = z.infer<typeof AnalyzedFoodSchema>;
export type NutritionData = z.infer<typeof NutritionDataSchema>;
// ─── Planta: plant photo identification ──────────────────────────
export const PlantIdentificationSchema = z.object({
scientificName: z.string().optional().describe('Latin binomial, e.g. "Monstera deliciosa"'),
commonNames: z.array(z.string()).default([]).describe('Common names in German if known'),
confidence: z.number().min(0).max(1).optional().describe('Identification confidence, 0..1'),
healthAssessment: z.string().optional().describe('Brief health observation visible in the photo'),
wateringAdvice: z.string().optional().describe('How often to water this species'),
lightAdvice: z.string().optional().describe('Preferred light conditions'),
generalTips: z.array(z.string()).default([]).describe('Other care tips, in German'),
});
export type PlantIdentification = z.infer<typeof PlantIdentificationSchema>;

View file

@ -22,6 +22,9 @@ export * from './contact';
// Landing page configuration types
export * from './landing-config';
// AI structured-output Zod schemas (shared between mana-api + web frontend)
export * from './ai-schemas';
// API types
export interface User {
id: string;

696
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff