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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface PromptTemplate {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface RenderedPrompt {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user