Run request analysis on demand on the VPS
This commit is contained in:
parent
9f9b13aa01
commit
d899e902a0
21 changed files with 2485 additions and 4 deletions
407
server.js
407
server.js
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue