feat(thought): add batched thought generator with LLM prompt and parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 21:04:46 +00:00
parent e33a253c5b
commit a16d70a1b8
3 changed files with 178 additions and 9 deletions
@@ -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> = {}): 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();
});
});
+7 -9
View File
@@ -30,16 +30,14 @@ export const templates: Record<string, PromptTemplate> = {
'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}}',
},
};
+57
View File
@@ -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<ThoughtResult[]> {
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;
}