mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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>
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { extractJson } from '../utils/json-extractor';
|
|
|
|
describe('extractJson', () => {
|
|
it('parses direct JSON object', () => {
|
|
const result = extractJson('{"name": "test", "value": 42}');
|
|
expect(result).toEqual({ name: 'test', value: 42 });
|
|
});
|
|
|
|
it('parses direct JSON array', () => {
|
|
const result = extractJson('[1, 2, 3]');
|
|
expect(result).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('strips markdown json code fence', () => {
|
|
const input = '```json\n{"category": "bug", "title": "Fix login"}\n```';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ category: 'bug', title: 'Fix login' });
|
|
});
|
|
|
|
it('strips markdown code fence without json label', () => {
|
|
const input = '```\n{"key": "value"}\n```';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ key: 'value' });
|
|
});
|
|
|
|
it('extracts JSON from surrounding text', () => {
|
|
const input =
|
|
'Here is the analysis:\n{"confidence": 0.95, "species": "Rose"}\nHope this helps!';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ confidence: 0.95, species: 'Rose' });
|
|
});
|
|
|
|
it('extracts JSON array from surrounding text', () => {
|
|
const input = 'The items are: [1, 2, 3] as requested.';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('handles nested JSON objects', () => {
|
|
const input = '{"outer": {"inner": {"deep": true}}, "list": [1, 2]}';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ outer: { inner: { deep: true } }, list: [1, 2] });
|
|
});
|
|
|
|
it('handles JSON with escaped quotes in strings', () => {
|
|
const input = '{"text": "He said \\"hello\\""}';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ text: 'He said "hello"' });
|
|
});
|
|
|
|
it('handles JSON with braces inside strings', () => {
|
|
const input = 'Result: {"code": "if (x) { return }"}';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ code: 'if (x) { return }' });
|
|
});
|
|
|
|
it('trims whitespace before parsing', () => {
|
|
const input = ' \n {"key": "value"} \n ';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ key: 'value' });
|
|
});
|
|
|
|
it('applies validation function on success', () => {
|
|
const validate = (data: unknown) => {
|
|
const obj = data as { name: string };
|
|
if (!obj.name) throw new Error('missing name');
|
|
return obj;
|
|
};
|
|
const result = extractJson('{"name": "test"}', validate);
|
|
expect(result).toEqual({ name: 'test' });
|
|
});
|
|
|
|
it('throws when validation fails', () => {
|
|
const validate = (data: unknown) => {
|
|
const obj = data as { name?: string };
|
|
if (!obj.name) throw new Error('missing name');
|
|
return obj;
|
|
};
|
|
expect(() => extractJson('{"value": 123}', validate)).toThrow();
|
|
});
|
|
|
|
it('throws on completely invalid input', () => {
|
|
expect(() => extractJson('This is just plain text with no JSON')).toThrow(
|
|
'Failed to extract JSON'
|
|
);
|
|
});
|
|
|
|
it('throws on empty input', () => {
|
|
expect(() => extractJson('')).toThrow('Failed to extract JSON');
|
|
});
|
|
|
|
it('handles real-world LLM response with preamble', () => {
|
|
const input = `Based on my analysis, here is the result:
|
|
|
|
\`\`\`json
|
|
{
|
|
"foods": [
|
|
{"name": "Apple", "calories": 95, "protein": 0.5}
|
|
],
|
|
"totalCalories": 95,
|
|
"confidence": 0.9
|
|
}
|
|
\`\`\`
|
|
|
|
This analysis is based on the image provided.`;
|
|
|
|
const result = extractJson<{ foods: unknown[]; totalCalories: number }>(input);
|
|
expect(result.totalCalories).toBe(95);
|
|
expect(result.foods).toHaveLength(1);
|
|
});
|
|
|
|
it('prefers object over array when both exist', () => {
|
|
// Direct parse fails, fence fails, tries object first
|
|
const input = 'Some text {"key": "val"} and [1, 2, 3]';
|
|
const result = extractJson(input);
|
|
expect(result).toEqual({ key: 'val' });
|
|
});
|
|
});
|