diff --git a/server/src/llm/__tests__/llmService.test.ts b/server/src/llm/__tests__/llmService.test.ts new file mode 100644 index 0000000..e5507d6 --- /dev/null +++ b/server/src/llm/__tests__/llmService.test.ts @@ -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(); + }); +}); diff --git a/server/src/llm/llmService.ts b/server/src/llm/llmService.ts new file mode 100644 index 0000000..074bc24 --- /dev/null +++ b/server/src/llm/llmService.ts @@ -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): Promise; + 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, + ): Promise { + 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(); + }, + }; +} diff --git a/server/src/llm/templates.ts b/server/src/llm/templates.ts new file mode 100644 index 0000000..7e7fab0 --- /dev/null +++ b/server/src/llm/templates.ts @@ -0,0 +1,45 @@ +import type { PromptTemplate } from './promptTemplate.js'; + +export const templates: Record = { + 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?', + }, +};