Run request analysis on demand on the VPS

This commit is contained in:
Andraxion 2026-06-27 00:18:41 -04:00
parent 9f9b13aa01
commit d899e902a0
21 changed files with 2485 additions and 4 deletions

407
server.js
View file

@ -1,4 +1,5 @@
import express from "express";
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
@ -39,6 +40,11 @@ const catalogMetaPath = path.join(dataRoot, "catalog_meta.json");
const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json");
const editorSettingsPath = path.join(dataRoot, "editor_settings.json");
const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json");
const requestAnalysisWorkerScriptPath = path.join(__dirname, "scripts", "request-analysis-worker.mjs");
const requestAnalysisRunState = {
child: null,
restartTimer: null,
};
const imagesCatalogPath = path.join(contentRoot, "images.json");
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
@ -384,6 +390,117 @@ function normalizeLauncherRequestTags(value) {
});
}
function normalizeLauncherRequestAnalysisState(value) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "processing" || normalized === "processed" || normalized === "needs_review" || normalized === "error") {
return normalized;
}
return "unprocessed";
}
function normalizeLauncherRequestAnalysisConfidence(value) {
const rawNumber = Number(value);
if (!Number.isFinite(rawNumber)) {
return null;
}
return Math.max(0, Math.min(1, rawNumber));
}
function normalizeLauncherRequestAnalysisStringList(value) {
return normalizeUniqueStringList(value, {
normalizeValue: (entry) => String(entry || "").replace(/\s+/g, " ").trim(),
dedupeKey: (entry) => String(entry || "").replace(/\s+/g, " ").trim().toLowerCase(),
});
}
function normalizeLauncherRequestAnalysisItem(item, index = 0) {
const source = item && typeof item === "object" && !Array.isArray(item)
? item
: null;
if (!source) {
return null;
}
const fallbackTitle = `Analyzed request ${index + 1}`;
const title = String(source.title || "").trim() || fallbackTitle;
const primaryCategory = String(source.primaryCategory || source.category || "").trim() || "Unsorted";
const tags = normalizeLauncherRequestAnalysisStringList(source.tags);
const parsedInterpretation = String(source.parsedInterpretation || source.summary || "").trim();
const implementationApproach = String(source.implementationApproach || source.implementationNotes || "").trim();
const statusRecommendationRaw = String(source.statusRecommendation || source.status || "").trim().toLowerCase();
const statusRecommendation = statusRecommendationRaw === "active"
|| statusRecommendationRaw === "duplicate"
|| statusRecommendationRaw === "blocked"
|| statusRecommendationRaw === "needs_review"
? statusRecommendationRaw
: "needs_review";
const problemTypeRaw = String(source.problemType || "").trim().toLowerCase();
const problemType = problemTypeRaw === "feature"
|| problemTypeRaw === "bug"
|| problemTypeRaw === "workflow"
|| problemTypeRaw === "performance"
|| problemTypeRaw === "ux"
|| problemTypeRaw === "content"
? problemTypeRaw
: "unknown";
if (!title || !parsedInterpretation || !implementationApproach) {
return null;
}
return {
title,
primaryCategory,
tags,
statusRecommendation,
parsedInterpretation,
implementationApproach,
affectedSystems: normalizeLauncherRequestAnalysisStringList(source.affectedSystems),
affectedFiles: normalizeLauncherRequestAnalysisStringList(source.affectedFiles),
problemType,
rawExcerpt: String(source.rawExcerpt || "").trim(),
confidence: normalizeLauncherRequestAnalysisConfidence(source.confidence),
notes: String(source.notes || "").trim(),
};
}
function normalizeLauncherRequestAnalysis(value) {
const source = value && typeof value === "object" && !Array.isArray(value)
? value
: null;
if (!source) {
return undefined;
}
const normalizedItems = Array.isArray(source.items)
? source.items
.map((item, index) => normalizeLauncherRequestAnalysisItem(item, index))
.filter(Boolean)
: [];
const averageConfidence = normalizedItems.length > 0
? normalizedItems.reduce((total, item) => total + (Number.isFinite(item.confidence) ? item.confidence : 0), 0) / normalizedItems.length
: null;
const confidence = normalizeLauncherRequestAnalysisConfidence(source.confidence);
const state = normalizeLauncherRequestAnalysisState(source.state);
const error = String(source.error || "").trim();
const model = String(source.model || "").trim();
const createdAt = String(source.createdAt || "").trim() || new Date().toISOString();
const updatedAt = String(source.updatedAt || "").trim() || createdAt;
const submissionId = String(source.submissionId || "").trim();
const sourceTextSnapshot = String(source.sourceTextSnapshot || source.sourceText || "").trim();
if (!model && !error && !submissionId && !sourceTextSnapshot && normalizedItems.length === 0 && state === "unprocessed") {
return undefined;
}
return {
state,
confidence: confidence ?? averageConfidence,
model: model || undefined,
createdAt,
updatedAt,
error: error || undefined,
submissionId: submissionId || undefined,
sourceTextSnapshot: sourceTextSnapshot || undefined,
itemCount: normalizedItems.length,
items: normalizedItems,
};
}
function buildPendingLauncherRequestTitle(text, fallback = "Pending request") {
const normalized = String(text || "").replace(/\s+/g, " ").trim();
if (!normalized) {
@ -421,11 +538,182 @@ function normalizeLauncherRequestEntry(entry, index = 0) {
sourceText,
summary,
implementationNotes: String(source.implementationNotes || "").trim(),
analysis: normalizeLauncherRequestAnalysis(source.analysis),
createdAt,
updatedAt,
};
}
function buildLauncherRequestFromAnalysisItem(sourceRequest, analysis, item, index = 0) {
const normalizedItem = normalizeLauncherRequestAnalysisItem(item, index);
if (!sourceRequest || !normalizedItem) {
return null;
}
const now = new Date().toISOString();
const sourceAnalysis = analysis && typeof analysis === "object" && !Array.isArray(analysis)
? analysis
: {};
return normalizeLauncherRequestEntry({
id: createLauncherRequestId(),
sourceSubmissionId: String(sourceRequest.id || "").trim() || undefined,
title: normalizedItem.title,
status: "active",
category: normalizedItem.primaryCategory,
tags: normalizedItem.tags,
sourceText: String(sourceRequest.sourceText || "").trim(),
summary: normalizedItem.parsedInterpretation,
implementationNotes: normalizedItem.implementationApproach,
analysis: {
state: "processed",
confidence: normalizedItem.confidence,
model: sourceAnalysis.model,
createdAt: String(sourceAnalysis.createdAt || "").trim() || now,
updatedAt: now,
submissionId: String(sourceRequest.id || "").trim(),
sourceTextSnapshot: String(sourceRequest.sourceText || "").trim(),
items: [normalizedItem],
},
createdAt: String(sourceRequest.createdAt || "").trim() || now,
updatedAt: now,
}, index);
}
function normalizeBooleanEnvFlag(value, fallback = false) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return fallback;
}
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function resolveRequestAnalyzerProvider() {
const configured = String(process.env.REQUEST_ANALYZER_PROVIDER || "").trim().toLowerCase();
if (configured === "deepseek" || configured === "openai-compatible") {
return configured;
}
return String(process.env.DEEPSEEK_API_KEY || "").trim() ? "deepseek" : "openai-compatible";
}
function isRequestAnalysisAutorunEnabled() {
return normalizeBooleanEnvFlag(process.env.REQUEST_ANALYZER_AUTORUN, false);
}
function isRequestAnalysisConfigured() {
if (!isRequestAnalysisAutorunEnabled()) {
return false;
}
const provider = resolveRequestAnalyzerProvider();
if (provider === "deepseek") {
return Boolean(String(process.env.DEEPSEEK_API_KEY || process.env.REQUEST_ANALYZER_API_KEY || "").trim());
}
return Boolean(String(process.env.REQUEST_ANALYZER_MODEL || "").trim());
}
function isLauncherRequestAutoQueueEligible(entry) {
const analysisState = String(entry?.analysis?.state || "").trim().toLowerCase();
return String(entry?.status || "").trim().toLowerCase() === "pending"
&& (!analysisState || analysisState === "unprocessed");
}
function getQueuedPendingLauncherRequestCount() {
try {
const payload = readLauncherRequestsPayload();
return payload.requests.filter((entry) => isLauncherRequestAutoQueueEligible(entry)).length;
} catch {
return 0;
}
}
function clearRequestAnalysisRestartTimer() {
if (requestAnalysisRunState.restartTimer) {
clearTimeout(requestAnalysisRunState.restartTimer);
requestAnalysisRunState.restartTimer = null;
}
}
function scheduleQueuedRequestAnalysis(reason = "queued-pending-requests", delayMs = 0) {
if (!isRequestAnalysisConfigured()) {
return false;
}
const queuedPendingCount = getQueuedPendingLauncherRequestCount();
if (queuedPendingCount <= 0) {
return false;
}
if (requestAnalysisRunState.child && !requestAnalysisRunState.child.killed) {
return false;
}
clearRequestAnalysisRestartTimer();
const runDelayMs = Math.max(0, Math.floor(Number(delayMs) || 0));
requestAnalysisRunState.restartTimer = setTimeout(() => {
requestAnalysisRunState.restartTimer = null;
launchQueuedRequestAnalysis(reason);
}, runDelayMs);
return true;
}
function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
if (!isRequestAnalysisConfigured()) {
return { launched: false, reason: "request-analysis-not-configured" };
}
const queuedPendingCount = getQueuedPendingLauncherRequestCount();
if (queuedPendingCount <= 0) {
return { launched: false, reason: "no-pending-requests" };
}
if (requestAnalysisRunState.child && !requestAnalysisRunState.child.killed) {
return { launched: false, reason: "request-analysis-already-running" };
}
const provider = resolveRequestAnalyzerProvider();
const args = [
requestAnalysisWorkerScriptPath,
"--provider",
provider,
"--api-base",
`http://127.0.0.1:${port}`,
"--limit",
String(Math.max(1, Math.floor(Number(process.env.REQUEST_ANALYZER_AUTORUN_LIMIT) || 5))),
];
const child = spawn(process.execPath, args, {
cwd: __dirname,
env: {
...process.env,
REQUEST_ANALYZER_PROVIDER: provider,
REQUEST_ANALYZER_API_BASE: `http://127.0.0.1:${port}`,
},
stdio: ["ignore", "pipe", "pipe"],
});
requestAnalysisRunState.child = child;
child.stdout?.on("data", (chunk) => {
const text = String(chunk || "").trim();
if (text) {
console.log(`[request-analysis] ${text}`);
}
});
child.stderr?.on("data", (chunk) => {
const text = String(chunk || "").trim();
if (text) {
console.error(`[request-analysis] ${text}`);
}
});
child.on("exit", (code, signal) => {
requestAnalysisRunState.child = null;
console.log(`[request-analysis] worker finished code=${String(code)} signal=${String(signal || "")} reason=${reason}`);
if (getQueuedPendingLauncherRequestCount() > 0) {
scheduleQueuedRequestAnalysis("drain-pending-requests", 1200);
}
});
child.on("error", (error) => {
requestAnalysisRunState.child = null;
console.error(`[request-analysis] worker launch failed: ${String(error)}`);
});
console.log(`[request-analysis] launched provider=${provider} queuedPending=${queuedPendingCount} reason=${reason}`);
return {
launched: true,
reason,
queuedPendingCount,
pid: child.pid,
};
}
function expandLegacyLauncherRequestEntry(entry, index = 0) {
const source = entry && typeof entry === "object" && !Array.isArray(entry)
? entry
@ -2377,8 +2665,9 @@ app.post("/api/launcher-requests", (req, res) => {
recordSaveEvent({
type: "launcher-request-add",
requestId: requestEntry.id,
textPreview: requestEntry.text.slice(0, 80),
textPreview: requestEntry.sourceText.slice(0, 80),
});
scheduleQueuedRequestAnalysis("launcher-request-add", 250);
res.status(201).json({
ok: true,
request: requestEntry,
@ -2408,6 +2697,7 @@ app.patch("/api/launcher-requests/:requestId", (req, res) => {
sourceText: req.body?.sourceText ?? existing.sourceText,
summary: req.body?.summary ?? existing.summary,
implementationNotes: req.body?.implementationNotes ?? existing.implementationNotes,
analysis: req.body?.analysis ?? existing.analysis,
updatedAt: new Date().toISOString(),
}, index);
const nextRequests = [...payload.requests];
@ -2449,7 +2739,7 @@ app.delete("/api/launcher-requests/:requestId", (req, res) => {
recordSaveEvent({
type: "launcher-request-delete",
requestId,
textPreview: existing.text.slice(0, 80),
textPreview: existing.sourceText.slice(0, 80),
});
res.json({
ok: true,
@ -2461,6 +2751,115 @@ app.delete("/api/launcher-requests/:requestId", (req, res) => {
}
});
app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => {
const requestId = String(req.params.requestId || "").trim();
const action = String(req.body?.action || "").trim().toLowerCase();
try {
const payload = readLauncherRequestsPayload();
const index = payload.requests.findIndex((entry) => entry.id === requestId);
if (index < 0) {
res.status(404).json({ error: "Request not found." });
return;
}
const existing = payload.requests[index];
const now = new Date().toISOString();
const submittedAnalysis = normalizeLauncherRequestAnalysis({
...(existing.analysis && typeof existing.analysis === "object" && !Array.isArray(existing.analysis) ? existing.analysis : {}),
...(req.body?.analysis && typeof req.body.analysis === "object" && !Array.isArray(req.body.analysis) ? req.body.analysis : {}),
model: req.body?.model ?? req.body?.analysis?.model ?? existing.analysis?.model,
submissionId: requestId,
sourceTextSnapshot: existing.sourceText,
createdAt: existing.analysis?.createdAt || now,
updatedAt: now,
state: action === "promote"
? "processed"
: (action === "error" ? "error" : "needs_review"),
error: action === "error"
? (req.body?.error || req.body?.analysis?.error || "Unknown analysis error.")
: (req.body?.analysis?.error || ""),
});
if (action === "promote") {
const analyzedItems = Array.isArray(submittedAnalysis?.items) ? submittedAnalysis.items : [];
const promoted = analyzedItems
.filter((item) => item && item.statusRecommendation === "active")
.map((item, itemIndex) => buildLauncherRequestFromAnalysisItem(existing, submittedAnalysis, item, itemIndex))
.filter(Boolean);
if (promoted.length === 0) {
res.status(400).json({ error: "Promotion requires at least one active analyzed item." });
return;
}
const nextRequests = [
...payload.requests.slice(0, index),
...promoted,
...payload.requests.slice(index + 1),
];
writeLauncherRequestsPayload({
schemaVersion: payload.schemaVersion,
requests: nextRequests,
});
recordSaveEvent({
type: "launcher-request-promote",
requestId,
itemCount: promoted.length,
model: submittedAnalysis?.model || "",
});
res.json({
ok: true,
promoted,
requests: nextRequests,
});
return;
}
if (action !== "review" && action !== "error") {
res.status(400).json({ error: "Unsupported analysis action." });
return;
}
const reviewedRequest = normalizeLauncherRequestEntry({
...existing,
analysis: submittedAnalysis,
updatedAt: now,
}, index);
if (!reviewedRequest) {
res.status(500).json({ error: "Failed to normalize analyzed request." });
return;
}
const nextRequests = [...payload.requests];
nextRequests[index] = reviewedRequest;
writeLauncherRequestsPayload({
schemaVersion: payload.schemaVersion,
requests: nextRequests,
});
recordSaveEvent({
type: action === "error" ? "launcher-request-analysis-error" : "launcher-request-review",
requestId,
model: reviewedRequest.analysis?.model || "",
itemCount: reviewedRequest.analysis?.itemCount || 0,
});
res.json({
ok: true,
request: reviewedRequest,
requests: nextRequests,
});
} catch (err) {
res.status(500).json({ error: `Failed to process launcher request analysis: ${String(err)}` });
}
});
app.post("/api/launcher-requests/process-pending", (_req, res) => {
try {
const result = launchQueuedRequestAnalysis("manual-api-trigger");
res.json({
ok: true,
...result,
autorunEnabled: isRequestAnalysisAutorunEnabled(),
configured: isRequestAnalysisConfigured(),
queuedPendingCount: getQueuedPendingLauncherRequestCount(),
});
} catch (err) {
res.status(500).json({ error: `Failed to trigger launcher request analysis: ${String(err)}` });
}
});
app.get("/api/world-default", (_req, res) => {
try {
const indexPayload = readWorldIndexPayload();
@ -3088,5 +3487,9 @@ app.listen(port, host, () => {
if (!fs.existsSync(contentRoot)) {
console.warn(`[paths] content root does not exist yet. Create: ${contentRoot}`);
}
if (isRequestAnalysisAutorunEnabled()) {
console.log(`[request-analysis] autorun enabled provider=${resolveRequestAnalyzerProvider()} configured=${isRequestAnalysisConfigured() ? "yes" : "no"}`);
scheduleQueuedRequestAnalysis("server-startup", 1200);
}
});