mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 01:21:24 +02:00
refactor: rename planta → plants, clean up codebase
- Rename planta module to plants everywhere (routes, modules, API, branding, i18n, docker, docs, shared packages) - Fix package name collisions: @mana/credits-service, @mana/subscriptions-service (unblocks turbo) - Extract layout composables: use-ai-tier-items, use-sync-status-items, RouteTierGate (layout 1345→1015 lines) - Create shared DB pool for apps/api (lib/db.ts), migrate 5 modules - Add automations module queries.ts with useAllAutomations/useEnabledAutomations - Remove debug console.log statements from production code - Rename storage display name: Ablage → Speicher Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c6c19dbc77
commit
a91a6076cc
110 changed files with 831 additions and 707 deletions
117
apps/api/src/modules/plants/routes.ts
Normal file
117
apps/api/src/modules/plants/routes.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* 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 nutriphi/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 nutriphi/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 nutriphi/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 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<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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue