/** * Plants module — Photo upload + AI plant identification. * * CRUD for plants, photos, watering is handled by mana-sync. This * module owns the server-only operations: photo upload to mana-media * and structured plant identification via the Vercel AI SDK * (`generateObject`) using the shared PlantIdentificationSchema in * @mana/shared-types. See food/routes.ts for the rationale behind * the AI SDK + Zod approach. */ import { Hono } from 'hono'; import { generateObject } from 'ai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; 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'; // See food/routes.ts for the rationale on the default model and // the /v1 base URL. const VISION_MODEL = process.env.VISION_MODEL || 'ollama/gemma3:4b'; const llm = createOpenAICompatible({ name: 'mana-llm', baseURL: `${LLM_URL}/v1`, // See food/routes.ts for the rationale on this flag. supportsStructuredOutputs: true, }); 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 food/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) ───────────────── routes.post('/photos/upload', async (c) => { const userId = c.get('userId'); const formData = await c.req.formData(); const file = formData.get('file') as File | null; const plantId = formData.get('plantId') as string | null; if (!file) return c.json({ error: 'No file provided' }, 400); if (file.size > 10 * 1024 * 1024) return c.json({ error: 'File too large (max 10MB)' }, 400); try { const { uploadImageToMedia } = await import('../../lib/media'); const buffer = await file.arrayBuffer(); const result = await uploadImageToMedia(buffer, file.name, { app: 'plants', userId }); return c.json( { storagePath: result.id, publicUrl: result.urls.original, mediaId: result.id, plantId, }, 201 ); } catch (err) { logger.error('plants.upload_failed', { error: err instanceof Error ? err.message : String(err), }); return c.json({ error: 'Upload failed' }, 500); } }); // ─── AI Analysis (Gemini Vision on uploaded URL) ───────────── routes.post('/analysis/identify', async (c) => { const { photoUrl } = await c.req.json(); if (!photoUrl) return c.json({ error: 'photoUrl required' }, 400); try { const { object } = await generateObject({ model: llm(VISION_MODEL), schema: PlantIdentificationSchema, messages: [ { role: 'system', content: IDENTIFICATION_PROMPT, providerOptions: SYSTEM_CACHE_HINT, }, { role: 'user', content: [ { type: 'text', text: 'Analysiere diese Pflanze.' }, { type: 'image', image: new URL(photoUrl) }, ], }, ], }); return c.json(envelope(object)); } catch (err) { logger.error('plants.analysis_failed', { error: err instanceof Error ? err.message : String(err), }); return c.json({ error: 'Analysis failed' }, 500); } }); export { routes as plantsRoutes };