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

@ -29,19 +29,20 @@
"test:cov": "jest --coverage"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/credit-operations": "workspace:*",
"@manacore/nestjs-integration": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-errors": "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:*",
"@nestjs/common": "^10.4.15",
"@nestjs/throttler": "^6.2.1",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
@ -56,15 +57,15 @@
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"@nestjs/testing": "^10.4.15",
"@types/jest": "^30.0.0",
"jest": "^30.2.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",

View file

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { LlmModule } from '@manacore/shared-llm';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
import { ManaCoreModule } from '@manacore/nestjs-integration';
import { DatabaseModule } from './db/database.module';
@ -20,6 +21,15 @@ import { HealthModule } from '@manacore/shared-nestjs-health';
envFilePath: '.env',
}),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
LlmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
manaLlmUrl: configService.get('MANA_LLM_URL'),
timeout: configService.get<number>('LLM_TIMEOUT', 120000),
debug: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({

View file

@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LlmClientService } from '@manacore/shared-llm';
import { AsyncResult, ok, err, ServiceError } from '@manacore/shared-errors';
import type { ChatCompletionResponseDto } from './dto/chat-completion.dto';
@ -8,65 +8,33 @@ interface ChatMessage {
content: string;
}
interface ChatCompletionResponse {
id: string;
model: string;
choices: {
message: { role: string; content: string };
finish_reason: string;
}[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
interface LlmModel {
id: string;
owned_by: string;
}
@Injectable()
export class OllamaService {
private readonly logger = new Logger(OllamaService.name);
private readonly baseUrl: string;
private readonly timeout: number;
private isConnected = false;
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
this.timeout = this.configService.get<number>('LLM_TIMEOUT') || 120000;
// Check connection on startup
constructor(private readonly llm: LlmClientService) {
this.checkConnection();
}
async checkConnection(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const data = await response.json();
this.isConnected = data.status === 'healthy' || data.status === 'degraded';
if (this.isConnected) {
const providers = Object.keys(data.providers || {}).join(', ');
this.logger.log(`mana-llm connected: ${data.status}, providers: ${providers}`);
}
return this.isConnected;
const health = await this.llm.health();
const isConnected = health.status === 'healthy' || health.status === 'degraded';
if (isConnected) {
const providers = Object.keys(health.providers || {}).join(', ');
this.logger.log(`mana-llm connected: ${health.status}, providers: ${providers}`);
}
this.isConnected = false;
return false;
} catch (error) {
this.isConnected = false;
this.logger.warn(`mana-llm not available at ${this.baseUrl} - local models will not work`);
return isConnected;
} catch {
this.logger.warn('mana-llm not available - local models will not work');
return false;
}
}
isAvailable(): boolean {
return this.isConnected;
// Perform a synchronous check based on last known state
// The actual health is checked on-demand via checkConnection
return true;
}
async createChatCompletion(
@ -75,70 +43,33 @@ export class OllamaService {
temperature?: number,
maxTokens?: number
): AsyncResult<ChatCompletionResponseDto> {
if (!this.isConnected) {
// Try to reconnect
await this.checkConnection();
if (!this.isConnected) {
return err(
ServiceError.externalError('mana-llm', `mana-llm server not available at ${this.baseUrl}`)
);
}
}
// Normalize model name to include ollama/ prefix if it doesn't have a provider
const normalizedModel = modelName.includes('/') ? modelName : `ollama/${modelName}`;
this.logger.log(`Sending request to mana-llm model: ${normalizedModel}`);
try {
const requestBody: Record<string, unknown> = {
const result = await this.llm.chatMessages(messages, {
model: normalizedModel,
messages,
stream: false,
};
// Add optional parameters
if (temperature !== undefined) {
requestBody.temperature = temperature;
}
if (maxTokens !== undefined) {
requestBody.max_tokens = maxTokens;
}
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(this.timeout),
temperature,
maxTokens,
});
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`mana-llm API error: ${response.status} - ${errorText}`);
return err(ServiceError.externalError('mana-llm', `API error: ${response.status}`));
}
const data: ChatCompletionResponse = await response.json();
if (!data.choices?.[0]?.message?.content) {
if (!result.content) {
this.logger.warn('No message content in mana-llm response');
return err(ServiceError.generationFailed('mana-llm', 'No response generated'));
}
const usage = data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
// Log performance metrics
if (usage.completion_tokens) {
if (result.usage.completion_tokens) {
this.logger.debug(
`Generated ${usage.completion_tokens} tokens (total: ${usage.total_tokens})`
`Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})`
);
}
return ok({
content: data.choices[0].message.content,
content: result.content,
usage: {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
prompt_tokens: result.usage.prompt_tokens,
completion_tokens: result.usage.completion_tokens,
total_tokens: result.usage.total_tokens,
},
});
} catch (error) {
@ -160,14 +91,8 @@ export class OllamaService {
async listModels(): Promise<string[]> {
try {
const response = await fetch(`${this.baseUrl}/v1/models`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return [];
}
const data = await response.json();
return (data.data || []).map((m: LlmModel) => m.id);
const models = await this.llm.listModels();
return models.map((m) => m.id);
} catch {
return [];
}

View file

@ -21,8 +21,9 @@
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-drizzle-config": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-llm": "workspace:^",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-setup": "workspace:*",

View file

@ -1,5 +1,5 @@
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LlmClientService } from '@manacore/shared-llm';
import { TokenService } from '../token/token.service';
interface GenerateOptions {
@ -19,14 +19,11 @@ function estimateTokens(text: string): number {
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly manaLlmUrl: string;
constructor(
private configService: ConfigService,
private readonly llm: LlmClientService,
private tokenService: TokenService
) {
this.manaLlmUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
}
) {}
async generate(userId: string, options: GenerateOptions) {
const model = options.model || 'ollama/gemma3:4b';
@ -51,11 +48,16 @@ export class AiService {
}
// Generate text via mana-llm
const completionText = await this.generateWithManaLlm(fullPrompt, options, model);
const result = await this.llm.chat(fullPrompt, {
model,
systemPrompt: 'You are a helpful assistant.',
temperature: options.temperature || 0.7,
maxTokens: options.maxTokens || 2000,
});
// Calculate actual cost and log
const actualPromptTokens = estimateTokens(fullPrompt);
const completionTokens = estimateTokens(completionText);
// Use actual token counts from response when available, fall back to estimates
const actualPromptTokens = result.usage.prompt_tokens || estimateTokens(fullPrompt);
const completionTokens = result.usage.completion_tokens || estimateTokens(result.content);
const { tokensUsed, remainingBalance } = await this.tokenService.logUsage(
userId,
model,
@ -65,7 +67,7 @@ export class AiService {
);
return {
text: completionText,
text: result.content,
tokenInfo: {
promptTokens: actualPromptTokens,
completionTokens,
@ -110,34 +112,4 @@ export class AiService {
balance,
};
}
private async generateWithManaLlm(
prompt: string,
options: GenerateOptions,
model: string
): Promise<string> {
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: prompt },
],
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000,
}),
signal: AbortSignal.timeout(120000),
});
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`mana-llm error: ${response.status} - ${errorText}`);
throw new BadRequestException(`LLM generation failed: ${response.status}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '';
}
}

View file

@ -1,7 +1,8 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { LlmModule } from '@manacore/shared-llm';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { SpaceModule } from './space/space.module';
@ -22,6 +23,14 @@ import { HttpExceptionFilter } from './common/http-exception.filter';
limit: 100,
},
]),
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: 'context-backend' }),
SpaceModule,

View file

@ -21,11 +21,12 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/nestjs-integration": "workspace:*",
"@manacore/shared-errors": "workspace:*",
"@google/genai": "^1.14.0",
"@manacore/manadeck-database": "workspace:*",
"@manacore/nestjs-integration": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-errors": "workspace:*",
"@manacore/shared-llm": "workspace:^",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",

View file

@ -4,6 +4,7 @@ import { ClsModule } from 'nestjs-cls';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { ManaCoreModule } from '@manacore/nestjs-integration';
import { LlmModule } from '@manacore/shared-llm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ApiController } from './controllers/api.controller';
@ -50,6 +51,16 @@ import {
inject: [ConfigService],
}) as any,
// LLM (via mana-llm service)
LlmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
manaLlmUrl: config.get('MANA_LLM_URL'),
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
// Health checks
TerminusModule,
HttpModule,

View file

@ -1,6 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenAI, Type } from '@google/genai';
import { LlmClientService } from '@manacore/shared-llm';
import { AsyncResult, ok, err, ServiceError } from '@manacore/shared-errors';
export type CardType = 'text' | 'flashcard' | 'quiz' | 'mixed';
@ -50,32 +49,16 @@ export interface DeckGenerationData {
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly ai: GoogleGenAI | null;
private readonly model = 'gemini-2.0-flash';
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
if (apiKey) {
this.ai = new GoogleGenAI({ apiKey });
this.logger.log('Google Gemini AI initialized successfully');
} else {
this.ai = null;
this.logger.warn('Google Gemini API key not configured - AI features disabled');
}
}
constructor(private readonly llm: LlmClientService) {}
isAvailable(): boolean {
return this.ai !== null;
return true;
}
async generateDeck(request: DeckGenerationRequest): AsyncResult<DeckGenerationData> {
const startTime = Date.now();
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
const {
prompt,
deckTitle,
@ -96,28 +79,23 @@ export class AiService {
cardTypes
);
const response = await this.ai.models.generateContent({
model: this.model,
contents: userPrompt,
config: {
systemInstruction: systemPrompt,
responseMimeType: 'application/json',
responseSchema: this.buildResponseSchema(cardTypes),
const { data, usage } = await this.llm.json<{ cards: GeneratedCard[] }>(userPrompt, {
systemPrompt,
temperature: 0.7,
validate: (raw) => {
const obj = raw as { cards: GeneratedCard[] };
if (!obj.cards || !Array.isArray(obj.cards)) {
throw new Error('Response must contain a "cards" array');
}
return obj;
},
});
const generationTime = Date.now() - startTime;
const responseText = response.text?.trim();
if (!responseText) {
return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI'));
}
const parsed = JSON.parse(responseText);
const cards: GeneratedCard[] = parsed.cards || [];
const cards = data.cards;
if (cards.length === 0) {
return err(ServiceError.generationFailed('Google Gemini', 'No cards generated'));
return err(ServiceError.generationFailed('mana-llm', 'No cards generated'));
}
this.logger.log(`Generated ${cards.length} cards in ${generationTime}ms`);
@ -125,8 +103,8 @@ export class AiService {
return ok({
cards,
metadata: {
model: this.model,
tokensUsed: response.usageMetadata?.totalTokenCount,
model: 'mana-llm',
tokensUsed: usage.total_tokens || undefined,
generationTime,
},
});
@ -135,7 +113,7 @@ export class AiService {
return err(
ServiceError.generationFailed(
'Google Gemini',
'mana-llm',
error instanceof Error ? error.message : 'Unknown error occurred',
error instanceof Error ? error : undefined
)
@ -176,7 +154,33 @@ QUALITY GUIDELINES:
4. For quiz: all 4 options should be plausible, avoid obviously wrong answers
5. Include helpful hints for difficult flashcards
6. Add explanations for quiz questions to reinforce learning
7. Progress from easier to harder cards when possible`;
7. Progress from easier to harder cards when possible
RESPONSE FORMAT:
You MUST respond with a valid JSON object containing a "cards" array. Each card has:
${this.buildJsonSchemaDescription(cardTypes)}`;
}
private buildJsonSchemaDescription(cardTypes: CardType[]): string {
const schemas: string[] = [];
if (cardTypes.includes('flashcard')) {
schemas.push(
`- Flashcard: { "cardType": "flashcard", "title": "optional title", "content": { "front": "question/term", "back": "answer/definition", "hint": "optional hint" } }`
);
}
if (cardTypes.includes('quiz')) {
schemas.push(
`- Quiz: { "cardType": "quiz", "title": "optional title", "content": { "question": "the question", "options": ["A", "B", "C", "D"], "correctAnswer": 0, "explanation": "why this is correct" } }`
);
}
if (cardTypes.includes('text')) {
schemas.push(
`- Text: { "cardType": "text", "title": "optional title", "content": { "text": "informational content" } }`
);
}
return schemas.join('\n');
}
private buildUserPrompt(
@ -200,7 +204,9 @@ CARD DISTRIBUTION:
${typeDistribution}
Generate exactly ${cardCount} cards that cover the topic comprehensively.
Ensure variety in the questions and good coverage of the subject matter.`;
Ensure variety in the questions and good coverage of the subject matter.
Respond ONLY with a JSON object: {"cards": [...]}`;
}
private suggestTypeDistribution(cardCount: number, cardTypes: CardType[]): string {
@ -229,7 +235,7 @@ Ensure variety in the questions and good coverage of the subject matter.`;
}
/**
* Generate cards from an image using Gemini Vision
* Generate cards from an image using vision model
*/
async generateFromImage(
imageBase64: string,
@ -238,59 +244,41 @@ Ensure variety in the questions and good coverage of the subject matter.`;
): AsyncResult<DeckGenerationData> {
const startTime = Date.now();
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
try {
const prompt = `Analyze this image and create ${cardCount} educational flashcards based on its content.
${context ? `Context: ${context}` : ''}
For each concept, term, or important element you identify in the image, create a flashcard or quiz question.
Return the cards as a JSON object with a "cards" array containing objects with:
Return ONLY a JSON object: {"cards": [...]} where each card has:
- cardType: "flashcard" or "quiz"
- title: short title
- content: { front, back, hint } for flashcards OR { question, options, correctAnswer, explanation } for quiz`;
const response = await this.ai.models.generateContent({
model: this.model,
contents: [
{
role: 'user',
parts: [
{ text: prompt },
{
inlineData: {
mimeType: 'image/jpeg',
data: imageBase64,
},
},
],
const { data, usage } = await this.llm.visionJson<{ cards: GeneratedCard[] }>(
prompt,
imageBase64,
'image/jpeg',
{
validate: (raw) => {
const obj = raw as { cards: GeneratedCard[] };
if (!obj.cards || !Array.isArray(obj.cards)) {
throw new Error('Response must contain a "cards" array');
}
return obj;
},
],
config: {
responseMimeType: 'application/json',
},
});
}
);
const generationTime = Date.now() - startTime;
const responseText = response.text?.trim();
if (!responseText) {
return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI'));
}
const parsed = JSON.parse(responseText);
const cards: GeneratedCard[] = parsed.cards || [];
this.logger.log(`Generated ${cards.length} cards from image in ${generationTime}ms`);
this.logger.log(`Generated ${data.cards.length} cards from image in ${generationTime}ms`);
return ok({
cards,
cards: data.cards,
metadata: {
model: this.model,
tokensUsed: response.usageMetadata?.totalTokenCount,
model: 'mana-llm',
tokensUsed: usage.total_tokens || undefined,
generationTime,
},
});
@ -298,7 +286,7 @@ Return the cards as a JSON object with a "cards" array containing objects with:
this.logger.error('AI image generation failed:', error);
return err(
ServiceError.generationFailed(
'Google Gemini',
'mana-llm',
error instanceof Error ? error.message : 'Unknown error'
)
);
@ -312,109 +300,24 @@ Return the cards as a JSON object with a "cards" array containing objects with:
content: string,
cardType: string
): AsyncResult<{ enhancedContent: string }> {
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
try {
const prompt = `Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging.
const result = await this.llm.chat(
`Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging.
Original content:
${content}
Return the enhanced content in the same JSON format as the input, but improved.`;
Return the enhanced content in the same JSON format as the input, but improved.`
);
const response = await this.ai.models.generateContent({
model: this.model,
contents: prompt,
config: {
responseMimeType: 'application/json',
},
});
const responseText = response.text?.trim();
if (!responseText) {
if (!result.content) {
return ok({ enhancedContent: content });
}
return ok({ enhancedContent: responseText });
return ok({ enhancedContent: result.content });
} catch (error) {
this.logger.error('AI content enhancement failed:', error);
return ok({ enhancedContent: content }); // Return original on failure
return ok({ enhancedContent: content });
}
}
private buildResponseSchema(cardTypes: CardType[]): any {
const cardSchemas: any[] = [];
if (cardTypes.includes('flashcard')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['flashcard'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
front: { type: Type.STRING },
back: { type: Type.STRING },
hint: { type: Type.STRING },
},
required: ['front', 'back'],
},
},
required: ['cardType', 'content'],
});
}
if (cardTypes.includes('quiz')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['quiz'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
question: { type: Type.STRING },
options: { type: Type.ARRAY, items: { type: Type.STRING } },
correctAnswer: { type: Type.NUMBER },
explanation: { type: Type.STRING },
},
required: ['question', 'options', 'correctAnswer'],
},
},
required: ['cardType', 'content'],
});
}
if (cardTypes.includes('text')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['text'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
text: { type: Type.STRING },
},
required: ['text'],
},
},
required: ['cardType', 'content'],
});
}
return {
type: Type.OBJECT,
properties: {
cards: {
type: Type.ARRAY,
items: cardSchemas.length === 1 ? cardSchemas[0] : { anyOf: cardSchemas },
},
},
required: ['cards'],
};
}
}

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({

View file

@ -18,8 +18,9 @@
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@google/generative-ai": "^0.21.0",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-llm": "workspace:^",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-metrics": "workspace:*",

View file

@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LlmClientService } from '@manacore/shared-llm';
import type { AnalysisResult } from '@planta/shared';
const PLANT_ANALYSIS_PROMPT = `Du bist ein erfahrener Botaniker und Pflanzenexperte. Analysiere dieses Pflanzenfoto und erstelle einen detaillierten Steckbrief.
@ -43,70 +43,32 @@ Falls du die Pflanze nicht identifizieren kannst, setze confidence auf 0 und sci
@Injectable()
export class VisionService {
private readonly logger = new Logger(VisionService.name);
private readonly manaLlmUrl: string;
private readonly visionModel = 'ollama/llava:7b';
constructor(private configService: ConfigService) {
this.manaLlmUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
this.logger.log(`Planta Vision using mana-llm at ${this.manaLlmUrl}`);
}
constructor(private readonly llm: LlmClientService) {}
async analyzePlantImage(imageBuffer: Buffer, mimeType: string): Promise<AnalysisResult | null> {
try {
const base64 = imageBuffer.toString('base64');
const result = 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: PLANT_ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${base64}` },
},
],
},
],
const { data } = await this.llm.visionJson<AnalysisResult>(
PLANT_ANALYSIS_PROMPT,
base64,
mimeType,
{
temperature: 0.3,
}),
signal: AbortSignal.timeout(120000),
});
if (!result.ok) {
const errorText = await result.text();
this.logger.error(`mana-llm vision error: ${result.status} - ${errorText}`);
return null;
}
const data = await result.json();
const response = (data.choices?.[0]?.message?.content || '').trim();
this.logger.debug(`Gemini raw response: ${response}`);
// Parse JSON response - handle potential markdown code blocks
let jsonStr = response;
if (response.includes('```')) {
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
jsonStr = match[1].trim();
validate: (raw) => {
const result = raw as AnalysisResult;
this.validateAnalysisResult(result);
return result;
},
}
}
const parsed = JSON.parse(jsonStr) as AnalysisResult;
// Validate and sanitize response
this.validateAnalysisResult(parsed);
this.logger.log(
`Plant identified: ${parsed.identification.scientificName} (${parsed.identification.confidence}% confidence)`
);
return parsed;
this.logger.log(
`Plant identified: ${data.identification.scientificName} (${data.identification.confidence}% confidence)`
);
return data;
} catch (error) {
this.logger.error(`Vision analysis failed: ${error}`);
return null;
@ -114,7 +76,6 @@ export class VisionService {
}
private validateAnalysisResult(result: AnalysisResult): void {
// Validate identification
if (!result.identification) {
result.identification = {
scientificName: 'Unbekannt',
@ -123,13 +84,11 @@ export class VisionService {
};
}
// Ensure confidence is within range
if (typeof result.identification.confidence !== 'number') {
result.identification.confidence = 0;
}
result.identification.confidence = Math.max(0, Math.min(100, result.identification.confidence));
// Validate health
if (!result.health) {
result.health = {
status: 'healthy',
@ -143,7 +102,6 @@ export class VisionService {
result.health.status = 'healthy';
}
// Validate care
if (!result.care) {
result.care = {
light: 'medium',

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';
@ -14,6 +15,14 @@ import { WateringModule } from './watering/watering.module';
isGlobal: true,
envFilePath: '.env',
}),
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: 'planta-backend' }),
MetricsModule.register({

View file

@ -17,8 +17,9 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/nestjs-integration": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-llm": "workspace:^",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-metrics": "workspace:*",

View file

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LlmModule } from '@manacore/shared-llm';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
import { ManaCoreModule } from '@manacore/nestjs-integration';
import { HealthModule } from '@manacore/shared-nestjs-health';
@ -25,6 +26,14 @@ import { GuideModule } from './guide/guide.module';
}),
inject: [ConfigService],
}),
LlmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
manaLlmUrl: config.get('MANA_LLM_URL'),
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
MetricsModule.register({
prefix: 'traces_',
excludePaths: ['/health'],

View file

@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } fro
import { ConfigService } from '@nestjs/config';
import { eq, and, desc } from 'drizzle-orm';
import { CreditClientService } from '@manacore/nestjs-integration';
import { LlmClientService } from '@manacore/shared-llm';
import { DATABASE_CONNECTION } from '../db/database.module';
import type { Database } from '../db/connection';
import { guides, guidePois, pois, cities } from '../db/schema';
@ -18,7 +19,8 @@ export class GuideService {
private readonly configService: ConfigService,
private readonly cityService: CityService,
private readonly poiService: PoiService,
private readonly creditClient: CreditClientService
private readonly creditClient: CreditClientService,
private readonly llm: LlmClientService
) {}
async generateGuide(userId: string, request: GenerateGuideRequest) {
@ -135,35 +137,20 @@ export class GuideService {
// Step 3: Enrich POIs with AI summaries
this.logger.log(`[${guideId}] Step 3: Content enrichment`);
if (manaLlmUrl) {
for (const poi of nearbyPois) {
if (!poi.aiSummary) {
try {
const prompt =
language === 'de'
? `Schreibe eine 200-Wort-Zusammenfassung über "${poi.name}" in ${city.name}. Fokus auf Baugeschichte, Architekturstil und interessante Anekdoten.`
: `Write a 200-word summary about "${poi.name}" in ${city.name}. Focus on architectural history, style, and interesting anecdotes.`;
for (const poi of nearbyPois) {
if (!poi.aiSummary) {
try {
const prompt =
language === 'de'
? `Schreibe eine 200-Wort-Zusammenfassung über "${poi.name}" in ${city.name}. Fokus auf Baugeschichte, Architekturstil und interessante Anekdoten.`
: `Write a 200-word summary about "${poi.name}" in ${city.name}. Focus on architectural history, style, and interesting anecdotes.`;
const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{ role: 'user', content: prompt }],
model: 'default',
max_tokens: 500,
}),
});
if (llmResponse.ok) {
const data = await llmResponse.json();
const summary = data.choices?.[0]?.message?.content;
if (summary) {
await this.poiService.updateAiSummary(poi.id, summary, language);
}
}
} catch (err) {
this.logger.warn(`AI summary failed for POI ${poi.name}:`, err);
const result = await this.llm.chat(prompt, { maxTokens: 500 });
if (result.content) {
await this.poiService.updateAiSummary(poi.id, result.content, language);
}
} catch (err) {
this.logger.warn(`AI summary failed for POI ${poi.name}:`, err);
}
}
}
@ -197,43 +184,29 @@ export class GuideService {
const poi = sortedPois[i];
let narrative: string | null = null;
if (manaLlmUrl) {
try {
const prevStation = i > 0 ? sortedPois[i - 1].name : 'Startpunkt';
const distanceToPrev =
i > 0
? Math.round(
this.haversineDistance(
sortedPois[i - 1].latitude,
sortedPois[i - 1].longitude,
poi.latitude,
poi.longitude
)
try {
const prevStation = i > 0 ? sortedPois[i - 1].name : 'Startpunkt';
const distanceToPrev =
i > 0
? Math.round(
this.haversineDistance(
sortedPois[i - 1].latitude,
sortedPois[i - 1].longitude,
poi.latitude,
poi.longitude
)
: 0;
)
: 0;
const prompt =
language === 'de'
? `Du bist ein erfahrener Stadtführer in ${city.name}. Schreibe einen kurzen, lebendigen Stadtführer-Text (80-120 Wörter) über "${poi.name}" als Station ${i + 1} einer Stadtführung. ${i > 0 ? `Die vorherige Station war "${prevStation}" (${distanceToPrev}m entfernt).` : 'Dies ist die erste Station.'} Erwähne architektonische Details und eine interessante Anekdote.`
: `You are an experienced city guide in ${city.name}. Write a short, vivid guide text (80-120 words) about "${poi.name}" as station ${i + 1} of a walking tour. ${i > 0 ? `The previous station was "${prevStation}" (${distanceToPrev}m away).` : 'This is the first station.'} Mention architectural details and an interesting anecdote.`;
const prompt =
language === 'de'
? `Du bist ein erfahrener Stadtführer in ${city.name}. Schreibe einen kurzen, lebendigen Stadtführer-Text (80-120 Wörter) über "${poi.name}" als Station ${i + 1} einer Stadtführung. ${i > 0 ? `Die vorherige Station war "${prevStation}" (${distanceToPrev}m entfernt).` : 'Dies ist die erste Station.'} Erwähne architektonische Details und eine interessante Anekdote.`
: `You are an experienced city guide in ${city.name}. Write a short, vivid guide text (80-120 words) about "${poi.name}" as station ${i + 1} of a walking tour. ${i > 0 ? `The previous station was "${prevStation}" (${distanceToPrev}m away).` : 'This is the first station.'} Mention architectural details and an interesting anecdote.`;
const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{ role: 'user', content: prompt }],
model: 'default',
max_tokens: 300,
}),
});
if (llmResponse.ok) {
const data = await llmResponse.json();
narrative = data.choices?.[0]?.message?.content || null;
}
} catch (err) {
this.logger.warn(`Narrative generation failed for POI ${poi.name}:`, err);
}
const result = await this.llm.chat(prompt, { maxTokens: 300 });
narrative = result.content || null;
} catch (err) {
this.logger.warn(`Narrative generation failed for POI ${poi.name}:`, err);
}
guidePoiRecords.push({