managarten/apps/api/src/modules/plants/routes.ts
Till JS fea3adf5fe feat(llm-aliases): M5 — migrate consumers to MANA_LLM aliases
Final milestone of docs/plans/llm-fallback-aliases.md. Every backend
caller now requests models via the `mana/<class>` alias system instead
of hardcoded `ollama/...` strings. mana-llm resolves aliases through
`services/mana-llm/aliases.yaml` with health-aware fallback (M3) and
emits resolved-model + fallback metrics (M4).

SSOT moved to `packages/shared-ai/src/llm-aliases.ts` so apps/api,
apps/mana/apps/web, and services/mana-ai all import the same
`MANA_LLM` constant via the existing `@mana/shared-ai` workspace
dependency. Three additional sites (memoro-server, mana-events,
mana-research) inline the alias string with a SSOT comment because
they don't pull @mana/shared-ai today.

Migrated 14 sites across 10 files:
- apps/api: writing(LONG_FORM), comic(STRUCTURED), context(FAST_TEXT),
  food(VISION), plants(VISION), research orchestrator (3 tiers
  collapsed to STRUCTURED+FAST_TEXT/LONG_FORM)
- apps/mana/apps/web: voice/parse-task + parse-habit (STRUCTURED)
- services/mana-ai: planner llm-client + tick.ts (REASONING)
- services/mana-events: website-extractor (STRUCTURED, inlined)
- services/mana-research: mana-llm client (FAST_TEXT, inlined)
- apps/memoro/apps/server: ai.ts (FAST_TEXT, inlined)

Legacy env-vars removed: WRITING_MODEL, COMIC_STORYBOARD_MODEL,
VISION_MODEL, MANA_LLM_DEFAULT_MODEL. The chain in aliases.yaml is
now the single tuning surface; SIGHUP reloads it without redeploys.

New `scripts/validate-llm-strings.mjs` regex-scans 2538 files for
hardcoded `<provider>/<model>` strings and fails the build if any
land outside the SSOT or the explicitly-allowed paths (image-gen
modules, model-inspector code, this validator itself, the registry).
Wired into `validate:all` next to the i18n + theme validators.

Verified: `pnpm validate:llm-strings` clean, `pnpm --filter @mana/api
type-check` clean, `pnpm --filter @mana/ai-service type-check`
clean. Web type-check has 2 pre-existing errors in
SettingsSidebar.svelte (i18n MessageFormatter type drift, last
touched in 988c17a67 — unrelated to this work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:26:03 +02:00

116 lines
3.7 KiB
TypeScript

/**
* 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';
import { MANA_LLM } from '@mana/shared-ai';
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
const VISION_MODEL = MANA_LLM.VISION;
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<PlantIdentification> {
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 };