From a16d70a1b8868e83def12e0e59b0bc468a889a7a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 21:04:46 +0000 Subject: [PATCH] feat(thought): add batched thought generator with LLM prompt and parser Co-Authored-By: Claude Opus 4.6 --- .../llm/__tests__/thoughtGenerator.test.ts | 114 ++++++++++++++++++ server/src/llm/templates.ts | 16 ++- server/src/llm/thoughtGenerator.ts | 57 +++++++++ 3 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 server/src/llm/__tests__/thoughtGenerator.test.ts create mode 100644 server/src/llm/thoughtGenerator.ts diff --git a/server/src/llm/__tests__/thoughtGenerator.test.ts b/server/src/llm/__tests__/thoughtGenerator.test.ts new file mode 100644 index 0000000..6dc1522 --- /dev/null +++ b/server/src/llm/__tests__/thoughtGenerator.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { LlmService } from '../llmService.js'; +import { generateBatchedThoughts, type ThoughtRequest } from '../thoughtGenerator.js'; + +function mockLlmService(overrides: Partial = {}): LlmService { + return { + generate: vi.fn().mockResolvedValue(null), + queueDepth: vi.fn().mockReturnValue(0), + clear: vi.fn(), + isDailyLimitReached: vi.fn().mockReturnValue(false), + ...overrides, + }; +} + +describe('generateBatchedThoughts', () => { + it('returns empty array for empty input', async () => { + const llm = mockLlmService(); + const result = await generateBatchedThoughts([], llm); + expect(result).toEqual([]); + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('calls LLM with batchedThoughts template for single NPC', async () => { + const llm = mockLlmService({ + generate: vi.fn().mockResolvedValue('1. I wonder what lies beyond the hills.'), + }); + + const requests: ThoughtRequest[] = [{ + entityId: 1, + name: 'Bjorn', + personality: 'SOC:6, EMP:7, TMP:13, CUR:11', + currentState: 'wandering, idle', + recentEvents: 'none', + }]; + + const result = await generateBatchedThoughts(requests, llm); + expect(llm.generate).toHaveBeenCalledOnce(); + expect(llm.generate).toHaveBeenCalledWith('batchedThoughts', expect.objectContaining({ + npcList: expect.stringContaining('Bjorn'), + })); + expect(result).toHaveLength(1); + expect(result[0].entityId).toBe(1); + expect(result[0].text).toBe('I wonder what lies beyond the hills.'); + }); + + it('parses multi-NPC response correctly', async () => { + const response = [ + '1. My stomach is killing me.', + '2. It is nice having someone like Sven around.', + '3. I wonder what is past those hills.', + ].join('\n'); + + const llm = mockLlmService({ + generate: vi.fn().mockResolvedValue(response), + }); + + const requests: ThoughtRequest[] = [ + { entityId: 10, name: 'Bjorn', personality: '', currentState: 'hungry', recentEvents: 'none' }, + { entityId: 20, name: 'Helga', personality: '', currentState: 'content', recentEvents: 'befriended Sven' }, + { entityId: 30, name: 'Sven', personality: '', currentState: 'idle', recentEvents: 'none' }, + ]; + + const result = await generateBatchedThoughts(requests, llm); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ entityId: 10, text: 'My stomach is killing me.' }); + expect(result[1]).toEqual({ entityId: 20, text: 'It is nice having someone like Sven around.' }); + expect(result[2]).toEqual({ entityId: 30, text: 'I wonder what is past those hills.' }); + }); + + it('skips lines that fail to parse', async () => { + const response = '1. Good thought.\ngarbage line\n3. Another thought.'; + const llm = mockLlmService({ + generate: vi.fn().mockResolvedValue(response), + }); + + const requests: ThoughtRequest[] = [ + { entityId: 1, name: 'A', personality: '', currentState: '', recentEvents: '' }, + { entityId: 2, name: 'B', personality: '', currentState: '', recentEvents: '' }, + { entityId: 3, name: 'C', personality: '', currentState: '', recentEvents: '' }, + ]; + + const result = await generateBatchedThoughts(requests, llm); + expect(result).toHaveLength(2); + expect(result[0].entityId).toBe(1); + expect(result[1].entityId).toBe(3); + }); + + it('returns empty array when LLM returns null', async () => { + const llm = mockLlmService({ + generate: vi.fn().mockResolvedValue(null), + }); + + const requests: ThoughtRequest[] = [ + { entityId: 1, name: 'A', personality: '', currentState: '', recentEvents: '' }, + ]; + + const result = await generateBatchedThoughts(requests, llm); + expect(result).toEqual([]); + }); + + it('returns empty array when daily limit reached', async () => { + const llm = mockLlmService({ + isDailyLimitReached: vi.fn().mockReturnValue(true), + }); + + const requests: ThoughtRequest[] = [ + { entityId: 1, name: 'A', personality: '', currentState: '', recentEvents: '' }, + ]; + + const result = await generateBatchedThoughts(requests, llm); + expect(result).toEqual([]); + expect(llm.generate).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/llm/templates.ts b/server/src/llm/templates.ts index 7e7fab0..2fcaac9 100644 --- a/server/src/llm/templates.ts +++ b/server/src/llm/templates.ts @@ -30,16 +30,14 @@ export const templates: Record = { 'Describe what happened in one vivid sentence.', }, - innerMonologue: { - name: 'innerMonologue', + batchedThoughts: { + name: 'batchedThoughts', 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.', + 'You voice the inner thoughts of NPCs in a medieval village simulation. ' + + 'Write one brief thought per NPC in first person (1 sentence each). ' + + 'Reflect their personality and current situation. Be natural, not dramatic. ' + + 'Respond with numbered lines matching the input.', userPrompt: - 'NPC: {{npcName}} ({{personality}})\n' + - 'Current state: {{currentState}}\n' + - 'Recent events: {{recentEvents}}\n' + - 'What is {{npcName}} thinking right now?', + 'Generate a brief inner thought for each NPC:\n\n{{npcList}}', }, }; diff --git a/server/src/llm/thoughtGenerator.ts b/server/src/llm/thoughtGenerator.ts new file mode 100644 index 0000000..5a5a64a --- /dev/null +++ b/server/src/llm/thoughtGenerator.ts @@ -0,0 +1,57 @@ +import type { EntityId } from '@dflike/shared'; +import type { LlmService } from './llmService.js'; + +export interface ThoughtRequest { + entityId: EntityId; + name: string; + personality: string; + currentState: string; + recentEvents: string; +} + +export interface ThoughtResult { + entityId: EntityId; + text: string; +} + +export async function generateBatchedThoughts( + requests: ThoughtRequest[], + llmService: LlmService, +): Promise { + if (requests.length === 0) return []; + if (llmService.isDailyLimitReached()) return []; + + const npcList = requests + .map((r, i) => + `${i + 1}. ${r.name} — Personality: ${r.personality}. State: ${r.currentState}. Recent: ${r.recentEvents}`, + ) + .join('\n'); + + const response = await llmService.generate('batchedThoughts', { npcList }); + if (response == null) return []; + + return parseNumberedResponse(response, requests); +} + +function parseNumberedResponse( + response: string, + requests: ThoughtRequest[], +): ThoughtResult[] { + const results: ThoughtResult[] = []; + const lines = response.split('\n').filter((l) => l.trim().length > 0); + + for (const line of lines) { + const match = line.match(/^(\d+)\.\s*(.+)/); + if (!match) continue; + + const index = parseInt(match[1], 10) - 1; + if (index < 0 || index >= requests.length) continue; + + results.push({ + entityId: requests[index].entityId, + text: match[2].trim(), + }); + } + + return results; +}