mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 10:06:41 +02:00
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:
parent
e7bf58c5b6
commit
e2f144962c
48 changed files with 2476 additions and 1297 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue