From 8e6a970ae4a3944c8c09285202ba4fb0c6b58ac7 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 17:21:00 +0000 Subject: [PATCH] fix: backstory generation reliability and admin LLM stats loading - Fix LLM stats panel stuck on "Loading stats..." by requesting stats immediately after admin authentication succeeds - Add retry mechanism for backstory generation (up to 3 attempts with increasing delays) so transient failures don't permanently skip NPCs - Fix stale model bug: queued LLM requests now use the current model at dequeue time instead of capturing it at enqueue time, so fallback model switches take effect for pending requests - Add per-template maxTokens (backstory: 350, invention: 300, desire: 250) to prevent "length" finish truncation on structured JSON responses - Remove unused "reasoning" field from backstory prompt to reduce token usage - Add failure logging throughout LLM pipeline (backstory null results, queue errors) for better diagnostics Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 1 + server/src/game/GameLoop.ts | 20 +++++++++++++++++++- server/src/llm/backstoryGenerator.ts | 5 ++++- server/src/llm/generationQueue.ts | 9 +++++++-- server/src/llm/llmService.ts | 10 ++++++++-- server/src/llm/promptTemplate.ts | 1 + server/src/llm/templates.ts | 6 ++++-- 7 files changed, 44 insertions(+), 8 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 49766d9..fefb85e 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -317,6 +317,7 @@ export class GameScene extends Phaser.Scene { this.client.onAdminAuthResult = (data) => { if (data.success && data.constants) { this.adminPanel.setAuthenticated(data.constants); + this.adminPanel.requestStatsRefresh(); } else { this.adminPanel.showAuthError(); } diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 1154c14..4d5738d 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -231,7 +231,25 @@ export class GameLoop { } generateNpcBackstory(entityId: number): void { - generateBackstoryAndDesires(this.world, entityId, this.llmService, this.tick, this.logService); + this.generateBackstoryWithRetry(entityId, 0); + } + + private generateBackstoryWithRetry(entityId: number, attempt: number): void { + const MAX_RETRIES = 2; + generateBackstoryAndDesires(this.world, entityId, this.llmService, this.tick, this.logService) + .then(() => { + const backstory = this.world.getComponent(entityId, 'backstory'); + if (!backstory && attempt < MAX_RETRIES) { + const name = this.world.getComponent(entityId, 'name') ?? `entity ${entityId}`; + const delay = 5000 * (attempt + 1); + this.logService.log('warning', 'LLM', `Retrying backstory for ${name} (attempt ${attempt + 2}/${MAX_RETRIES + 1}) in ${delay / 1000}s`); + setTimeout(() => this.generateBackstoryWithRetry(entityId, attempt + 1), delay); + } + }) + .catch((err) => { + const name = this.world.getComponent(entityId, 'name') ?? `entity ${entityId}`; + this.logService.log('error', 'LLM', `Backstory generation error for ${name}: ${err?.message ?? err}`); + }); } private spawnInitialNPCs(count: number): void { diff --git a/server/src/llm/backstoryGenerator.ts b/server/src/llm/backstoryGenerator.ts index 0066558..9ba55bc 100644 --- a/server/src/llm/backstoryGenerator.ts +++ b/server/src/llm/backstoryGenerator.ts @@ -104,7 +104,10 @@ export async function generateBackstoryAndDesires( structureList: context.structureList, }); - if (!result) return; + if (!result) { + logService?.log('warning', 'LLM', `Backstory generation returned null for ${name} (entity ${entityId})`); + return; + } let parsed: { backstory?: string; desires?: unknown[] } | null = null; try { diff --git a/server/src/llm/generationQueue.ts b/server/src/llm/generationQueue.ts index 332be58..4472f15 100644 --- a/server/src/llm/generationQueue.ts +++ b/server/src/llm/generationQueue.ts @@ -13,7 +13,7 @@ export interface GenerationQueue { export function createGenerationQueue( client: OpenRouterClient, - options: { requestsPerMinute: number }, + options: { requestsPerMinute: number; getModel?: () => string }, ): GenerationQueue { const queue: QueueItem[] = []; const intervalMs = Math.ceil(60000 / options.requestsPerMinute); @@ -25,9 +25,14 @@ export function createGenerationQueue( processing = true; const item = queue.shift()!; + // Apply current model at dequeue time, not enqueue time + if (options.getModel) { + item.request = { ...item.request, model: options.getModel() }; + } client.complete(item.request).then(result => { item.resolve(result); - }).catch(() => { + }).catch((err) => { + console.warn('[LLM Queue] Request failed:', err?.message ?? err); item.resolve(null); }).finally(() => { processing = false; diff --git a/server/src/llm/llmService.ts b/server/src/llm/llmService.ts index 3d97f38..92079b9 100644 --- a/server/src/llm/llmService.ts +++ b/server/src/llm/llmService.ts @@ -36,13 +36,16 @@ export function createLlmService(logService?: LogService, statsService?: LlmStat } const client = createOpenRouterClient(config, logService); + + let currentModel = config.model; + const queue = createGenerationQueue(client, { requestsPerMinute: config.requestsPerMinute, + getModel: () => currentModel, }); const counters = createUsageCounters(); const tokenTracker = createTokenTracker(100); - let currentModel = config.model; let switchBackTimer: ReturnType | null = null; function getNextMidnightUTC(): number { @@ -83,7 +86,10 @@ export function createLlmService(logService?: LogService, statsService?: LlmStat return null; } const rendered = renderTemplate(template, variables); - const result = await queue.enqueue({ ...rendered, model: currentModel }); + const request = template.maxTokens + ? { ...rendered, maxTokens: template.maxTokens } + : rendered; + const result = await queue.enqueue(request); if (isRateLimited(result)) { logService?.log('warning', 'LLM', 'Rate limited, switching to fallback'); diff --git a/server/src/llm/promptTemplate.ts b/server/src/llm/promptTemplate.ts index b46e4ca..d060106 100644 --- a/server/src/llm/promptTemplate.ts +++ b/server/src/llm/promptTemplate.ts @@ -12,6 +12,7 @@ export interface PromptTemplate { name: string; systemPrompt: string; userPrompt: string; + maxTokens?: number; } export interface RenderedPrompt { diff --git a/server/src/llm/templates.ts b/server/src/llm/templates.ts index cb08664..aeff2a2 100644 --- a/server/src/llm/templates.ts +++ b/server/src/llm/templates.ts @@ -42,6 +42,7 @@ export const templates: Record = { invention: { name: 'invention', + maxTokens: 300, systemPrompt: 'Given available materials and a settler\'s stats, consider 3 possible inventions, ' + 'then select the one that best fits the settler\'s personality. ' + @@ -68,6 +69,7 @@ export const templates: Record = { backstoryAndDesires: { name: 'backstoryAndDesires', + maxTokens: 350, systemPrompt: 'Write a brief backstory (1-2 sentences) and 1-2 initial desires for this settler. ' + 'The backstory should reflect their personality without referencing professions or institutions that do not exist. ' + @@ -85,12 +87,12 @@ export const templates: Record = { '{"description": "human-readable desire", ' + '"category": "material|social|shelter|comfort|community|creative", ' + '"fulfillment": {"type": "own_item|structure_exists|building_exists|relationship_tier|recipe_exists|custom", ...criteria}, ' + - '"priority": 0.0-1.0, ' + - '"reasoning": "why this fits the settler"}]}', + '"priority": 0.0-1.0}]}', }, desireGeneration: { name: 'desireGeneration', + maxTokens: 250, systemPrompt: 'Generate a new personal desire for this settler based on their personality and recent experiences.\n\n' + 'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.',