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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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?',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user