mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 17:52:54 +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
|
|
@ -26,6 +26,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/shared-llm": "workspace:^",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,32 +1,20 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LlmClientService } from '@manacore/shared-llm';
|
||||
|
||||
export interface FeedbackAnalysis {
|
||||
title: string;
|
||||
category: 'bug' | 'feature' | 'improvement' | 'question' | 'other';
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = ['bug', 'feature', 'improvement', 'question', 'other'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private readonly manaLlmUrl: string | null = null;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const url = this.configService.get<string>('MANA_LLM_URL');
|
||||
if (url) {
|
||||
this.manaLlmUrl = url;
|
||||
this.logger.log(`AI service using mana-llm at ${url}`);
|
||||
} else {
|
||||
this.logger.warn('MANA_LLM_URL not configured - AI features disabled');
|
||||
}
|
||||
}
|
||||
constructor(private readonly llm: LlmClientService) {}
|
||||
|
||||
async analyzeFeedback(feedbackText: string): Promise<FeedbackAnalysis> {
|
||||
// Fallback if AI not available
|
||||
if (!this.manaLlmUrl) {
|
||||
return this.fallbackAnalysis(feedbackText);
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = `Analysiere dieses User-Feedback und generiere:
|
||||
1. Einen kurzen, prägnanten deutschen Titel (max 60 Zeichen) der den Kern des Feedbacks zusammenfasst
|
||||
|
|
@ -37,48 +25,24 @@ Feedback: "${feedbackText}"
|
|||
Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein anderer Text):
|
||||
{"title": "...", "category": "..."}`;
|
||||
|
||||
const result = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'ollama/gemma3:4b',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
const { data } = await this.llm.json<FeedbackAnalysis>(prompt, {
|
||||
temperature: 0.3,
|
||||
timeout: 30_000,
|
||||
validate: (raw) => {
|
||||
const obj = raw as FeedbackAnalysis;
|
||||
if (!obj.title || !obj.category) throw new Error('missing fields');
|
||||
if (!VALID_CATEGORIES.includes(obj.category as any)) {
|
||||
obj.category = 'other';
|
||||
}
|
||||
if (obj.title.length > 60) {
|
||||
obj.title = obj.title.substring(0, 57) + '...';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`mana-llm error: ${result.status}`);
|
||||
}
|
||||
|
||||
const data = await result.json();
|
||||
const response = (data.choices?.[0]?.message?.content || '').trim();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonStr) as FeedbackAnalysis;
|
||||
|
||||
// Validate category
|
||||
const validCategories = ['bug', 'feature', 'improvement', 'question', 'other'];
|
||||
if (!validCategories.includes(parsed.category)) {
|
||||
parsed.category = 'other';
|
||||
}
|
||||
|
||||
// Ensure title is not too long
|
||||
if (parsed.title.length > 60) {
|
||||
parsed.title = parsed.title.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
this.logger.debug(`AI analyzed feedback: ${JSON.stringify(parsed)}`);
|
||||
return parsed;
|
||||
this.logger.debug(`AI analyzed feedback: ${JSON.stringify(data)}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI analysis failed: ${error}`);
|
||||
return this.fallbackAnalysis(feedbackText);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { LlmModule } from '@manacore/shared-llm';
|
||||
import configuration from './config/configuration';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
|
|
@ -35,6 +36,14 @@ import { SecurityModule } from './security';
|
|||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
LlmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
manaLlmUrl: config.get('MANA_LLM_URL'),
|
||||
debug: config.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
LoggerModule,
|
||||
SecurityModule,
|
||||
MetricsModule,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"dependencies": {
|
||||
"@manacore/bot-services": "workspace:*",
|
||||
"@manacore/matrix-bot-common": "workspace:*",
|
||||
"@manacore/shared-llm": "workspace:^",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
|
|
|
|||
|
|
@ -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 { HealthController, createHealthProvider } from '@manacore/matrix-bot-common';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import configuration from './config/configuration';
|
||||
|
|
@ -10,6 +11,15 @@ import configuration from './config/configuration';
|
|||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
LlmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
manaLlmUrl: config.get('llm.url') || 'http://localhost:3025',
|
||||
defaultModel: config.get('llm.model') || 'ollama/gemma3:4b',
|
||||
timeout: config.get<number>('llm.timeout') || 120000,
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
BotModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
|
|
|
|||
|
|
@ -1,49 +1,17 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { LlmClientService } from '@manacore/shared-llm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
interface LlmModel {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string | ContentPart[];
|
||||
}
|
||||
|
||||
interface ContentPart {
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: { url: 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;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OllamaService implements OnModuleInit {
|
||||
private readonly logger = new Logger(OllamaService.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly defaultModel: string;
|
||||
private readonly timeout: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>('llm.url') || 'http://localhost:3025';
|
||||
constructor(
|
||||
private readonly llm: LlmClientService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.defaultModel = this.configService.get<string>('llm.model') || 'ollama/gemma3:4b';
|
||||
this.timeout = this.configService.get<number>('llm.timeout') || 120000;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
|
|
@ -52,27 +20,23 @@ export class OllamaService implements OnModuleInit {
|
|||
|
||||
async checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const data = await response.json();
|
||||
this.logger.log(`mana-llm connected: ${data.status}, providers: ${Object.keys(data.providers || {}).join(', ')}`);
|
||||
return data.status === 'healthy' || data.status === 'degraded';
|
||||
const health = await this.llm.health();
|
||||
this.logger.log(
|
||||
`mana-llm connected: ${health.status}, providers: ${Object.keys(health.providers || {}).join(', ')}`
|
||||
);
|
||||
return health.status === 'healthy' || health.status === 'degraded';
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to connect to mana-llm at ${this.baseUrl}:`, error);
|
||||
this.logger.error('Failed to connect to mana-llm:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<{ name: string; size: number; modified_at: string }[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/v1/models`);
|
||||
const data = await response.json();
|
||||
|
||||
// Convert OpenAI format to legacy Ollama format for compatibility
|
||||
return (data.data || []).map((m: LlmModel) => ({
|
||||
const models = await this.llm.listModels();
|
||||
return models.map((m) => ({
|
||||
name: m.id,
|
||||
size: 0, // mana-llm doesn't provide size
|
||||
size: 0,
|
||||
modified_at: new Date().toISOString(),
|
||||
}));
|
||||
} catch (error) {
|
||||
|
|
@ -87,39 +51,15 @@ export class OllamaService implements OnModuleInit {
|
|||
): Promise<string> {
|
||||
const selectedModel = model ? this.normalizeModel(model) : this.defaultModel;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages,
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const result = await this.llm.chatMessages(messages, { model: selectedModel });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`mana-llm API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data: ChatCompletionResponse = await response.json();
|
||||
|
||||
// Log performance metrics
|
||||
if (data.usage) {
|
||||
this.logger.debug(
|
||||
`Generated ${data.usage.completion_tokens} tokens (total: ${data.usage.total_tokens})`
|
||||
);
|
||||
}
|
||||
|
||||
return data.choices[0]?.message?.content || '';
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
throw new Error('LLM Timeout - Antwort dauerte zu lange');
|
||||
}
|
||||
throw error;
|
||||
if (result.usage.completion_tokens) {
|
||||
this.logger.debug(
|
||||
`Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})`
|
||||
);
|
||||
}
|
||||
|
||||
return result.content;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
|
|
@ -129,59 +69,19 @@ export class OllamaService implements OnModuleInit {
|
|||
async chatWithImage(prompt: string, imageBase64: string, model?: string): Promise<string> {
|
||||
const selectedModel = model ? this.normalizeModel(model) : this.defaultModel;
|
||||
|
||||
try {
|
||||
// Use OpenAI vision format
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/png;base64,${imageBase64}` },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await this.llm.vision(prompt, imageBase64, 'image/png', {
|
||||
model: selectedModel,
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages,
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`mana-llm API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data: ChatCompletionResponse = await response.json();
|
||||
|
||||
// Log performance metrics
|
||||
if (data.usage) {
|
||||
this.logger.debug(
|
||||
`Vision: Generated ${data.usage.completion_tokens} tokens (total: ${data.usage.total_tokens})`
|
||||
);
|
||||
}
|
||||
|
||||
return data.choices[0]?.message?.content || '';
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
throw new Error('LLM Timeout - Bildanalyse dauerte zu lange');
|
||||
}
|
||||
throw error;
|
||||
if (result.usage.completion_tokens) {
|
||||
this.logger.debug(
|
||||
`Vision: Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})`
|
||||
);
|
||||
}
|
||||
|
||||
return result.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize model name to include provider prefix if missing.
|
||||
* e.g., "gemma3:4b" -> "ollama/gemma3:4b"
|
||||
*/
|
||||
private normalizeModel(model: string): string {
|
||||
if (model.includes('/')) {
|
||||
return model;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue