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 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-10 17:21:00 +00:00
parent b6f93dd694
commit 8e6a970ae4
7 changed files with 44 additions and 8 deletions

View File

@@ -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();
}

View File

@@ -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<string>(entityId, 'backstory');
if (!backstory && attempt < MAX_RETRIES) {
const name = this.world.getComponent<string>(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<string>(entityId, 'name') ?? `entity ${entityId}`;
this.logService.log('error', 'LLM', `Backstory generation error for ${name}: ${err?.message ?? err}`);
});
}
private spawnInitialNPCs(count: number): void {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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<typeof setTimeout> | 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');

View File

@@ -12,6 +12,7 @@ export interface PromptTemplate {
name: string;
systemPrompt: string;
userPrompt: string;
maxTokens?: number;
}
export interface RenderedPrompt {

View File

@@ -42,6 +42,7 @@ export const templates: Record<string, PromptTemplate> = {
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<string, PromptTemplate> = {
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<string, PromptTemplate> = {
'{"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.',