1223 lines
48 KiB
JavaScript
1223 lines
48 KiB
JavaScript
import fs from "fs/promises";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
const kbRoot = path.join(repoRoot, "docs", "kb");
|
|
const requestTagCatalogPath = path.join(kbRoot, "tags.json");
|
|
const terminologyCatalogPath = path.join(kbRoot, "terminology.json");
|
|
const moduleIndexPath = path.join(kbRoot, "modules.json");
|
|
const editorCapabilitiesPath = path.join(kbRoot, "editor-capabilities.md");
|
|
const queueWorkflowPath = path.join(kbRoot, "queue-workflow.md");
|
|
const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash";
|
|
const DEFAULT_PROVIDER = process.env.REQUEST_ANALYZER_PROVIDER
|
|
|| (String(process.env.DEEPSEEK_API_KEY || "").trim() ? "deepseek" : "openai-compatible");
|
|
|
|
const DEFAULTS = {
|
|
apiBase: process.env.REQUEST_ANALYZER_API_BASE || `http://127.0.0.1:${process.env.PORT || "5180"}`,
|
|
provider: DEFAULT_PROVIDER,
|
|
modelBaseUrl: process.env.REQUEST_ANALYZER_MODEL_BASE_URL || "",
|
|
model: process.env.REQUEST_ANALYZER_MODEL || "",
|
|
apiKey: process.env.REQUEST_ANALYZER_API_KEY || process.env.DEEPSEEK_API_KEY || "",
|
|
adminPassword: process.env.REQUEST_ANALYZER_ADMIN_PASSWORD || process.env.LAUNCHER_ADMIN_PASSWORD || "",
|
|
limit: Number(process.env.REQUEST_ANALYZER_LIMIT || 5),
|
|
promoteThreshold: Number(process.env.REQUEST_ANALYZER_PROMOTE_THRESHOLD || 0.85),
|
|
maxTokens: Math.max(512, Number(process.env.REQUEST_ANALYZER_MAX_TOKENS || 4000)),
|
|
thinking: String(process.env.REQUEST_ANALYZER_THINKING || "disabled").trim().toLowerCase() === "enabled"
|
|
? "enabled"
|
|
: "disabled",
|
|
poll: false,
|
|
intervalMs: Number(process.env.REQUEST_ANALYZER_INTERVAL_MS || 30000),
|
|
dryRun: false,
|
|
requestId: "",
|
|
};
|
|
|
|
function printHelp() {
|
|
console.log(`Worldshaper request analysis worker
|
|
|
|
Usage:
|
|
npm run analyze:requests -- [options]
|
|
|
|
Options:
|
|
--provider <name> Model provider: deepseek or openai-compatible.
|
|
--request-id <id> Analyze one pending request only.
|
|
--limit <n> Maximum pending requests to process in one pass.
|
|
--poll Keep polling for new pending requests.
|
|
--interval-ms <ms> Poll interval when --poll is enabled.
|
|
--dry-run Do not write anything back to the API.
|
|
--api-base <url> Worldshaper API base URL.
|
|
--model-base-url <url> Model API base URL. Defaults to DeepSeek when provider=deepseek.
|
|
--model <id> Model id to use. Defaults to ${DEFAULT_DEEPSEEK_MODEL} for DeepSeek.
|
|
--api-key <key> API key for the model endpoint.
|
|
--max-tokens <n> Max output tokens for the model response.
|
|
--thinking <mode> DeepSeek thinking mode: enabled or disabled.
|
|
--promote-threshold <n> Minimum per-item confidence for auto-promotion.
|
|
--help Show this message.
|
|
|
|
Environment variables:
|
|
REQUEST_ANALYZER_API_BASE
|
|
REQUEST_ANALYZER_PROVIDER
|
|
REQUEST_ANALYZER_MODEL_BASE_URL
|
|
REQUEST_ANALYZER_MODEL
|
|
REQUEST_ANALYZER_API_KEY
|
|
REQUEST_ANALYZER_ADMIN_PASSWORD
|
|
REQUEST_ANALYZER_MAX_TOKENS
|
|
REQUEST_ANALYZER_THINKING
|
|
REQUEST_ANALYZER_LIMIT
|
|
REQUEST_ANALYZER_INTERVAL_MS
|
|
REQUEST_ANALYZER_PROMOTE_THRESHOLD
|
|
DEEPSEEK_API_KEY
|
|
LAUNCHER_ADMIN_PASSWORD
|
|
|
|
Notes:
|
|
- DeepSeek uses ${DEFAULT_DEEPSEEK_BASE_URL}/chat/completions.
|
|
- DeepSeek JSON mode is enabled automatically for this worker.
|
|
- Run the Worldshaper API server first so the worker can read and patch requests.
|
|
`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const config = { ...DEFAULTS };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = String(argv[index] || "").trim();
|
|
if (!arg) {
|
|
continue;
|
|
}
|
|
if (arg === "--help" || arg === "-h") {
|
|
config.help = true;
|
|
continue;
|
|
}
|
|
if (arg === "--provider") {
|
|
config.provider = String(argv[index + 1] || "").trim().toLowerCase() || config.provider;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--poll") {
|
|
config.poll = true;
|
|
continue;
|
|
}
|
|
if (arg === "--dry-run") {
|
|
config.dryRun = true;
|
|
continue;
|
|
}
|
|
if (arg === "--request-id") {
|
|
config.requestId = String(argv[index + 1] || "").trim();
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--limit") {
|
|
config.limit = Math.max(1, Math.floor(Number(argv[index + 1]) || config.limit));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--interval-ms") {
|
|
config.intervalMs = Math.max(1000, Math.floor(Number(argv[index + 1]) || config.intervalMs));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--api-base") {
|
|
config.apiBase = String(argv[index + 1] || "").trim() || config.apiBase;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--model-base-url") {
|
|
config.modelBaseUrl = String(argv[index + 1] || "").trim() || config.modelBaseUrl;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--model") {
|
|
config.model = String(argv[index + 1] || "").trim() || config.model;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--api-key") {
|
|
config.apiKey = String(argv[index + 1] || "").trim() || config.apiKey;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--max-tokens") {
|
|
config.maxTokens = Math.max(512, Math.floor(Number(argv[index + 1]) || config.maxTokens));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--thinking") {
|
|
const thinkingValue = String(argv[index + 1] || "").trim().toLowerCase();
|
|
config.thinking = thinkingValue === "enabled" ? "enabled" : "disabled";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--promote-threshold") {
|
|
const parsed = Number(argv[index + 1]);
|
|
if (Number.isFinite(parsed)) {
|
|
config.promoteThreshold = Math.max(0, Math.min(1, parsed));
|
|
}
|
|
index += 1;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function finalizeConfig(config) {
|
|
const provider = String(config.provider || "").trim().toLowerCase();
|
|
const nextConfig = {
|
|
...config,
|
|
provider: provider === "deepseek" ? "deepseek" : "openai-compatible",
|
|
};
|
|
if (!String(nextConfig.modelBaseUrl || "").trim()) {
|
|
nextConfig.modelBaseUrl = nextConfig.provider === "deepseek"
|
|
? DEFAULT_DEEPSEEK_BASE_URL
|
|
: "http://127.0.0.1:1234/v1";
|
|
}
|
|
if (!String(nextConfig.model || "").trim() && nextConfig.provider === "deepseek") {
|
|
nextConfig.model = DEFAULT_DEEPSEEK_MODEL;
|
|
}
|
|
return nextConfig;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function withTrailingSlash(value) {
|
|
return String(value || "").endsWith("/") ? String(value || "") : `${String(value || "")}/`;
|
|
}
|
|
|
|
function buildUrl(base, pathname) {
|
|
return new URL(String(pathname || "").replace(/^\//, ""), withTrailingSlash(base)).toString();
|
|
}
|
|
|
|
async function fetchJson(url, init = {}) {
|
|
const response = await fetch(url, init);
|
|
if (!response.ok) {
|
|
const responseText = await response.text().catch(() => "");
|
|
throw new Error(`${response.status} ${response.statusText}${responseText ? `: ${responseText.slice(0, 240)}` : ""}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function buildAdminHeaders(config, baseHeaders = {}) {
|
|
const nextHeaders = {
|
|
...baseHeaders,
|
|
};
|
|
const adminPassword = String(config?.adminPassword || "").trim();
|
|
if (adminPassword) {
|
|
nextHeaders["x-worldshaper-admin-password"] = adminPassword;
|
|
}
|
|
return nextHeaders;
|
|
}
|
|
|
|
function tokenize(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9_/-]+/i)
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length >= 2);
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
const seen = new Set();
|
|
const next = [];
|
|
for (const value of Array.isArray(values) ? values : []) {
|
|
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
const key = normalized.toLowerCase();
|
|
if (!normalized || seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
next.push(normalized);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function normalizeRequestTagLookupValue(value) {
|
|
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
}
|
|
|
|
async function loadRequestTagCatalog() {
|
|
const payload = JSON.parse(await fs.readFile(requestTagCatalogPath, "utf8"));
|
|
const tags = Array.isArray(payload?.tags)
|
|
? payload.tags
|
|
.map((entry) => ({
|
|
id: String(entry?.id || "").trim(),
|
|
label: String(entry?.label || "").trim(),
|
|
aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [],
|
|
}))
|
|
.filter((entry) => entry.id && entry.label)
|
|
: [];
|
|
if (tags.length === 0) {
|
|
throw new Error("Request tag catalog did not contain any valid tags.");
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
async function loadTerminologyCatalog() {
|
|
const payload = JSON.parse(await fs.readFile(terminologyCatalogPath, "utf8"));
|
|
const terms = Array.isArray(payload?.terms)
|
|
? payload.terms
|
|
.map((entry) => ({
|
|
canonical: String(entry?.canonical || "").trim(),
|
|
aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [],
|
|
tags: Array.isArray(entry?.tags) ? entry.tags.map((tag) => String(tag || "").trim()).filter(Boolean) : [],
|
|
systemIds: Array.isArray(entry?.systemIds) ? entry.systemIds.map((systemId) => String(systemId || "").trim()).filter(Boolean) : [],
|
|
}))
|
|
.filter((entry) => entry.canonical)
|
|
: [];
|
|
return {
|
|
schemaVersion: Number(payload?.schemaVersion) || 1,
|
|
terms,
|
|
};
|
|
}
|
|
|
|
async function loadModuleIndex() {
|
|
const payload = JSON.parse(await fs.readFile(moduleIndexPath, "utf8"));
|
|
const modules = Array.isArray(payload?.modules)
|
|
? payload.modules
|
|
.map((entry) => ({
|
|
id: String(entry?.id || "").trim(),
|
|
name: String(entry?.name || "").trim(),
|
|
docPath: String(entry?.docPath || "").trim(),
|
|
tags: Array.isArray(entry?.tags) ? entry.tags.map((tag) => String(tag || "").trim()).filter(Boolean) : [],
|
|
systemIds: Array.isArray(entry?.systemIds) ? entry.systemIds.map((systemId) => String(systemId || "").trim()).filter(Boolean) : [],
|
|
aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [],
|
|
priority: String(entry?.priority || "medium").trim().toLowerCase(),
|
|
}))
|
|
.filter((entry) => entry.id && entry.name && entry.docPath)
|
|
: [];
|
|
return {
|
|
schemaVersion: Number(payload?.schemaVersion) || 1,
|
|
modules,
|
|
};
|
|
}
|
|
|
|
function buildRequestTagLookup(tagDefinitions) {
|
|
return new Map(
|
|
(Array.isArray(tagDefinitions) ? tagDefinitions : []).flatMap((entry) => [
|
|
[normalizeRequestTagLookupValue(entry.label), entry.label],
|
|
...(Array.isArray(entry.aliases) ? entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]) : []),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function normalizeRequestTags(values, tagLookup, fallback = []) {
|
|
const normalized = uniqueStrings(Array.isArray(values) ? values : [])
|
|
.map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "")
|
|
.filter(Boolean);
|
|
if (normalized.length > 0) {
|
|
return uniqueStrings(normalized);
|
|
}
|
|
return uniqueStrings(Array.isArray(fallback) ? fallback : [])
|
|
.map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "")
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function clampConfidence(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return null;
|
|
}
|
|
return Math.max(0, Math.min(1, parsed));
|
|
}
|
|
|
|
function normalizeItemStatusRecommendation(value) {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
if (normalized === "active" || normalized === "duplicate" || normalized === "blocked" || normalized === "needs_review") {
|
|
return normalized;
|
|
}
|
|
return "needs_review";
|
|
}
|
|
|
|
function normalizeProblemType(value) {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
if (normalized === "feature" || normalized === "bug" || normalized === "workflow" || normalized === "performance" || normalized === "ux" || normalized === "content") {
|
|
return normalized;
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
function buildFallbackTitle(text, fallback = "Pending request") {
|
|
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
|
if (!normalized) {
|
|
return fallback;
|
|
}
|
|
const firstSentence = normalized.split(/[\r\n.!?]+/).map((entry) => entry.trim()).find(Boolean) || normalized;
|
|
return firstSentence.length > 72 ? `${firstSentence.slice(0, 69).trim()}...` : firstSentence;
|
|
}
|
|
|
|
function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSystems = [], kb = null) {
|
|
const source = rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
|
|
? rawItem
|
|
: null;
|
|
if (!source) {
|
|
return null;
|
|
}
|
|
const fallbackText = String(source.rawExcerpt || request?.sourceText || "").trim();
|
|
const title = String(source.title || "").trim() || buildFallbackTitle(fallbackText, `Analyzed request ${index + 1}`);
|
|
const parsedInterpretation = String(source.parsedInterpretation || source.summary || "").trim();
|
|
const implementationApproach = String(source.implementationApproach || source.implementationNotes || "").trim();
|
|
if (!title || !parsedInterpretation || !implementationApproach) {
|
|
return null;
|
|
}
|
|
const primaryCategory = String(source.primaryCategory || source.category || "").trim()
|
|
|| String(relevantSystems[0]?.name || "Unsorted");
|
|
const affectedSystems = uniqueStrings(source.affectedSystems);
|
|
const defaultTags = [
|
|
primaryCategory,
|
|
...affectedSystems,
|
|
];
|
|
const tags = normalizeRequestTags(
|
|
Array.isArray(source.tags) ? source.tags : defaultTags,
|
|
kb?.requestTagLookup || new Map(),
|
|
defaultTags,
|
|
);
|
|
const reviewRationale = String(source.reviewRationale || source.reviewReason || source.notes || "").trim();
|
|
const reviewOptions = uniqueStrings(source.reviewOptions);
|
|
return {
|
|
title,
|
|
primaryCategory,
|
|
tags: tags.length > 0 ? tags : ["General"],
|
|
statusRecommendation: normalizeItemStatusRecommendation(source.statusRecommendation || source.status),
|
|
parsedInterpretation,
|
|
implementationApproach,
|
|
affectedSystems,
|
|
affectedFiles: uniqueStrings(source.affectedFiles),
|
|
problemType: normalizeProblemType(source.problemType),
|
|
rawExcerpt: String(source.rawExcerpt || "").trim(),
|
|
confidence: clampConfidence(source.confidence),
|
|
reviewRationale,
|
|
reviewOptions,
|
|
notes: String(source.notes || "").trim(),
|
|
};
|
|
}
|
|
|
|
function normalizeAnalysisResult(rawResult, request, relevantSystems, kb = null) {
|
|
const source = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult)
|
|
? rawResult
|
|
: (Array.isArray(rawResult) ? { items: rawResult } : null);
|
|
if (!source) {
|
|
throw new Error("Model response was not a JSON object.");
|
|
}
|
|
const items = (Array.isArray(source.items) ? source.items : [])
|
|
.map((item, index) => normalizeAnalysisItem(item, index, request, relevantSystems, kb))
|
|
.filter(Boolean);
|
|
if (items.length === 0) {
|
|
throw new Error("Model response did not contain any valid request items.");
|
|
}
|
|
const finiteConfidences = items.map((item) => item.confidence).filter((value) => Number.isFinite(value));
|
|
const averageConfidence = finiteConfidences.length > 0
|
|
? finiteConfidences.reduce((total, value) => total + value, 0) / finiteConfidences.length
|
|
: null;
|
|
const minimumConfidence = finiteConfidences.length > 0
|
|
? Math.min(...finiteConfidences)
|
|
: null;
|
|
return {
|
|
submissionId: String(source.submissionId || request.id || "").trim() || request.id,
|
|
sourceText: String(source.sourceText || request.sourceText || "").trim() || request.sourceText,
|
|
confidence: clampConfidence(source.confidence) ?? averageConfidence,
|
|
minimumConfidence,
|
|
items,
|
|
};
|
|
}
|
|
|
|
async function loadKnowledgeBase() {
|
|
const systemsIndexPath = path.join(kbRoot, "systems.json");
|
|
const requestSchemaPath = path.join(kbRoot, "request-analysis-schema.json");
|
|
const systemsIndex = JSON.parse(await fs.readFile(systemsIndexPath, "utf8"));
|
|
const requestSchema = JSON.parse(await fs.readFile(requestSchemaPath, "utf8"));
|
|
const requestTagDefinitions = await loadRequestTagCatalog();
|
|
const terminology = await loadTerminologyCatalog();
|
|
const moduleIndex = await loadModuleIndex();
|
|
const docsById = new Map();
|
|
const moduleDocsById = new Map();
|
|
for (const system of Array.isArray(systemsIndex.systems) ? systemsIndex.systems : []) {
|
|
const docPath = path.join(repoRoot, String(system.docPath || "").replace(/\//g, path.sep));
|
|
const docText = await fs.readFile(docPath, "utf8").catch(() => "");
|
|
docsById.set(String(system.id || ""), docText);
|
|
}
|
|
for (const moduleEntry of Array.isArray(moduleIndex.modules) ? moduleIndex.modules : []) {
|
|
const docPath = path.join(repoRoot, String(moduleEntry.docPath || "").replace(/\//g, path.sep));
|
|
const docText = await fs.readFile(docPath, "utf8").catch(() => "");
|
|
moduleDocsById.set(String(moduleEntry.id || ""), docText);
|
|
}
|
|
return {
|
|
systemsIndex,
|
|
requestSchema,
|
|
requestTagDefinitions,
|
|
requestTagLookup: buildRequestTagLookup(requestTagDefinitions),
|
|
terminology,
|
|
moduleIndex,
|
|
docsById,
|
|
moduleDocsById,
|
|
editorCapabilitiesText: await fs.readFile(editorCapabilitiesPath, "utf8").catch(() => ""),
|
|
queueWorkflowText: await fs.readFile(queueWorkflowPath, "utf8").catch(() => ""),
|
|
};
|
|
}
|
|
|
|
function normalizeAmbiguityLevel(value) {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
if (normalized === "low" || normalized === "medium" || normalized === "high") {
|
|
return normalized;
|
|
}
|
|
return "medium";
|
|
}
|
|
|
|
function normalizeSystemIds(values, kb, fallback = []) {
|
|
const knownIds = new Set((Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).map((system) => String(system.id || "").trim()));
|
|
const requested = uniqueStrings(Array.isArray(values) ? values : [])
|
|
.map((entry) => String(entry || "").trim())
|
|
.filter((entry) => knownIds.has(entry));
|
|
if (requested.length > 0) {
|
|
return requested;
|
|
}
|
|
return uniqueStrings(Array.isArray(fallback) ? fallback : [])
|
|
.map((entry) => String(entry || "").trim())
|
|
.filter((entry) => knownIds.has(entry));
|
|
}
|
|
|
|
function normalizeModuleIds(values, kb, fallback = []) {
|
|
const knownIds = new Set((Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []).map((moduleEntry) => String(moduleEntry.id || "").trim()));
|
|
const requested = uniqueStrings(Array.isArray(values) ? values : [])
|
|
.map((entry) => String(entry || "").trim())
|
|
.filter((entry) => knownIds.has(entry));
|
|
if (requested.length > 0) {
|
|
return requested;
|
|
}
|
|
return uniqueStrings(Array.isArray(fallback) ? fallback : [])
|
|
.map((entry) => String(entry || "").trim())
|
|
.filter((entry) => knownIds.has(entry));
|
|
}
|
|
|
|
function buildSystemSearchText(system, docText) {
|
|
const parts = [
|
|
system?.id,
|
|
system?.name,
|
|
...(Array.isArray(system?.aliases) ? system.aliases : []),
|
|
...(Array.isArray(system?.tags) ? system.tags : []),
|
|
...(Array.isArray(system?.uiSurfaces) ? system.uiSurfaces : []),
|
|
...(Array.isArray(system?.keyFiles) ? system.keyFiles : []),
|
|
...(Array.isArray(system?.apiEndpoints) ? system.apiEndpoints : []),
|
|
docText,
|
|
];
|
|
return parts.join(" ").toLowerCase();
|
|
}
|
|
|
|
function buildModuleSearchText(moduleEntry, docText) {
|
|
const parts = [
|
|
moduleEntry?.id,
|
|
moduleEntry?.name,
|
|
...(Array.isArray(moduleEntry?.aliases) ? moduleEntry.aliases : []),
|
|
...(Array.isArray(moduleEntry?.tags) ? moduleEntry.tags : []),
|
|
...(Array.isArray(moduleEntry?.systemIds) ? moduleEntry.systemIds : []),
|
|
docText,
|
|
];
|
|
return parts.join(" ").toLowerCase();
|
|
}
|
|
|
|
function collectTerminologyMatches(kb, requestText) {
|
|
const requestLower = String(requestText || "").toLowerCase();
|
|
return (Array.isArray(kb?.terminology?.terms) ? kb.terminology.terms : [])
|
|
.map((term) => {
|
|
const phrases = [term.canonical, ...(Array.isArray(term.aliases) ? term.aliases : [])]
|
|
.map((entry) => String(entry || "").trim().toLowerCase())
|
|
.filter(Boolean);
|
|
const matched = phrases.filter((phrase) => requestLower.includes(phrase));
|
|
return matched.length > 0
|
|
? {
|
|
term,
|
|
matched,
|
|
}
|
|
: null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function pickRelevantSystems(kb, requestText, limit = 4) {
|
|
const systems = Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : [];
|
|
const queryTokens = tokenize(requestText);
|
|
const requestLower = String(requestText || "").toLowerCase();
|
|
const terminologyMatches = collectTerminologyMatches(kb, requestText);
|
|
const terminologyBoostBySystemId = new Map();
|
|
terminologyMatches.forEach(({ term, matched }) => {
|
|
(Array.isArray(term.systemIds) ? term.systemIds : []).forEach((systemId) => {
|
|
terminologyBoostBySystemId.set(systemId, (terminologyBoostBySystemId.get(systemId) || 0) + (matched.length * 4));
|
|
});
|
|
});
|
|
const ranked = systems
|
|
.map((system) => {
|
|
const docText = kb.docsById.get(String(system.id || "")) || "";
|
|
const corpus = buildSystemSearchText(system, docText);
|
|
let score = terminologyBoostBySystemId.get(String(system.id || "").trim()) || 0;
|
|
for (const token of queryTokens) {
|
|
if (corpus.includes(token)) {
|
|
score += 2;
|
|
}
|
|
}
|
|
for (const alias of Array.isArray(system.aliases) ? system.aliases : []) {
|
|
const normalizedAlias = String(alias || "").trim().toLowerCase();
|
|
if (normalizedAlias && requestLower.includes(normalizedAlias)) {
|
|
score += 5;
|
|
}
|
|
}
|
|
if (String(system.priorityForRequestTriage || "").trim().toLowerCase() === "high") {
|
|
score += 1;
|
|
}
|
|
return {
|
|
system,
|
|
docText,
|
|
score,
|
|
};
|
|
})
|
|
.sort((left, right) => right.score - left.score || String(left.system?.name || "").localeCompare(String(right.system?.name || "")));
|
|
const positive = ranked.filter((entry) => entry.score > 0).slice(0, limit);
|
|
if (positive.length > 0) {
|
|
return positive;
|
|
}
|
|
return ranked.slice(0, limit);
|
|
}
|
|
|
|
function pickRelevantModules(kb, requestText, routing, limit = 4) {
|
|
const modules = Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : [];
|
|
const queryTokens = tokenize(requestText);
|
|
const requestLower = String(requestText || "").toLowerCase();
|
|
const suggestedTags = new Set(Array.isArray(routing?.suggestedTags) ? routing.suggestedTags : []);
|
|
const suggestedSystems = new Set(Array.isArray(routing?.suggestedSystems) ? routing.suggestedSystems : []);
|
|
const ranked = modules
|
|
.map((moduleEntry) => {
|
|
const docText = kb.moduleDocsById.get(String(moduleEntry.id || "")) || "";
|
|
const corpus = buildModuleSearchText(moduleEntry, docText);
|
|
let score = 0;
|
|
for (const token of queryTokens) {
|
|
if (corpus.includes(token)) {
|
|
score += 2;
|
|
}
|
|
}
|
|
for (const alias of Array.isArray(moduleEntry.aliases) ? moduleEntry.aliases : []) {
|
|
const normalizedAlias = String(alias || "").trim().toLowerCase();
|
|
if (normalizedAlias && requestLower.includes(normalizedAlias)) {
|
|
score += 5;
|
|
}
|
|
}
|
|
for (const tag of Array.isArray(moduleEntry.tags) ? moduleEntry.tags : []) {
|
|
if (suggestedTags.has(tag)) {
|
|
score += 4;
|
|
}
|
|
}
|
|
for (const systemId of Array.isArray(moduleEntry.systemIds) ? moduleEntry.systemIds : []) {
|
|
if (suggestedSystems.has(systemId)) {
|
|
score += 4;
|
|
}
|
|
}
|
|
if (String(moduleEntry.priority || "medium") === "high") {
|
|
score += 1;
|
|
}
|
|
return {
|
|
moduleEntry,
|
|
docText,
|
|
score,
|
|
};
|
|
})
|
|
.sort((left, right) => right.score - left.score || String(left.moduleEntry?.name || "").localeCompare(String(right.moduleEntry?.name || "")));
|
|
const positive = ranked.filter((entry) => entry.score > 0).slice(0, limit);
|
|
if (positive.length > 0) {
|
|
return positive;
|
|
}
|
|
return ranked.slice(0, limit);
|
|
}
|
|
|
|
function deriveHeuristicRouting(kb, requestText) {
|
|
const terminologyMatches = collectTerminologyMatches(kb, requestText);
|
|
const relevantSystems = pickRelevantSystems(kb, requestText, 4);
|
|
const matchedTerms = uniqueStrings(
|
|
terminologyMatches.flatMap(({ matched }) => matched),
|
|
);
|
|
const suggestedTags = uniqueStrings([
|
|
...terminologyMatches.flatMap(({ term }) => Array.isArray(term.tags) ? term.tags : []),
|
|
...relevantSystems.flatMap(({ system }) => Array.isArray(system.tags) ? system.tags : []),
|
|
]);
|
|
const suggestedSystems = uniqueStrings([
|
|
...terminologyMatches.flatMap(({ term }) => Array.isArray(term.systemIds) ? term.systemIds : []),
|
|
...relevantSystems.map(({ system }) => String(system.id || "").trim()),
|
|
]);
|
|
const suggestedModules = pickRelevantModules(kb, requestText, {
|
|
suggestedTags,
|
|
suggestedSystems,
|
|
}, 4).map(({ moduleEntry }) => String(moduleEntry.id || "").trim());
|
|
const normalizedText = String(requestText || "").replace(/\s+/g, " ").trim();
|
|
const tokenCount = tokenize(normalizedText).length;
|
|
const genericPatterns = [
|
|
/\b(make|improve|fix|upgrade)\b.+\b(game|editor|worldshaper|site|launcher|it)\b/i,
|
|
/^\s*(better|more|faster|cooler|good)\s*!?\s*$/i,
|
|
/^\s*add\s+[a-z0-9_-]+\s*!?\s*$/i,
|
|
];
|
|
const isBroad = genericPatterns.some((pattern) => pattern.test(normalizedText)) || tokenCount <= 4;
|
|
const ambiguity = isBroad
|
|
? "high"
|
|
: (matchedTerms.length === 0 || suggestedSystems.length > 3 ? "medium" : "low");
|
|
const displaySystemNames = suggestedSystems
|
|
.map((systemId) => (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).find((system) => String(system.id || "").trim() === systemId)?.name || systemId)
|
|
.slice(0, 4);
|
|
const possibleDirections = uniqueStrings([
|
|
suggestedTags.includes("Graphics Painter") ? "Treat it as an asset-editing request inside the Graphics Painter." : "",
|
|
suggestedTags.includes("Tiling") ? "Treat it as map-grid painting or placement behavior on the tile canvas." : "",
|
|
suggestedTags.includes("Content") ? "Treat it as a new asset or content-record request that needs catalog support." : "",
|
|
suggestedTags.includes("Rendering") || suggestedTags.includes("Worlds") ? "Treat it as a runtime-facing request that may need renderer or world metadata support." : "",
|
|
suggestedTags.includes("Chunks") ? "Treat it as a chunk or world-scale placement workflow." : "",
|
|
]).slice(0, 4);
|
|
return {
|
|
summary: displaySystemNames.length > 0
|
|
? `Likely touches ${displaySystemNames.join(", ")}.`
|
|
: "Broad request with no single obvious subsystem match yet.",
|
|
ambiguity,
|
|
matchedTerms,
|
|
suggestedTags: suggestedTags.length > 0 ? suggestedTags.slice(0, 6) : ["General"],
|
|
suggestedSystems: suggestedSystems.slice(0, 4),
|
|
suggestedModules,
|
|
rationale: matchedTerms.length > 0
|
|
? `Matched terminology: ${matchedTerms.join(", ")}.`
|
|
: "Used broad alias and system matching because the submission did not name an exact editor surface.",
|
|
possibleDirections: possibleDirections.length > 0
|
|
? possibleDirections
|
|
: ["Clarify which editor surface or runtime behavior the request should target first."],
|
|
};
|
|
}
|
|
|
|
function normalizeRoutingResult(rawResult, kb, requestText, fallbackRouting) {
|
|
const source = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult)
|
|
? rawResult
|
|
: {};
|
|
const suggestedTags = normalizeRequestTags(
|
|
Array.isArray(source.suggestedTags) ? source.suggestedTags : source.tags,
|
|
kb?.requestTagLookup || new Map(),
|
|
fallbackRouting?.suggestedTags || ["General"],
|
|
);
|
|
const suggestedSystems = normalizeSystemIds(
|
|
Array.isArray(source.suggestedSystems) ? source.suggestedSystems : source.suggestedSystemIds,
|
|
kb,
|
|
fallbackRouting?.suggestedSystems || [],
|
|
);
|
|
const suggestedModules = normalizeModuleIds(
|
|
Array.isArray(source.suggestedModules) ? source.suggestedModules : source.suggestedModuleIds,
|
|
kb,
|
|
fallbackRouting?.suggestedModules || [],
|
|
);
|
|
const matchedTerms = uniqueStrings(source.matchedTerms || source.terms || fallbackRouting?.matchedTerms || []);
|
|
const possibleDirections = uniqueStrings(source.possibleDirections || fallbackRouting?.possibleDirections || []);
|
|
const summary = String(source.summary || source.routingSummary || fallbackRouting?.summary || "").trim()
|
|
|| `Likely touches ${suggestedTags.join(", ")}.`;
|
|
const rationale = String(source.rationale || source.reviewRationale || fallbackRouting?.rationale || "").trim()
|
|
|| "Routing used terminology and KB alias matching.";
|
|
const ambiguity = normalizeAmbiguityLevel(source.ambiguity || fallbackRouting?.ambiguity || "");
|
|
const kbSections = uniqueStrings([
|
|
...suggestedSystems.map((systemId) => {
|
|
const system = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).find((entry) => String(entry.id || "").trim() === systemId);
|
|
return String(system?.docPath || "").trim();
|
|
}),
|
|
...suggestedModules.map((moduleId) => {
|
|
const moduleEntry = (Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []).find((entry) => String(entry.id || "").trim() === moduleId);
|
|
return String(moduleEntry?.docPath || "").trim();
|
|
}),
|
|
].filter(Boolean));
|
|
return {
|
|
summary,
|
|
ambiguity,
|
|
matchedTerms,
|
|
suggestedTags: suggestedTags.length > 0 ? suggestedTags : ["General"],
|
|
suggestedSystems,
|
|
suggestedModules,
|
|
rationale,
|
|
possibleDirections: possibleDirections.length > 0
|
|
? possibleDirections
|
|
: (fallbackRouting?.possibleDirections || ["Clarify which part of the editor or runtime should own this request."]),
|
|
kbSections,
|
|
sourceText: String(requestText || "").trim(),
|
|
};
|
|
}
|
|
|
|
function buildRoutingPrompt(request, kb, heuristicRouting) {
|
|
const systemSummaries = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : [])
|
|
.map((system) => `- ${system.id}: ${system.name} | tags=${(Array.isArray(system.tags) ? system.tags : []).join(", ")} | aliases=${(Array.isArray(system.aliases) ? system.aliases : []).join(", ")}`)
|
|
.join("\n");
|
|
const moduleSummaries = (Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : [])
|
|
.map((moduleEntry) => `- ${moduleEntry.id}: ${moduleEntry.name} | tags=${(Array.isArray(moduleEntry.tags) ? moduleEntry.tags : []).join(", ")} | systems=${(Array.isArray(moduleEntry.systemIds) ? moduleEntry.systemIds : []).join(", ")}`)
|
|
.join("\n");
|
|
const terminologySummary = (Array.isArray(kb?.terminology?.terms) ? kb.terminology.terms : [])
|
|
.map((term) => `- ${term.canonical}: aliases=${(Array.isArray(term.aliases) ? term.aliases : []).join(", ")} | tags=${(Array.isArray(term.tags) ? term.tags : []).join(", ")} | systems=${(Array.isArray(term.systemIds) ? term.systemIds : []).join(", ")}`)
|
|
.join("\n");
|
|
return [
|
|
{
|
|
role: "system",
|
|
content: [
|
|
"You are the routing pass for Worldshaper request analysis.",
|
|
"Map the submission onto likely systems, modules, and standardized tags before the deeper analysis pass runs.",
|
|
"Prefer Worldshaper terminology when the user uses adjacent language such as sprite editor, painting tool, recoloring, engine, runtime, map, grid, or chunk.",
|
|
"Broad requests must still receive a useful interpretation and possible directions.",
|
|
"Do not expose or simulate hidden chain-of-thought.",
|
|
"Return only valid JSON with keys: summary, ambiguity, matchedTerms, suggestedTags, suggestedSystems, suggestedModules, rationale, possibleDirections.",
|
|
].join("\n"),
|
|
},
|
|
{
|
|
role: "user",
|
|
content: [
|
|
`Submission id: ${request.id}`,
|
|
"Raw submission:",
|
|
request.sourceText,
|
|
"",
|
|
"Standardized tags:",
|
|
(Array.isArray(kb?.requestTagDefinitions) ? kb.requestTagDefinitions : []).map((entry) => `- ${entry.label}: ${entry.description || ""}`).join("\n"),
|
|
"",
|
|
"System index:",
|
|
systemSummaries,
|
|
"",
|
|
"Focused modules:",
|
|
moduleSummaries,
|
|
"",
|
|
"Terminology map:",
|
|
terminologySummary,
|
|
"",
|
|
"Heuristic seed:",
|
|
JSON.stringify(heuristicRouting, null, 2),
|
|
].join("\n"),
|
|
},
|
|
];
|
|
}
|
|
|
|
function buildKbContext(kb, requestText, routing) {
|
|
const relevantSystems = uniqueStrings([
|
|
...normalizeSystemIds(routing?.suggestedSystems, kb, []),
|
|
...pickRelevantSystems(kb, requestText, 4).map(({ system }) => String(system.id || "").trim()),
|
|
])
|
|
.slice(0, 4)
|
|
.map((systemId) => {
|
|
const system = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).find((entry) => String(entry.id || "").trim() === systemId);
|
|
return system
|
|
? {
|
|
system,
|
|
docText: kb.docsById.get(systemId) || "",
|
|
}
|
|
: null;
|
|
})
|
|
.filter(Boolean);
|
|
const relevantModules = uniqueStrings([
|
|
...normalizeModuleIds(routing?.suggestedModules, kb, []),
|
|
...pickRelevantModules(kb, requestText, routing, 5).map(({ moduleEntry }) => String(moduleEntry.id || "").trim()),
|
|
])
|
|
.slice(0, 5)
|
|
.map((moduleId) => {
|
|
const moduleEntry = (Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []).find((entry) => String(entry.id || "").trim() === moduleId);
|
|
return moduleEntry
|
|
? {
|
|
moduleEntry,
|
|
docText: kb.moduleDocsById.get(moduleId) || "",
|
|
}
|
|
: null;
|
|
})
|
|
.filter(Boolean);
|
|
return {
|
|
systems: relevantSystems,
|
|
modules: relevantModules,
|
|
editorCapabilitiesText: String(kb?.editorCapabilitiesText || "").trim(),
|
|
};
|
|
}
|
|
|
|
function buildAnalysisPrompt(request, routing, kbContext, schema, kb) {
|
|
const schemaSummary = JSON.stringify(schema, null, 2);
|
|
const tagCatalogSummary = Array.isArray(kb?.requestTagDefinitions)
|
|
? kb.requestTagDefinitions.map((entry) => `- ${entry.label}: ${entry.description || ""}`.trim()).join("\n")
|
|
: "";
|
|
const systemDocs = kbContext.systems.map(({ system, docText }) => [
|
|
`System: ${system.name}`,
|
|
`System ID: ${system.id}`,
|
|
`Tags: ${(Array.isArray(system.tags) ? system.tags : []).join(", ")}`,
|
|
`Key files: ${(Array.isArray(system.keyFiles) ? system.keyFiles : []).join(", ")}`,
|
|
`API endpoints: ${(Array.isArray(system.apiEndpoints) ? system.apiEndpoints : []).join(", ") || "(none)"}`,
|
|
docText,
|
|
].join("\n")).join("\n\n---\n\n");
|
|
const moduleDocs = kbContext.modules.map(({ moduleEntry, docText }) => [
|
|
`Module: ${moduleEntry.name}`,
|
|
`Module ID: ${moduleEntry.id}`,
|
|
`Tags: ${(Array.isArray(moduleEntry.tags) ? moduleEntry.tags : []).join(", ")}`,
|
|
`System IDs: ${(Array.isArray(moduleEntry.systemIds) ? moduleEntry.systemIds : []).join(", ")}`,
|
|
docText,
|
|
].join("\n")).join("\n\n---\n\n");
|
|
return [
|
|
{
|
|
role: "system",
|
|
content: [
|
|
"You are processing Worldshaper editor requests.",
|
|
"Split a submission into one or more atomic requests.",
|
|
"Ground your decisions in the provided KB systems and modules only.",
|
|
"Use only the standardized tags listed in the provided tag catalog.",
|
|
"Do not expose or simulate hidden chain-of-thought. Provide short structured review rationale instead.",
|
|
"If a request is broad or ambiguous, still provide the most useful likely interpretation and concrete implementation path you can.",
|
|
"Return only valid JSON.",
|
|
"Do not wrap the JSON in markdown fences.",
|
|
"If you are unsure, lower confidence and use statusRecommendation = \"needs_review\".",
|
|
].join("\n"),
|
|
},
|
|
{
|
|
role: "user",
|
|
content: [
|
|
`Submission id: ${request.id}`,
|
|
"Raw submission:",
|
|
request.sourceText,
|
|
"",
|
|
"Routing summary:",
|
|
JSON.stringify(routing, null, 2),
|
|
"",
|
|
"Return JSON matching this schema:",
|
|
schemaSummary,
|
|
"",
|
|
"Standardized tags you may use:",
|
|
tagCatalogSummary,
|
|
"",
|
|
"Capability shorthand:",
|
|
kbContext.editorCapabilitiesText || "(none)",
|
|
"",
|
|
"Relevant KB systems:",
|
|
systemDocs || "(none)",
|
|
"",
|
|
"Relevant KB modules:",
|
|
moduleDocs || "(none)",
|
|
].join("\n"),
|
|
},
|
|
];
|
|
}
|
|
|
|
function extractJsonString(text) {
|
|
const rawText = String(text || "").trim();
|
|
if (!rawText) {
|
|
throw new Error("Model returned empty content.");
|
|
}
|
|
const fencedMatch = rawText.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
if (fencedMatch && fencedMatch[1]) {
|
|
return fencedMatch[1].trim();
|
|
}
|
|
const firstBrace = rawText.indexOf("{");
|
|
const lastBrace = rawText.lastIndexOf("}");
|
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
return rawText.slice(firstBrace, lastBrace + 1);
|
|
}
|
|
throw new Error("Could not find JSON object in model response.");
|
|
}
|
|
|
|
function readMessageContent(messageContent) {
|
|
if (typeof messageContent === "string") {
|
|
return messageContent;
|
|
}
|
|
if (Array.isArray(messageContent)) {
|
|
return messageContent
|
|
.map((part) => {
|
|
if (typeof part === "string") {
|
|
return part;
|
|
}
|
|
if (part && typeof part === "object" && typeof part.text === "string") {
|
|
return part.text;
|
|
}
|
|
return "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function callModelApi(config, messages, options = {}) {
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
if (config.apiKey) {
|
|
headers.Authorization = `Bearer ${config.apiKey}`;
|
|
}
|
|
const requestBody = {
|
|
model: config.model,
|
|
temperature: Number.isFinite(Number(options.temperature)) ? Number(options.temperature) : 0.2,
|
|
max_tokens: Math.max(256, Math.floor(Number(options.maxTokens) || config.maxTokens)),
|
|
messages,
|
|
};
|
|
if (config.provider === "deepseek") {
|
|
requestBody.response_format = { type: "json_object" };
|
|
requestBody.thinking = { type: config.thinking };
|
|
}
|
|
const payload = await fetchJson(buildUrl(config.modelBaseUrl, "chat/completions"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
const text = readMessageContent(payload?.choices?.[0]?.message?.content);
|
|
return JSON.parse(extractJsonString(text));
|
|
}
|
|
|
|
async function getLauncherRequests(config) {
|
|
return fetchJson(buildUrl(config.apiBase, "/api/launcher-requests"));
|
|
}
|
|
|
|
async function patchLauncherRequest(config, requestId, body) {
|
|
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}`), {
|
|
method: "PATCH",
|
|
headers: buildAdminHeaders(config, {
|
|
"Content-Type": "application/json",
|
|
}),
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function processLauncherRequestAnalysis(config, requestId, body) {
|
|
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}/process-analysis`), {
|
|
method: "POST",
|
|
headers: buildAdminHeaders(config, {
|
|
"Content-Type": "application/json",
|
|
}),
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function shouldPromoteAnalysis(result, config) {
|
|
return result.items.length > 0
|
|
&& String(result?.routing?.ambiguity || "").trim().toLowerCase() !== "high"
|
|
&& result.items.every((item) => (
|
|
item.statusRecommendation === "active"
|
|
&& Number.isFinite(item.confidence)
|
|
&& item.confidence >= config.promoteThreshold
|
|
));
|
|
}
|
|
|
|
async function markRequestProcessing(config, request) {
|
|
const now = new Date().toISOString();
|
|
return patchLauncherRequest(config, request.id, {
|
|
analysis: {
|
|
...(request.analysis && typeof request.analysis === "object" && !Array.isArray(request.analysis) ? request.analysis : {}),
|
|
state: "processing",
|
|
model: config.model,
|
|
submissionId: request.id,
|
|
sourceTextSnapshot: request.sourceText,
|
|
createdAt: request.analysis?.createdAt || now,
|
|
updatedAt: now,
|
|
error: "",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function markRequestError(config, request, errorMessage) {
|
|
return processLauncherRequestAnalysis(config, request.id, {
|
|
action: "error",
|
|
model: config.model,
|
|
error: String(errorMessage || "Unknown analysis failure."),
|
|
analysis: {
|
|
submissionId: request.id,
|
|
sourceTextSnapshot: request.sourceText,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function markRequestReview(config, request, result) {
|
|
return processLauncherRequestAnalysis(config, request.id, {
|
|
action: "review",
|
|
model: config.model,
|
|
analysis: {
|
|
submissionId: request.id,
|
|
sourceTextSnapshot: request.sourceText,
|
|
confidence: result.confidence,
|
|
routing: result.routing,
|
|
items: result.items,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function promoteRequest(config, request, result) {
|
|
return processLauncherRequestAnalysis(config, request.id, {
|
|
action: "promote",
|
|
model: config.model,
|
|
analysis: {
|
|
submissionId: request.id,
|
|
sourceTextSnapshot: request.sourceText,
|
|
confidence: result.confidence,
|
|
routing: result.routing,
|
|
items: result.items,
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildFallbackAnalysisResult(request, routing, kb) {
|
|
const primaryTag = Array.isArray(routing?.suggestedTags) && routing.suggestedTags.length > 0
|
|
? routing.suggestedTags[0]
|
|
: "General";
|
|
const primarySystemId = Array.isArray(routing?.suggestedSystems) && routing.suggestedSystems.length > 0
|
|
? routing.suggestedSystems[0]
|
|
: "";
|
|
const primarySystem = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : [])
|
|
.find((system) => String(system.id || "").trim() === primarySystemId);
|
|
const title = buildFallbackTitle(
|
|
request.sourceText,
|
|
routing?.ambiguity === "high" ? "Broader editor improvement request" : "Pending request",
|
|
);
|
|
const parsedInterpretation = routing?.ambiguity === "high"
|
|
? `This submission is broad, but it most likely points toward ${primaryTag} work${primarySystem?.name ? ` touching ${primarySystem.name}` : ""}. It needs a narrower decision before it can be promoted cleanly.`
|
|
: `This request most likely targets ${primarySystem?.name || primaryTag}, based on the submission language and the matched KB terminology.`;
|
|
const implementationApproach = routing?.ambiguity === "high"
|
|
? `Turn the request into a smaller scoped follow-up by choosing one direction first: ${(Array.isArray(routing?.possibleDirections) && routing.possibleDirections.length > 0 ? routing.possibleDirections : ["clarify whether this should be content, map editing, or runtime work"]).join(" ")}`.trim()
|
|
: `Start from ${primarySystem?.name || primaryTag}, audit the existing workflow there, and then scope the change so the request can be broken into editor-facing behavior, saved data changes, and any runtime handoff that may be required.`;
|
|
const reviewOptions = Array.isArray(routing?.possibleDirections) && routing.possibleDirections.length > 0
|
|
? routing.possibleDirections
|
|
: ["Clarify which part of the editor or runtime should own this request first."];
|
|
return {
|
|
submissionId: request.id,
|
|
sourceText: request.sourceText,
|
|
confidence: 0.38,
|
|
minimumConfidence: 0.38,
|
|
routing,
|
|
items: [
|
|
{
|
|
title,
|
|
primaryCategory: primaryTag,
|
|
tags: Array.isArray(routing?.suggestedTags) && routing.suggestedTags.length > 0 ? routing.suggestedTags : ["General"],
|
|
statusRecommendation: "needs_review",
|
|
parsedInterpretation,
|
|
implementationApproach,
|
|
affectedSystems: primarySystem?.name ? [primarySystem.name] : [],
|
|
affectedFiles: [],
|
|
problemType: "feature",
|
|
rawExcerpt: String(request.sourceText || "").trim(),
|
|
confidence: 0.38,
|
|
reviewRationale: String(routing?.rationale || "").trim() || "The request is still too broad to auto-promote safely.",
|
|
reviewOptions,
|
|
notes: String(routing?.summary || "").trim(),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async function analyzeRequest(config, kb, request) {
|
|
const heuristicRouting = deriveHeuristicRouting(kb, request.sourceText);
|
|
const routingPrompt = buildRoutingPrompt(request, kb, heuristicRouting);
|
|
let routing = heuristicRouting;
|
|
try {
|
|
const routingResult = await callModelApi(config, routingPrompt, {
|
|
maxTokens: Math.min(config.maxTokens, 900),
|
|
temperature: 0.1,
|
|
});
|
|
routing = normalizeRoutingResult(routingResult, kb, request.sourceText, heuristicRouting);
|
|
} catch (error) {
|
|
console.warn(` Routing pass fallback for ${request.id}: ${String(error)}`);
|
|
routing = normalizeRoutingResult({}, kb, request.sourceText, heuristicRouting);
|
|
}
|
|
const kbContext = buildKbContext(kb, request.sourceText, routing);
|
|
const systemNames = kbContext.systems.map(({ system }) => system.name);
|
|
console.log(`Analyzing ${request.id}: ${request.title}`);
|
|
console.log(` Systems: ${systemNames.join(", ")}`);
|
|
if (!config.dryRun) {
|
|
await markRequestProcessing(config, request);
|
|
}
|
|
const prompt = buildAnalysisPrompt(request, routing, kbContext, kb.requestSchema, kb);
|
|
let normalizedResult;
|
|
try {
|
|
const modelResult = await callModelApi(config, prompt, {
|
|
maxTokens: config.maxTokens,
|
|
temperature: 0.2,
|
|
});
|
|
normalizedResult = normalizeAnalysisResult(
|
|
modelResult,
|
|
request,
|
|
kbContext.systems.map((entry) => entry.system),
|
|
kb,
|
|
);
|
|
} catch (error) {
|
|
console.warn(` Analysis pass fallback for ${request.id}: ${String(error)}`);
|
|
normalizedResult = buildFallbackAnalysisResult(request, routing, kb);
|
|
}
|
|
normalizedResult.routing = routing;
|
|
const action = shouldPromoteAnalysis(normalizedResult, config) ? "promote" : "review";
|
|
console.log(` Result: ${normalizedResult.items.length} item(s), action=${action}, confidence=${normalizedResult.confidence ?? "n/a"}`);
|
|
if (config.dryRun) {
|
|
console.log(JSON.stringify({
|
|
requestId: request.id,
|
|
action,
|
|
result: normalizedResult,
|
|
}, null, 2));
|
|
return { action, result: normalizedResult };
|
|
}
|
|
if (action === "promote") {
|
|
await promoteRequest(config, request, normalizedResult);
|
|
} else {
|
|
await markRequestReview(config, request, normalizedResult);
|
|
}
|
|
return { action, result: normalizedResult };
|
|
}
|
|
|
|
function selectPendingRequests(payload, config) {
|
|
const requests = Array.isArray(payload?.requests) ? payload.requests : [];
|
|
const pending = requests.filter((request) => String(request?.status || "").trim().toLowerCase() === "pending");
|
|
if (config.requestId) {
|
|
return pending.filter((request) => String(request?.id || "").trim() === config.requestId);
|
|
}
|
|
return pending
|
|
.filter((request) => {
|
|
const analysisState = String(request?.analysis?.state || "").trim().toLowerCase();
|
|
return !analysisState || analysisState === "unprocessed";
|
|
})
|
|
.slice(0, Math.max(1, config.limit));
|
|
}
|
|
|
|
async function runSinglePass(config, kb) {
|
|
const payload = await getLauncherRequests(config);
|
|
const pendingRequests = selectPendingRequests(payload, config);
|
|
if (pendingRequests.length === 0) {
|
|
console.log("No eligible pending requests found.");
|
|
return 0;
|
|
}
|
|
let processedCount = 0;
|
|
for (const request of pendingRequests) {
|
|
try {
|
|
await analyzeRequest(config, kb, request);
|
|
processedCount += 1;
|
|
} catch (error) {
|
|
console.error(` Failed to analyze ${request.id}: ${String(error)}`);
|
|
if (!config.dryRun) {
|
|
try {
|
|
await markRequestError(config, request, String(error));
|
|
} catch (secondaryError) {
|
|
console.error(` Failed to record analysis error for ${request.id}: ${String(secondaryError)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return processedCount;
|
|
}
|
|
|
|
async function main() {
|
|
const config = finalizeConfig(parseArgs(process.argv.slice(2)));
|
|
if (config.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
if (!config.dryRun && !config.model) {
|
|
throw new Error("Missing local model id. Set REQUEST_ANALYZER_MODEL or pass --model.");
|
|
}
|
|
if (!config.dryRun && !config.apiKey) {
|
|
throw new Error(
|
|
config.provider === "deepseek"
|
|
? "Missing DeepSeek API key. Set DEEPSEEK_API_KEY or REQUEST_ANALYZER_API_KEY."
|
|
: "Missing model API key. Set REQUEST_ANALYZER_API_KEY or pass --api-key."
|
|
);
|
|
}
|
|
const kb = await loadKnowledgeBase();
|
|
if (config.poll) {
|
|
console.log(`Watching pending requests every ${config.intervalMs}ms`);
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
await runSinglePass(config, kb);
|
|
await sleep(config.intervalMs);
|
|
}
|
|
}
|
|
await runSinglePass(config, kb);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(String(error));
|
|
process.exitCode = 1;
|
|
});
|