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

@ -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",

View file

@ -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],
})

View file

@ -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);

View file

@ -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,

View file

@ -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",

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 { 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],

View file

@ -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;