feat(llm): add LLM service facade and prompt templates

Combines config, OpenRouter client, generation queue, and template
rendering into a simple generate(templateName, variables) API. When
disabled (no API key), returns a no-op implementation. Includes
backstory, socialNarration, and innerMonologue prompt templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 16:28:14 +00:00
parent e9409f6e82
commit b8a5032be6
3 changed files with 150 additions and 0 deletions
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createLlmService } from '../llmService.js';
describe('llmService', () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.OPENROUTER_API_KEY;
});
it('returns null when LLM is disabled (no API key)', async () => {
delete process.env.OPENROUTER_API_KEY;
const service = createLlmService();
const result = await service.generate('backstory', { npcName: 'Test' });
expect(result).toBeNull();
});
it('generates text using a named template', async () => {
process.env.OPENROUTER_API_KEY = 'test-key';
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'A brave warrior from the north.' } }],
}),
});
const service = createLlmService();
const result = await service.generate('backstory', {
npcName: 'Brynn',
stats: 'STR:15, DEX:12, CON:14, INT:8, PER:10, SOC:6, COU:16, CUR:11, EMP:7, TMP:13',
});
expect(result).toBe('A brave warrior from the north.');
});
it('exposes queue depth and clear', async () => {
process.env.OPENROUTER_API_KEY = 'test-key';
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'ok' } }],
}),
});
const service = createLlmService();
expect(service.queueDepth()).toBe(0);
service.clear();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { getLlmConfig } from '../config/llmConfig.js';
import { createOpenRouterClient } from './openRouterClient.js';
import { createGenerationQueue } from './generationQueue.js';
import { renderTemplate } from './promptTemplate.js';
import { templates } from './templates.js';
export interface LlmService {
generate(templateName: string, variables: Record<string, string>): Promise<string | null>;
queueDepth(): number;
clear(): void;
}
export function createLlmService(): LlmService {
const config = getLlmConfig();
if (!config.enabled) {
return {
generate: async () => null,
queueDepth: () => 0,
clear: () => {},
};
}
const client = createOpenRouterClient(config);
const queue = createGenerationQueue(client, {
requestsPerMinute: config.requestsPerMinute,
});
return {
async generate(
templateName: string,
variables: Record<string, string>,
): Promise<string | null> {
const template = templates[templateName];
if (!template) {
console.warn(`Unknown LLM template: ${templateName}`);
return null;
}
const rendered = renderTemplate(template, variables);
return queue.enqueue(rendered);
},
queueDepth(): number {
return queue.depth();
},
clear(): void {
queue.clear();
},
};
}
+45
View File
@@ -0,0 +1,45 @@
import type { PromptTemplate } from './promptTemplate.js';
export const templates: Record<string, PromptTemplate> = {
backstory: {
name: 'backstory',
systemPrompt:
'You are a narrator for a medieval fantasy village simulation. ' +
'Write brief, evocative NPC backstories. Keep responses to 1-2 sentences. ' +
'Do not use cliches. Ground details in daily village life.',
userPrompt:
'Generate a short backstory for an NPC named {{npcName}}.\n' +
'Their stats: {{stats}}\n' +
'The backstory should reflect their personality stats. ' +
'High sociability means outgoing, low means reclusive. ' +
'High courage means bold, low means timid. ' +
'High empathy means caring, low means self-focused. ' +
'High temperament means volatile, low means calm.',
},
socialNarration: {
name: 'socialNarration',
systemPrompt:
'You are a narrator for a medieval fantasy village simulation. ' +
'Describe NPC social interactions in 1 sentence. ' +
'Be specific and grounded. No purple prose.',
userPrompt:
'{{npc1Name}} ({{npc1Personality}}) had a {{outcome}} interaction with ' +
'{{npc2Name}} ({{npc2Personality}}). ' +
'They are currently {{relationship}} (sentiment: {{sentiment}}/100).\n' +
'Describe what happened in one vivid sentence.',
},
innerMonologue: {
name: 'innerMonologue',
systemPrompt:
'You are voicing the inner thoughts of an NPC in a medieval village simulation. ' +
'Write a single brief thought (1 sentence) in first person. ' +
'Reflect their personality and current situation. Be natural, not dramatic.',
userPrompt:
'NPC: {{npcName}} ({{personality}})\n' +
'Current state: {{currentState}}\n' +
'Recent events: {{recentEvents}}\n' +
'What is {{npcName}} thinking right now?',
},
};