feat: add NarrationService with event buffer, LLM priority, and template fallback
Implements NarrationService that manages a rolling buffer of 50 narration events, generates immediate template fallback text, and queues LLM generation for priority interactions and proposals (respecting daily limits). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { NarrationEvent } from '@dflike/shared';
|
||||
import type { LlmService } from '../llmService.js';
|
||||
import type { InteractionRecord } from '../narrationService.js';
|
||||
import { createNarrationService } from '../narrationService.js';
|
||||
|
||||
function mockLlmService(overrides: Partial<LlmService> = {}): LlmService {
|
||||
return {
|
||||
generate: vi.fn().mockResolvedValue('LLM narration text'),
|
||||
queueDepth: vi.fn().mockReturnValue(0),
|
||||
clear: vi.fn(),
|
||||
isDailyLimitReached: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRecord(overrides: Partial<InteractionRecord> = {}): InteractionRecord {
|
||||
return {
|
||||
tick: 100,
|
||||
entityIds: [1, 2],
|
||||
names: ['Alice', 'Bob'],
|
||||
outcome: 'positive',
|
||||
classification: 'acquaintance',
|
||||
isProposal: false,
|
||||
isPriority: false,
|
||||
npc1Personality: 'friendly',
|
||||
npc2Personality: 'curious',
|
||||
sentiment: 0.5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NarrationService', () => {
|
||||
let llm: LlmService;
|
||||
|
||||
beforeEach(() => {
|
||||
llm = mockLlmService();
|
||||
});
|
||||
|
||||
it('creates event with fallback text and returns it', () => {
|
||||
const service = createNarrationService(llm);
|
||||
const event = service.recordInteraction(makeRecord());
|
||||
|
||||
expect(event.id).toBe(1);
|
||||
expect(event.tick).toBe(100);
|
||||
expect(event.type).toBe('social');
|
||||
expect(event.entityIds).toEqual([1, 2]);
|
||||
expect(event.names).toEqual(['Alice', 'Bob']);
|
||||
expect(event.outcome).toBe('positive');
|
||||
expect(event.narration).toBeTruthy();
|
||||
expect(event.isLlmGenerated).toBe(false);
|
||||
});
|
||||
|
||||
it('sets type to proposal when isProposal is true', () => {
|
||||
const service = createNarrationService(llm);
|
||||
const event = service.recordInteraction(makeRecord({ isProposal: true, outcome: 'proposal_accepted' }));
|
||||
expect(event.type).toBe('proposal');
|
||||
});
|
||||
|
||||
it('auto-increments event ids', () => {
|
||||
const service = createNarrationService(llm);
|
||||
const e1 = service.recordInteraction(makeRecord());
|
||||
const e2 = service.recordInteraction(makeRecord());
|
||||
expect(e1.id).toBe(1);
|
||||
expect(e2.id).toBe(2);
|
||||
});
|
||||
|
||||
it('queues LLM for priority interactions', () => {
|
||||
const service = createNarrationService(llm);
|
||||
service.recordInteraction(makeRecord({ isPriority: true }));
|
||||
|
||||
expect(llm.generate).toHaveBeenCalledOnce();
|
||||
expect(llm.generate).toHaveBeenCalledWith('socialNarration', expect.objectContaining({
|
||||
npc1Name: 'Alice',
|
||||
npc2Name: 'Bob',
|
||||
}));
|
||||
});
|
||||
|
||||
it('always queues LLM for proposals', () => {
|
||||
const service = createNarrationService(llm);
|
||||
service.recordInteraction(makeRecord({ isProposal: true, outcome: 'proposal_accepted' }));
|
||||
|
||||
expect(llm.generate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does NOT queue LLM for non-priority, non-proposal interactions', () => {
|
||||
const service = createNarrationService(llm);
|
||||
service.recordInteraction(makeRecord({ isPriority: false, isProposal: false }));
|
||||
|
||||
expect(llm.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not queue LLM when daily limit is reached', () => {
|
||||
llm = mockLlmService({ isDailyLimitReached: vi.fn().mockReturnValue(true) });
|
||||
const service = createNarrationService(llm);
|
||||
service.recordInteraction(makeRecord({ isPriority: true }));
|
||||
|
||||
expect(llm.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolling buffer caps at 50', () => {
|
||||
const service = createNarrationService(llm);
|
||||
for (let i = 0; i < 55; i++) {
|
||||
service.recordInteraction(makeRecord({ tick: i }));
|
||||
}
|
||||
|
||||
const events = service.getRecentEvents();
|
||||
expect(events.length).toBe(50);
|
||||
// oldest events (tick 0-4) should have been dropped
|
||||
expect(events[0].tick).toBe(5);
|
||||
});
|
||||
|
||||
it('getRecentEvents returns a copy', () => {
|
||||
const service = createNarrationService(llm);
|
||||
service.recordInteraction(makeRecord());
|
||||
const events1 = service.getRecentEvents();
|
||||
const events2 = service.getRecentEvents();
|
||||
expect(events1).not.toBe(events2);
|
||||
expect(events1).toEqual(events2);
|
||||
});
|
||||
|
||||
it('getEventsForEntity filters correctly', () => {
|
||||
const service = createNarrationService(llm);
|
||||
service.recordInteraction(makeRecord({ entityIds: [1, 2] }));
|
||||
service.recordInteraction(makeRecord({ entityIds: [3, 4] }));
|
||||
service.recordInteraction(makeRecord({ entityIds: [2, 5] }));
|
||||
|
||||
const eventsFor2 = service.getEventsForEntity(2);
|
||||
expect(eventsFor2.length).toBe(2);
|
||||
|
||||
const eventsFor3 = service.getEventsForEntity(3);
|
||||
expect(eventsFor3.length).toBe(1);
|
||||
|
||||
const eventsFor9 = service.getEventsForEntity(9);
|
||||
expect(eventsFor9.length).toBe(0);
|
||||
});
|
||||
|
||||
it('onEventCreated callback fires', () => {
|
||||
const service = createNarrationService(llm);
|
||||
const created: NarrationEvent[] = [];
|
||||
service.onEventCreated = (e) => created.push(e);
|
||||
|
||||
service.recordInteraction(makeRecord());
|
||||
expect(created.length).toBe(1);
|
||||
expect(created[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('onEventUpdated callback fires when LLM result arrives', async () => {
|
||||
let resolveGenerate: (val: string) => void;
|
||||
const generatePromise = new Promise<string>((resolve) => {
|
||||
resolveGenerate = resolve;
|
||||
});
|
||||
llm = mockLlmService({
|
||||
generate: vi.fn().mockReturnValue(generatePromise),
|
||||
});
|
||||
|
||||
const service = createNarrationService(llm);
|
||||
const updated: NarrationEvent[] = [];
|
||||
service.onEventUpdated = (e) => updated.push(e);
|
||||
|
||||
const event = service.recordInteraction(makeRecord({ isPriority: true }));
|
||||
expect(updated.length).toBe(0);
|
||||
expect(event.isLlmGenerated).toBe(false);
|
||||
|
||||
resolveGenerate!('A beautifully crafted narration.');
|
||||
await vi.waitFor(() => expect(updated.length).toBe(1));
|
||||
|
||||
expect(updated[0].narration).toBe('A beautifully crafted narration.');
|
||||
expect(updated[0].isLlmGenerated).toBe(true);
|
||||
});
|
||||
|
||||
it('does not update event when LLM returns null', async () => {
|
||||
llm = mockLlmService({
|
||||
generate: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
const service = createNarrationService(llm);
|
||||
const updated: NarrationEvent[] = [];
|
||||
service.onEventUpdated = (e) => updated.push(e);
|
||||
|
||||
service.recordInteraction(makeRecord({ isPriority: true }));
|
||||
|
||||
// Wait a tick for the promise to settle
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(updated.length).toBe(0);
|
||||
});
|
||||
|
||||
it('passes correct variables to LLM generate', () => {
|
||||
const service = createNarrationService(llm);
|
||||
service.recordInteraction(makeRecord({
|
||||
isPriority: true,
|
||||
names: ['Elara', 'Brynn'],
|
||||
npc1Personality: 'bold',
|
||||
npc2Personality: 'shy',
|
||||
outcome: 'negative',
|
||||
classification: 'rival',
|
||||
sentiment: -0.3,
|
||||
}));
|
||||
|
||||
expect(llm.generate).toHaveBeenCalledWith('socialNarration', {
|
||||
npc1Name: 'Elara',
|
||||
npc2Name: 'Brynn',
|
||||
npc1Personality: 'bold',
|
||||
npc2Personality: 'shy',
|
||||
outcome: 'negative',
|
||||
relationship: 'rival',
|
||||
sentiment: '-0.3',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { EntityId, NarrationEvent, NarrationOutcome } from '@dflike/shared';
|
||||
import type { LlmService } from './llmService.js';
|
||||
import { generateFallbackNarration } from './narrationTemplates.js';
|
||||
|
||||
export interface InteractionRecord {
|
||||
tick: number;
|
||||
entityIds: [EntityId, EntityId];
|
||||
names: [string, string];
|
||||
outcome: NarrationOutcome;
|
||||
classification: string;
|
||||
isProposal: boolean;
|
||||
isPriority: boolean;
|
||||
npc1Personality?: string;
|
||||
npc2Personality?: string;
|
||||
sentiment?: number;
|
||||
}
|
||||
|
||||
export interface NarrationService {
|
||||
recordInteraction(record: InteractionRecord): NarrationEvent;
|
||||
getRecentEvents(): NarrationEvent[];
|
||||
getEventsForEntity(entityId: EntityId): NarrationEvent[];
|
||||
onEventUpdated: ((event: NarrationEvent) => void) | null;
|
||||
onEventCreated: ((event: NarrationEvent) => void) | null;
|
||||
}
|
||||
|
||||
const MAX_BUFFER_SIZE = 50;
|
||||
|
||||
export function createNarrationService(llmService: LlmService): NarrationService {
|
||||
const buffer: NarrationEvent[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
const service: NarrationService = {
|
||||
onEventUpdated: null,
|
||||
onEventCreated: null,
|
||||
|
||||
recordInteraction(record: InteractionRecord): NarrationEvent {
|
||||
const event: NarrationEvent = {
|
||||
id: nextId++,
|
||||
tick: record.tick,
|
||||
type: record.isProposal ? 'proposal' : 'social',
|
||||
entityIds: record.entityIds,
|
||||
names: record.names,
|
||||
outcome: record.outcome,
|
||||
narration: generateFallbackNarration(
|
||||
record.names[0],
|
||||
record.names[1],
|
||||
record.outcome,
|
||||
record.classification,
|
||||
),
|
||||
isLlmGenerated: false,
|
||||
};
|
||||
|
||||
buffer.push(event);
|
||||
if (buffer.length > MAX_BUFFER_SIZE) {
|
||||
buffer.shift();
|
||||
}
|
||||
|
||||
service.onEventCreated?.(event);
|
||||
|
||||
const shouldQueueLlm =
|
||||
(record.isProposal || record.isPriority) && !llmService.isDailyLimitReached();
|
||||
|
||||
if (shouldQueueLlm) {
|
||||
const variables: Record<string, string> = {
|
||||
npc1Name: record.names[0],
|
||||
npc2Name: record.names[1],
|
||||
npc1Personality: record.npc1Personality ?? '',
|
||||
npc2Personality: record.npc2Personality ?? '',
|
||||
outcome: record.outcome,
|
||||
relationship: record.classification,
|
||||
sentiment: String(record.sentiment ?? ''),
|
||||
};
|
||||
|
||||
llmService.generate('socialNarration', variables).then((result) => {
|
||||
if (result != null) {
|
||||
event.narration = result;
|
||||
event.isLlmGenerated = true;
|
||||
service.onEventUpdated?.(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
getRecentEvents(): NarrationEvent[] {
|
||||
return [...buffer];
|
||||
},
|
||||
|
||||
getEventsForEntity(entityId: EntityId): NarrationEvent[] {
|
||||
return buffer.filter(
|
||||
(e) => e.entityIds[0] === entityId || e.entityIds[1] === entityId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
Reference in New Issue
Block a user