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