feat: add unified @manacore/shared-llm package and migrate all backends

Create a shared LLM client package that provides a unified interface
to the mana-llm service, replacing 9 individual fetch-based integrations
with consistent error handling, retry logic, and JSON extraction.

Package (@manacore/shared-llm):
- LlmModule with forRoot/forRootAsync (NestJS dynamic module)
- LlmClientService: chat, json, vision, visionJson, embed, stream
- LlmClient standalone class for non-NestJS consumers
- extractJson utility (consolidates 3 markdown-stripping implementations)
- retryFetch with exponential backoff (429, 5xx, network errors)
- 44 unit tests (json-extractor, retry, llm-client)

Migrated backends:
- mana-core-auth: raw fetch → llm.json()
- planta: raw fetch + vision → llm.visionJson()
- nutriphi: raw fetch + regex → llm.visionJson() + llm.json()
- chat: custom OllamaService (175 LOC) → llm.chatMessages()
- context: raw fetch → llm.chat() (keeps token tracking)
- traces: 2x raw fetch → llm.chat()
- manadeck: @google/genai SDK → llm.json() + llm.visionJson()
- bot-services: raw Ollama API → LlmClient standalone
- matrix-ollama-bot: raw fetch → llm.chatMessages() + llm.vision()

New credit operations:
- AI_PLANT_ANALYSIS (2 credits, planta)
- AI_GUIDE_GENERATION (5 credits, traces)
- AI_CONTEXT_GENERATION (2 credits, context)
- AI_BOT_CHAT (0.1 credits, matrix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 22:06:30 +01:00
parent e7bf58c5b6
commit e2f144962c
48 changed files with 2476 additions and 1297 deletions

View file

@ -23,17 +23,18 @@
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@google/generative-ai": "^0.21.0",
"@manacore/shared-error-tracking": "workspace:*",
"@nutriphi/shared": "workspace:*",
"@manacore/shared-llm": "workspace:^",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-metrics": "workspace:*",
"@manacore/shared-nestjs-setup": "workspace:*",
"@google/generative-ai": "^0.21.0",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nutriphi/shared": "workspace:*",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",

View file

@ -1,5 +1,5 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Injectable, Logger } from '@nestjs/common';
import { LlmClientService } from '@manacore/shared-llm';
import type { AIAnalysisResult } from '../types/nutrition.types';
const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere das Bild dieser Mahlzeit und liefere eine detaillierte Nährwertanalyse.
@ -75,95 +75,28 @@ Antworte NUR mit einem validen JSON-Objekt im folgenden Format:
}`;
@Injectable()
export class GeminiService implements OnModuleInit {
export class GeminiService {
private readonly logger = new Logger(GeminiService.name);
private manaLlmUrl: string | null = null;
private readonly visionModel = 'ollama/llava:7b';
private readonly textModel = 'ollama/gemma3:4b';
constructor(private configService: ConfigService) {}
onModuleInit() {
this.manaLlmUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
this.logger.log(`NutriPhi AI using mana-llm at ${this.manaLlmUrl}`);
}
constructor(private readonly llm: LlmClientService) {}
async analyzeImage(imageBase64: string, mimeType = 'image/jpeg'): Promise<AIAnalysisResult> {
if (!this.manaLlmUrl) {
throw new Error('mana-llm not configured');
}
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.visionModel,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
},
],
},
],
temperature: 0.3,
}),
signal: AbortSignal.timeout(120000),
});
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`mana-llm vision error: ${response.status} - ${errorText}`);
throw new Error('Failed to analyze image');
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content || '';
// Extract JSON from response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('Failed to parse AI response');
}
return JSON.parse(jsonMatch[0]) as AIAnalysisResult;
const { data } = await this.llm.visionJson<AIAnalysisResult>(
ANALYSIS_PROMPT,
imageBase64,
mimeType,
{ temperature: 0.3 }
);
return data;
}
async analyzeText(description: string): Promise<AIAnalysisResult> {
if (!this.manaLlmUrl) {
throw new Error('mana-llm not configured');
}
const prompt = TEXT_ANALYSIS_PROMPT.replace('{INPUT}', description);
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.textModel,
messages: [{ role: 'user', content: prompt }],
temperature: 0.3,
}),
signal: AbortSignal.timeout(60000),
const { data } = await this.llm.json<AIAnalysisResult>(prompt, {
temperature: 0.3,
timeout: 60_000,
});
if (!response.ok) {
throw new Error(`mana-llm error: ${response.status}`);
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content || '';
// Extract JSON from response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('Failed to parse AI response');
}
return JSON.parse(jsonMatch[0]) as AIAnalysisResult;
return data;
}
}

View file

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LlmModule } from '@manacore/shared-llm';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
@ -16,6 +17,14 @@ import { RecommendationsModule } from './recommendations/recommendations.module'
isGlobal: true,
envFilePath: ['.env', '.env.development'],
}),
LlmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
manaLlmUrl: config.get('MANA_LLM_URL'),
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
DatabaseModule,
HealthModule.forRoot({ serviceName: 'nutriphi-backend' }),
MetricsModule.register({