From 4bcf90d7c556a3839ae1b6a91d98c7e373dc46cf Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 17:57:03 +0000 Subject: [PATCH] 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 --- .../llm/__tests__/narrationService.test.ts | 210 ++++++++++++++++++ server/src/llm/narrationService.ts | 98 ++++++++ 2 files changed, 308 insertions(+) create mode 100644 server/src/llm/__tests__/narrationService.test.ts create mode 100644 server/src/llm/narrationService.ts diff --git a/server/src/llm/__tests__/narrationService.test.ts b/server/src/llm/__tests__/narrationService.test.ts new file mode 100644 index 0000000..df92281 --- /dev/null +++ b/server/src/llm/__tests__/narrationService.test.ts @@ -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 { + 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 { + 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((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', + }); + }); +}); diff --git a/server/src/llm/narrationService.ts b/server/src/llm/narrationService.ts new file mode 100644 index 0000000..ec7e1ee --- /dev/null +++ b/server/src/llm/narrationService.ts @@ -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 = { + 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; +}