Files
claw/frontend/scene-generator/server.js

1376 lines
46 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const http = require("http");
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const { loadConfig, getDefaults } = require("./config-loader");
const { analyzeScene, analyzeSceneDeep } = require("./llm-client");
const { runGenerator, readDirectory, validateSceneIdCandidate } = require("./generator-runner");
let config;
let defaults;
try {
config = loadConfig();
defaults = getDefaults();
if (require.main === module) {
console.log(`[config] Loaded from: ${config.configPath}`);
console.log(`[config] Project root: ${config.projectRoot}`);
}
} catch (error) {
console.error(`[error] Failed to load config: ${error.message}`);
process.exit(1);
}
const PORT = parseInt(process.env.SG_SCENE_GENERATOR_PORT, 10) || 3210;
const HOST = "127.0.0.1";
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
};
function serveStatic(res, filePath) {
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] || "application/octet-stream";
fs.readFile(filePath, (error, data) => {
if (error) {
writeJson(res, 404, { error: "Not found" });
return;
}
res.writeHead(200, { "Content-Type": contentType });
res.end(data);
});
}
function initSSE(res) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
});
res.write(":\n");
return res;
}
function writeSSE(res, event, data) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
function writeJson(res, statusCode, payload) {
res.writeHead(statusCode, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
});
res.end(JSON.stringify(payload));
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (_) {
reject(new Error("Invalid JSON"));
}
});
req.on("error", reject);
});
}
async function handleAnalyze(req, res) {
let body;
try {
body = await parseBody(req);
} catch (_) {
writeJson(res, 400, { error: "Invalid JSON body" });
return;
}
const sourceDir = normalizeInputPath(body.sourceDir);
if (!sourceDir) {
writeJson(res, 400, { error: "sourceDir is required" });
return;
}
let dirContents;
try {
dirContents = readDirectory(sourceDir);
} catch (error) {
writeJson(res, 400, { error: error.message });
return;
}
try {
const result = await analyzeScene(sourceDir, dirContents, config);
writeJson(res, 200, result);
} catch (error) {
writeJson(res, 502, {
error: `LLM analysis failed: ${error.message}`,
hint: "You can still enter scene-id and scene-name manually.",
});
}
}
async function handleAnalyzeDeep(req, res) {
let body;
try {
body = await parseBody(req);
} catch (_) {
writeJson(res, 400, { error: "Invalid JSON body" });
return;
}
const sourceDir = normalizeInputPath(body.sourceDir);
if (!sourceDir) {
writeJson(res, 400, { error: "sourceDir is required" });
return;
}
let dirContents;
try {
dirContents = readDirectory(sourceDir);
} catch (error) {
writeJson(res, 400, { error: error.message });
return;
}
const deterministic = sanitizeSceneIr(dirContents.deterministic || {});
const warnings = [];
const sources = {
deterministic: true,
llm: false,
};
let llmSceneIr = null;
let llmError = null;
if (hasUsableLlmConfig(config)) {
try {
llmSceneIr = sanitizeSceneIr(await analyzeSceneDeep(sourceDir, dirContents, config));
sources.llm = true;
} catch (error) {
llmError = error.message;
warnings.push(`LLM semantic completion failed: ${error.message}`);
}
} else {
warnings.push("LLM semantic completion skipped because API config is incomplete.");
}
const merged = mergeSceneIr(deterministic, llmSceneIr, warnings);
merged.analysisMeta = {
sourceDir,
sources,
llmError,
warnings,
deterministicSignals: dirContents.analysisContext?.deterministicSignals || {},
};
console.log(
`[analyze-deep] ${merged.sceneId} / ${merged.sceneName} archetype=${merged.workflowArchetype || "unknown"} readiness=${merged.readiness?.level || "?"}`
);
writeJson(res, 200, merged);
}
async function handleGenerate(req, res) {
let body;
try {
body = await parseBody(req);
} catch (_) {
writeJson(res, 400, { error: "Invalid JSON body" });
return;
}
const sourceDir = normalizeInputPath(body.sourceDir);
const sceneId = stringValue(body.sceneId);
const sceneName = stringValue(body.sceneName);
const sceneKind = stringValue(body.sceneKind);
const targetUrl = stringValue(body.targetUrl);
const outputRoot = normalizeInputPath(body.outputRoot);
const lessons = normalizeInputPath(body.lessons);
const sceneInfoJson = normalizeJsonInput(body.sceneInfoJson);
const sceneIrJson = normalizeJsonInput(body.sceneIrJson);
if (!sourceDir || !sceneId || !sceneName || !outputRoot) {
writeJson(res, 400, {
error: "All fields required: sourceDir, sceneId, sceneName, outputRoot",
});
return;
}
const sseWriter = initSSE(res);
try {
const sceneIr = sceneIrJson ? sanitizeSceneIr(JSON.parse(sceneIrJson)) : null;
const blockers = getGenerationBlockers({
sceneIr,
sceneId,
sceneName,
sourceDir,
});
if (blockers.length) {
writeSSE(sseWriter, "error", {
message: `Generation blocked: ${blockers.join(", ")}`,
});
writeSSE(sseWriter, "complete", {
success: false,
blocked: true,
blockers,
readiness: sceneIr?.readiness || null,
workflowArchetype: sceneIr?.workflowArchetype || null,
confidence: sceneIr?.confidence || 0,
});
return;
}
await runGenerator(
{
sourceDir,
sceneId,
sceneName,
sceneKind: sceneKind || null,
targetUrl: targetUrl || null,
outputRoot,
lessons: lessons || null,
sceneInfoJson,
sceneIrJson,
completionMeta: sceneIr
? {
readiness: sceneIr.readiness || null,
workflowArchetype: sceneIr.workflowArchetype || null,
confidence: sceneIr.confidence || 0,
}
: null,
},
sseWriter,
config.projectRoot
);
} catch (error) {
writeSSE(sseWriter, "error", { message: `Server error: ${error.message}` });
}
sseWriter.end();
}
function handleHealth(req, res) {
writeJson(res, 200, {
status: "ok",
pid: process.pid,
configLoaded: true,
configPath: config.configPath,
projectRoot: config.projectRoot,
defaults,
});
}
function openFolderDialog(defaultPath) {
return new Promise((resolve) => {
const psScript = `
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
$dialog.Description = "Select a folder"
$dialog.ShowNewFolderButton = true
${defaultPath ? `$dialog.SelectedPath = '${defaultPath.replace(/'/g, "''")}'` : ""}
if ($dialog.ShowDialog() -eq 'OK') {
Write-Output $dialog.SelectedPath
}
`.trim();
const ps = spawn(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", psScript],
{ windowsHide: true }
);
let output = "";
ps.stdout.on("data", (data) => {
output += data.toString("utf8");
});
ps.on("close", (code) => {
if (code === 0 && output.trim()) {
let selected = output.trim();
if (selected.charCodeAt(0) === 0xfeff) {
selected = selected.slice(1);
}
resolve(selected);
return;
}
resolve(null);
});
ps.on("error", () => resolve(null));
});
}
async function handleSelectFolder(req, res) {
let body = {};
try {
body = await parseBody(req);
} catch (_) {}
const selectedPath = await openFolderDialog(body.defaultPath || "");
writeJson(res, 200, { path: selectedPath });
}
async function handleSelectFile(req, res) {
let body = {};
try {
body = await parseBody(req);
} catch (_) {}
const filter = body.filter || "All files (*.*)|*.*";
const psScript = `
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = '${filter.replace(/'/g, "''")}'
$dialog.Title = "Select a file"
${body.defaultPath ? `$dialog.InitialDirectory = '${body.defaultPath.replace(/'/g, "''")}'` : ""}
if ($dialog.ShowDialog() -eq 'OK') {
Write-Output $dialog.FileName
}
`.trim();
return new Promise((resolve) => {
const ps = spawn(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", psScript],
{ windowsHide: true }
);
let output = "";
ps.stdout.on("data", (data) => {
output += data.toString("utf8");
});
ps.on("close", (code) => {
let selected = output.trim();
if (selected.charCodeAt(0) === 0xfeff) {
selected = selected.slice(1);
}
writeJson(res, 200, { path: code === 0 && selected ? selected : null });
resolve();
});
ps.on("error", () => {
writeJson(res, 200, { path: null });
resolve();
});
});
}
function hasUsableLlmConfig(currentConfig) {
return Boolean(
currentConfig &&
stringValue(currentConfig.apiKey) &&
stringValue(currentConfig.baseUrl) &&
stringValue(currentConfig.model)
);
}
function sanitizeSceneIr(sceneIr) {
const value = sceneIr && typeof sceneIr === "object" ? JSON.parse(JSON.stringify(sceneIr)) : {};
value.sceneId = stringValue(value.sceneId);
value.sceneIdDiagnostics = sanitizeSceneIdDiagnostics(value.sceneIdDiagnostics);
value.sceneName = stringValue(value.sceneName) || "Generated Scene";
value.sceneKind = stringValue(value.sceneKind) || "report_collection";
value.workflowArchetype = stringValue(value.workflowArchetype) || "single_request_table";
value.bootstrap = sanitizeBootstrap(value.bootstrap);
value.params = sanitizeParams(value.params);
value.modes = sanitizeModes(value.modes);
value.defaultMode = stringValue(value.defaultMode) || null;
value.modeSwitchField = stringValue(value.modeSwitchField) || null;
value.workflowSteps = sanitizeWorkflowSteps(value.workflowSteps);
value.workflowEvidence = sanitizeWorkflowEvidence(value.workflowEvidence);
value.mainRequest = sanitizeMainRequest(value.mainRequest);
value.enrichmentRequests = sanitizeEnrichmentRequests(value.enrichmentRequests);
value.mergePlan = sanitizeMergePlan(value.mergePlan);
value.requestTemplate = ensureObject(value.requestTemplate);
value.responsePath = stringValue(value.responsePath);
value.normalizeRules = sanitizeNormalizeRules(value.normalizeRules);
value.artifactContract = sanitizeArtifactContract(value.artifactContract);
value.validationHints = sanitizeValidationHints(value.validationHints);
value.evidence = sanitizeEvidence(value.evidence);
value.readiness = sanitizeReadiness(value.readiness);
value.apiEndpoints = sanitizeApiEndpoints(value.apiEndpoints);
value.staticParams = ensureObject(value.staticParams);
value.columnDefs = Array.isArray(value.columnDefs) ? value.columnDefs : [];
value.confidence = clampConfidence(value.confidence);
value.uncertainties = sanitizeStringList(value.uncertainties);
return value;
}
function sanitizeMainRequest(mainRequest) {
const value = ensureObject(mainRequest);
if (!Object.keys(value).length) return null;
return {
apiEndpoint: sanitizeApiEndpoint(value.apiEndpoint),
requestTemplate: ensureObject(value.requestTemplate),
responsePath: stringValue(value.responsePath),
columnDefs: Array.isArray(value.columnDefs) ? value.columnDefs : [],
};
}
function sanitizeEnrichmentRequests(enrichmentRequests) {
if (!Array.isArray(enrichmentRequests)) return [];
return enrichmentRequests
.map((item) => ({
name: stringValue(item?.name),
apiEndpoint: sanitizeApiEndpoint(item?.apiEndpoint),
paramBindings: ensureObject(item?.paramBindings),
responsePath: stringValue(item?.responsePath),
consumedFields: sanitizeStringList(item?.consumedFields),
}))
.filter((item) => item.name);
}
function sanitizeMergePlan(mergePlan) {
const value = ensureObject(mergePlan);
if (!Object.keys(value).length) return null;
return {
joinKeys: sanitizeStringList(value.joinKeys),
fieldMappings: Array.isArray(value.fieldMappings)
? value.fieldMappings.map((item) => ({
outputField: stringValue(item?.outputField),
sourceType: stringValue(item?.sourceType),
sourceField: stringValue(item?.sourceField),
requestName: stringValue(item?.requestName) || null,
})).filter((item) => item.outputField)
: [],
aggregateRules: sanitizeStringList(value.aggregateRules),
outputColumns: Array.isArray(value.outputColumns) ? value.outputColumns : [],
};
}
function sanitizeSceneIdDiagnostics(sceneIdDiagnostics) {
const value = ensureObject(sceneIdDiagnostics);
return {
candidateSource: stringValue(value.candidateSource),
valid: value.valid !== false,
invalidReason: stringValue(value.invalidReason) || null,
candidates: Array.isArray(value.candidates)
? value.candidates
.map((candidate) => ({
value: stringValue(candidate?.value),
source: stringValue(candidate?.source),
valid: candidate?.valid !== false,
reason: stringValue(candidate?.reason) || null,
}))
.filter((candidate) => candidate.value)
: [],
};
}
function sanitizeBootstrap(bootstrap) {
const value = ensureObject(bootstrap);
return {
expectedDomain: stringValue(value.expectedDomain),
targetUrl: stringValue(value.targetUrl),
requiresTargetPage: value.requiresTargetPage !== false,
pageTitleKeywords: sanitizeStringList(value.pageTitleKeywords),
source: stringValue(value.source),
};
}
function sanitizeParams(params) {
if (!Array.isArray(params)) return [];
return params
.map((param) => ({
name: stringValue(param?.name),
resolver: stringValue(param?.resolver),
required: Boolean(param?.required),
promptMissing: stringValue(param?.promptMissing),
promptAmbiguous: stringValue(param?.promptAmbiguous),
resolverConfig: ensureObject(param?.resolverConfig),
}))
.filter((param) => param.name);
}
function sanitizeModes(modes) {
if (!Array.isArray(modes)) return [];
return modes
.map((mode) => ({
name: stringValue(mode?.name),
label: stringValue(mode?.label) || null,
condition: mode?.condition && typeof mode.condition === "object"
? {
field: stringValue(mode.condition.field),
operator: stringValue(mode.condition.operator) || "equals",
value: mode.condition.value,
}
: null,
apiEndpoint: sanitizeApiEndpoint(mode?.apiEndpoint),
columnDefs: Array.isArray(mode?.columnDefs) ? mode.columnDefs : [],
requestTemplate: ensureObject(mode?.requestTemplate),
normalizeRules: sanitizeNormalizeRules(mode?.normalizeRules),
responsePath: stringValue(mode?.responsePath),
}))
.filter((mode) => mode.name);
}
function sanitizeWorkflowSteps(steps) {
if (!Array.isArray(steps)) return [];
return steps
.map((step) => ({
type: stringValue(step?.type),
entry: stringValue(step?.entry) || null,
source: stringValue(step?.source) || null,
expr: stringValue(step?.expr) || null,
description: stringValue(step?.description) || null,
endpoint: stringValue(step?.endpoint) || null,
}))
.filter((step) => step.type);
}
function sanitizeWorkflowEvidence(workflowEvidence) {
const value = ensureObject(workflowEvidence);
return {
requestEntries: sanitizeStringList(value.requestEntries),
paginationFields: sanitizeStringList(value.paginationFields),
secondaryRequestEntries: sanitizeStringList(value.secondaryRequestEntries),
postProcessSteps: sanitizeStringList(value.postProcessSteps),
};
}
function sanitizeNormalizeRules(rules) {
const value = ensureObject(rules);
if (!Object.keys(value).length) return null;
return {
type: stringValue(value.type),
requiredFields: sanitizeStringList(value.requiredFields),
filterNull: value.filterNull !== false,
};
}
function sanitizeArtifactContract(contract) {
const value = ensureObject(contract);
return {
type: stringValue(value.type) || "report-artifact",
successStatus: sanitizeStringList(value.successStatus).length
? sanitizeStringList(value.successStatus)
: ["ok", "partial", "empty"],
failureStatus: sanitizeStringList(value.failureStatus).length
? sanitizeStringList(value.failureStatus)
: ["blocked", "error"],
};
}
function sanitizeValidationHints(hints) {
const value = ensureObject(hints);
return {
requiresTargetPage: value.requiresTargetPage !== false,
runtimeCompatible: value.runtimeCompatible !== false,
manualCompletionRequired: Boolean(value.manualCompletionRequired),
missingPieces: sanitizeStringList(value.missingPieces),
};
}
function sanitizeEvidence(evidence) {
if (!Array.isArray(evidence)) return [];
return evidence
.map((item) => ({
kind: stringValue(item?.kind),
evidenceType: stringValue(item?.evidenceType) || "signal",
layer: stringValue(item?.layer) || "business",
subject: stringValue(item?.subject) || null,
summary: stringValue(item?.summary),
source: stringValue(item?.source) || null,
confidence: clampConfidence(item?.confidence),
payload: item?.payload && typeof item.payload === "object" && !Array.isArray(item.payload)
? item.payload
: null,
}))
.filter((item) => item.summary);
}
function sanitizeReadiness(readiness) {
const value = ensureObject(readiness);
return {
level: stringValue(value.level),
confidence: clampConfidence(value.confidence),
gates: Array.isArray(value.gates)
? value.gates
.map((gate) => ({
name: stringValue(gate?.name),
passed: Boolean(gate?.passed),
reason: stringValue(gate?.reason) || null,
}))
.filter((gate) => gate.name)
: [],
risks: sanitizeStringList(value.risks),
missingPieces: sanitizeStringList(value.missingPieces),
notes: sanitizeStringList(value.notes),
};
}
function sanitizeApiEndpoints(endpoints) {
if (!Array.isArray(endpoints)) return [];
return endpoints.map(sanitizeApiEndpoint).filter(Boolean);
}
function sanitizeApiEndpoint(endpoint) {
if (!endpoint || typeof endpoint !== "object") return null;
const url = stringValue(endpoint.url);
if (!url) return null;
return {
name: stringValue(endpoint.name) || inferEndpointName(url),
url,
method: stringValue(endpoint.method).toUpperCase() || "GET",
contentType: stringValue(endpoint.contentType) || null,
description: stringValue(endpoint.description) || null,
};
}
function inferEndpointName(url) {
const parts = url.split(/[/?#]/).filter(Boolean);
return parts[parts.length - 1] || "endpoint";
}
function mergeSceneIr(deterministic, llmSceneIr, warnings) {
const merged = JSON.parse(JSON.stringify(deterministic));
const llm = llmSceneIr ? sanitizeSceneIr(llmSceneIr) : null;
if (!llm) {
merged.sceneIdDiagnostics = sanitizeSceneIdDiagnostics(merged.sceneIdDiagnostics);
merged.validationHints.manualCompletionRequired = merged.readiness.level !== "A";
return sanitizeSceneIr(merged);
}
const sceneIdentity = chooseSceneIdentity(deterministic, llm, warnings);
merged.sceneId = sceneIdentity.sceneId;
merged.sceneIdDiagnostics = sceneIdentity.sceneIdDiagnostics;
merged.sceneName = chooseSoftValue(deterministic.sceneName, llm.sceneName);
merged.sceneKind = chooseSoftValue(deterministic.sceneKind, llm.sceneKind) || "report_collection";
merged.workflowArchetype = chooseArchetype(deterministic, llm, warnings);
merged.bootstrap = mergeBootstrap(deterministic.bootstrap, llm.bootstrap, warnings);
merged.params = mergeByName(deterministic.params, llm.params);
merged.modes = deterministic.modes.length ? deterministic.modes : llm.modes;
merged.defaultMode = chooseSoftValue(deterministic.defaultMode, llm.defaultMode);
merged.modeSwitchField = chooseSoftValue(deterministic.modeSwitchField, llm.modeSwitchField);
merged.workflowSteps = mergeWorkflowSteps(deterministic.workflowSteps, llm.workflowSteps);
merged.workflowEvidence = mergeWorkflowEvidence(deterministic.workflowEvidence, llm.workflowEvidence);
merged.mainRequest = deterministic.mainRequest || llm.mainRequest || null;
merged.enrichmentRequests = deterministic.enrichmentRequests?.length
? deterministic.enrichmentRequests
: llm.enrichmentRequests;
merged.mergePlan = deterministic.mergePlan || llm.mergePlan || null;
merged.requestTemplate = mergeObjects(deterministic.requestTemplate, llm.requestTemplate);
merged.responsePath = chooseHardValue(deterministic.responsePath, llm.responsePath);
merged.normalizeRules = deterministic.normalizeRules || llm.normalizeRules || null;
merged.artifactContract = deterministic.artifactContract || llm.artifactContract;
merged.apiEndpoints = mergeEndpoints(deterministic.apiEndpoints, llm.apiEndpoints);
merged.staticParams = mergeObjects(deterministic.staticParams, llm.staticParams);
merged.columnDefs = deterministic.columnDefs.length ? deterministic.columnDefs : llm.columnDefs;
merged.evidence = mergeEvidence(deterministic.evidence, llm.evidence);
merged.uncertainties = uniqueStrings([...(deterministic.uncertainties || []), ...(llm.uncertainties || [])]);
merged.confidence = Number(
(
clampConfidence(deterministic.confidence) * 0.7 +
clampConfidence(llm.confidence) * 0.3 +
(llm.evidence.length ? 0.02 : 0)
).toFixed(2)
);
merged.readiness = computeReadinessPreview(merged, warnings, llm.readiness);
merged.validationHints = {
requiresTargetPage: merged.bootstrap.requiresTargetPage !== false,
runtimeCompatible: merged.params.every((param) =>
["dictionary_entity", "month_week_period", "fixed_enum", "literal_passthrough"].includes(param.resolver)
),
manualCompletionRequired: merged.readiness.level !== "A",
missingPieces: merged.readiness.missingPieces.slice(),
};
return sanitizeSceneIr(merged);
}
function chooseSoftValue(primary, secondary) {
return stringValue(secondary) || stringValue(primary) || "";
}
function chooseHardValue(primary, secondary) {
const primaryValue = stringValue(primary);
if (primaryValue) return primaryValue;
return stringValue(secondary);
}
function chooseArchetype(deterministic, llm, warnings) {
const deterministicValue = stringValue(deterministic.workflowArchetype);
const llmValue = stringValue(llm.workflowArchetype);
if (deterministicValue && llmValue && deterministicValue !== llmValue) {
warnings.push(`Workflow archetype conflict: deterministic=${deterministicValue}, llm=${llmValue}`);
}
return deterministicValue || llmValue || "single_request_table";
}
function chooseSceneIdentity(deterministic, llm, warnings) {
const deterministicDiagnostics = sanitizeSceneIdDiagnostics(deterministic.sceneIdDiagnostics);
const llmDiagnostics = sanitizeSceneIdDiagnostics(llm.sceneIdDiagnostics);
const candidates = [
{
sceneId: stringValue(llm.sceneId),
diagnostics: {
...llmDiagnostics,
candidateSource: llmDiagnostics.candidateSource || "llm_semantic",
},
},
{
sceneId: stringValue(deterministic.sceneId),
diagnostics: {
...deterministicDiagnostics,
candidateSource: deterministicDiagnostics.candidateSource || "deterministic",
},
},
].filter((item) => item.sceneId);
const validCandidate = candidates.find((item) => item.diagnostics.valid);
if (
stringValue(deterministic.sceneId) &&
stringValue(llm.sceneId) &&
stringValue(deterministic.sceneId) !== stringValue(llm.sceneId)
) {
warnings.push(`SceneId conflict: deterministic=${deterministic.sceneId}, llm=${llm.sceneId}`);
}
if (validCandidate) {
return {
sceneId: validCandidate.sceneId,
sceneIdDiagnostics: validCandidate.diagnostics,
};
}
const fallback = candidates[0] || {
sceneId: "",
diagnostics: {
candidateSource: "",
valid: false,
invalidReason: "empty_scene_id",
candidates: [],
},
};
return {
sceneId: fallback.sceneId,
sceneIdDiagnostics: {
...fallback.diagnostics,
valid: false,
invalidReason: fallback.diagnostics.invalidReason || "invalid_scene_id",
},
};
}
function mergeBootstrap(deterministic, llm, warnings) {
const deterministicTarget = isUnsafeBootstrapValue(deterministic.targetUrl) ? "" : stringValue(deterministic.targetUrl);
const deterministicDomain = isUnsafeBootstrapValue(deterministic.expectedDomain) ? "" : stringValue(deterministic.expectedDomain);
const llmTarget = isUnsafeBootstrapValue(llm.targetUrl) ? "" : stringValue(llm.targetUrl);
const llmDomain = isUnsafeBootstrapValue(llm.expectedDomain) ? "" : stringValue(llm.expectedDomain);
const merged = {
expectedDomain: chooseHardValue(deterministicDomain, llmDomain),
targetUrl: chooseHardValue(deterministicTarget, llmTarget),
requiresTargetPage: deterministic.requiresTargetPage !== false && llm.requiresTargetPage !== false,
pageTitleKeywords: uniqueStrings([...(deterministic.pageTitleKeywords || []), ...(llm.pageTitleKeywords || [])]),
source: stringValue(deterministic.source) || stringValue(llm.source) || "deterministic",
};
if (stringValue(llm.targetUrl) && !llmTarget) {
warnings.push(`Ignored unsafe llm bootstrap target: ${llm.targetUrl}`);
}
if (stringValue(llm.expectedDomain) && !llmDomain) {
warnings.push(`Ignored unsafe llm bootstrap domain: ${llm.expectedDomain}`);
}
if (
deterministic.targetUrl &&
llm.targetUrl &&
stringValue(deterministic.targetUrl) !== stringValue(llm.targetUrl)
) {
warnings.push(`Bootstrap target conflict: deterministic=${deterministic.targetUrl}, llm=${llm.targetUrl}`);
}
if (
deterministic.expectedDomain &&
llm.expectedDomain &&
stringValue(deterministic.expectedDomain) !== stringValue(llm.expectedDomain)
) {
warnings.push(
`Bootstrap domain conflict: deterministic=${deterministic.expectedDomain}, llm=${llm.expectedDomain}`
);
}
return merged;
}
function isUnsafeBootstrapValue(value) {
const text = stringValue(value).toLowerCase();
if (!text) return false;
return (
text.includes("localhost") ||
text.includes("127.0.0.1") ||
text.includes("surfaceservices") ||
text.includes("reportservices")
);
}
function mergeByName(primary, secondary) {
const map = new Map();
for (const item of secondary || []) {
map.set(item.name, item);
}
for (const item of primary || []) {
map.set(item.name, item);
}
return Array.from(map.values()).filter((item) => item && item.name);
}
function mergeWorkflowSteps(primary, secondary) {
const merged = [];
const seen = new Set();
for (const step of [...(primary || []), ...(secondary || [])]) {
if (!step || !step.type) continue;
const key = [step.type, step.entry || "", step.endpoint || "", step.expr || ""].join("|");
if (seen.has(key)) continue;
seen.add(key);
merged.push(step);
}
return merged;
}
function mergeWorkflowEvidence(primary, secondary) {
return {
requestEntries: uniqueStrings([...(primary?.requestEntries || []), ...(secondary?.requestEntries || [])]),
paginationFields: uniqueStrings([...(primary?.paginationFields || []), ...(secondary?.paginationFields || [])]),
secondaryRequestEntries: uniqueStrings([
...(primary?.secondaryRequestEntries || []),
...(secondary?.secondaryRequestEntries || []),
]),
postProcessSteps: uniqueStrings([...(primary?.postProcessSteps || []), ...(secondary?.postProcessSteps || [])]),
};
}
function mergeObjects(primary, secondary) {
return {
...ensureObject(secondary),
...ensureObject(primary),
};
}
function mergeEndpoints(primary, secondary) {
const map = new Map();
for (const endpoint of secondary || []) {
const key = `${endpoint.method}|${endpoint.url}`;
map.set(key, endpoint);
}
for (const endpoint of primary || []) {
const key = `${endpoint.method}|${endpoint.url}`;
map.set(key, endpoint);
}
return Array.from(map.values());
}
function mergeEvidence(primary, secondary) {
const seen = new Set();
const merged = [];
for (const item of [...(primary || []), ...(secondary || [])]) {
if (!item || !item.summary) continue;
const key = `${item.kind}|${item.evidenceType || ""}|${item.summary}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(item);
}
return merged;
}
function computeReadinessPreview(sceneIr, warnings, llmReadiness) {
const risks = [];
const missingPieces = [];
const notes = [];
const gates = [];
if (!sceneIr.sceneIdDiagnostics?.valid) {
missingPieces.push("invalid_scene_id");
risks.push(
`Scene id is invalid${sceneIr.sceneIdDiagnostics?.invalidReason ? `: ${sceneIr.sceneIdDiagnostics.invalidReason}` : "."}`
);
}
const hasUnsafeBootstrap =
isUnsafeBootstrapValue(sceneIr.bootstrap.targetUrl) || isUnsafeBootstrapValue(sceneIr.bootstrap.expectedDomain);
if (hasUnsafeBootstrap) {
missingPieces.push("bootstrap_target");
risks.push("Bootstrap resolves to localhost/helper/export instead of a business domain.");
} else if (!sceneIr.bootstrap.targetUrl && !sceneIr.bootstrap.expectedDomain) {
missingPieces.push("bootstrap_target");
risks.push("Bootstrap target/domain is missing.");
} else if (!sceneIr.bootstrap.expectedDomain) {
risks.push("Expected domain is missing.");
}
if (!sceneIr.apiEndpoints.length) {
missingPieces.push("api_endpoint");
risks.push("No API endpoint is available.");
}
if (!sceneIr.workflowSteps.length) {
missingPieces.push("workflow_steps");
risks.push("Workflow steps are incomplete.");
}
const businessEndpoints = (sceneIr.apiEndpoints || []).filter((endpoint) => !isUnsafeBootstrapValue(endpoint.url));
const requestContract = previewRequestContract(sceneIr, businessEndpoints);
if (!requestContract.passed) {
missingPieces.push(requestContract.reason || "request_contract");
risks.push(requestContract.message);
}
const responseContract = previewResponseContract(sceneIr, businessEndpoints);
if (!responseContract.passed) {
missingPieces.push(responseContract.reason || "response_contract");
risks.push(responseContract.message);
}
const workflowContract = previewWorkflowContract(sceneIr, businessEndpoints);
if (!workflowContract.passed) {
missingPieces.push(workflowContract.reason || "workflow_contract");
risks.push(workflowContract.message);
}
if (sceneIr.workflowArchetype === "multi_mode_request" && !sceneIr.modes.length) {
missingPieces.push("modes");
risks.push("Multi-mode workflow has no resolved modes.");
}
if (sceneIr.workflowArchetype === "paginated_enrichment") {
const hasPaginate =
sceneIr.workflowSteps.some((step) => step.type === "paginate") ||
(sceneIr.workflowEvidence?.paginationFields || []).length > 0;
const hasSecondary =
sceneIr.workflowSteps.some((step) => step.type === "secondary_request") ||
(sceneIr.workflowEvidence?.secondaryRequestEntries || []).length > 0;
const hasPostProcess =
sceneIr.workflowSteps.some((step) => ["filter", "transform", "export"].includes(step.type)) ||
(sceneIr.workflowEvidence?.postProcessSteps || []).length > 0;
if (!hasPaginate) {
missingPieces.push("paginate_step");
risks.push("Paginated enrichment is missing a pagination step.");
}
if (!hasSecondary || sceneIr.apiEndpoints.length < 2) {
missingPieces.push("secondary_request");
risks.push("Paginated enrichment is missing a strong secondary request path.");
}
if (!hasPostProcess) {
missingPieces.push("post_process");
risks.push("Paginated enrichment is missing filter/transform/export evidence.");
}
}
if (!sceneIr.validationHints.runtimeCompatible) {
risks.push("Some params require runtime support not confirmed by the frontend preview.");
}
gates.push({
name: "scene_id_valid",
passed: sceneIr.sceneIdDiagnostics?.valid !== false,
reason: sceneIr.sceneIdDiagnostics?.valid === false ? sceneIr.sceneIdDiagnostics.invalidReason || "invalid_scene_id" : null,
});
gates.push({
name: "bootstrap_resolved",
passed: !hasUnsafeBootstrap && Boolean(sceneIr.bootstrap.targetUrl || sceneIr.bootstrap.expectedDomain),
reason: !hasUnsafeBootstrap && Boolean(sceneIr.bootstrap.targetUrl || sceneIr.bootstrap.expectedDomain)
? null
: "bootstrap_target",
});
gates.push({
name: "request_contract_complete",
passed: requestContract.passed,
reason: requestContract.passed ? null : requestContract.reason,
});
gates.push({
name: "response_contract_complete",
passed: responseContract.passed,
reason: responseContract.passed ? null : responseContract.reason,
});
gates.push({
name: "workflow_contract_complete",
passed: workflowContract.passed,
reason: workflowContract.passed ? null : workflowContract.reason,
});
gates.push({
name: "workflow_complete_for_archetype",
passed: workflowContract.passed,
reason: workflowContract.passed ? null : workflowContract.reason,
});
gates.push({
name: "runtime_contract_compatible",
passed: sceneIr.validationHints.runtimeCompatible !== false,
reason: sceneIr.validationHints.runtimeCompatible !== false ? null : "runtime_contract_incompatible",
});
for (const warning of warnings || []) {
risks.push(warning);
}
if (llmReadiness && Array.isArray(llmReadiness.notes)) {
notes.push(...sanitizeStringList(llmReadiness.notes));
}
let level = "A";
if (missingPieces.length > 0) {
level = missingPieces.length >= 2 ? "C" : "B";
} else if (risks.length > 1 || clampConfidence(sceneIr.confidence) < 0.7) {
level = "B";
}
if (level === "A") {
notes.unshift("Ready for direct internal-network trial.");
} else if (level === "B") {
notes.unshift("Structurally plausible, but human review is recommended.");
} else {
notes.unshift("Draft only; manual completion is required before trial.");
}
return {
level,
confidence: clampConfidence(
llmReadiness?.confidence ? (sceneIr.confidence * 0.7 + llmReadiness.confidence * 0.3) : sceneIr.confidence
),
gates,
risks: uniqueStrings(risks),
missingPieces: uniqueStrings(missingPieces),
notes: uniqueStrings(notes),
};
}
function previewRequestContract(sceneIr, businessEndpoints) {
const endpointCount = (businessEndpoints || []).length;
const hasRequestStep = (sceneIr.workflowSteps || []).some((step) => ["request", "paginate", "secondary_request", "page_state"].includes(step.type));
const hasRequestEvidence = (sceneIr.workflowEvidence?.requestEntries || []).length > 0;
const hasParams = (sceneIr.params || []).length > 0;
if (sceneIr.workflowArchetype === "single_request_enrichment") {
return endpointCount >= 2 && Boolean(sceneIr.mainRequest) && (sceneIr.enrichmentRequests || []).length > 0
? { passed: true }
: {
passed: false,
reason: endpointCount >= 2 ? "main_request" : "request_endpoint",
message: endpointCount >= 2
? "G1-E workflow is missing main/enrichment request evidence."
: "G1-E workflow requires both main and enrichment business endpoints.",
};
}
if (sceneIr.workflowArchetype === "multi_mode_request") {
const hasModes = (sceneIr.modes || []).length > 0;
const hasModeSwitch = Boolean(sceneIr.modeSwitchField);
return endpointCount > 0 && hasModes && hasModeSwitch
? { passed: true }
: {
passed: false,
reason: !hasModes || !hasModeSwitch ? "request_mode_param" : "request_endpoint",
message: !hasModes || !hasModeSwitch
? "Multi-mode request is missing mode selection contract."
: "Request contract is missing business endpoint evidence.",
};
}
if (sceneIr.workflowArchetype === "paginated_enrichment") {
return endpointCount >= 2 && (hasRequestStep || hasRequestEvidence)
? { passed: true }
: {
passed: false,
reason: endpointCount >= 2 ? "request_workflow" : "request_endpoint",
message: endpointCount >= 2
? "Paginated enrichment is missing request workflow evidence."
: "Paginated enrichment requires both primary and secondary business endpoints.",
};
}
if (sceneIr.workflowArchetype === "page_state_eval") {
return hasRequestStep || endpointCount > 0
? { passed: true }
: {
passed: false,
reason: "request_workflow",
message: "Page-state evaluation is missing request/state workflow evidence.",
};
}
return endpointCount > 0 || hasRequestStep || hasRequestEvidence || hasParams
? { passed: true }
: {
passed: false,
reason: "request_endpoint",
message: "Request contract is missing business endpoint or request entry evidence.",
};
}
function previewResponseContract(sceneIr, businessEndpoints) {
const endpointCount = (businessEndpoints || []).length;
const hasTransform = (sceneIr.workflowSteps || []).some((step) => ["transform", "filter", "export"].includes(step.type));
const hasResponsePath = Boolean(sceneIr.responsePath) || (sceneIr.modes || []).some((mode) => Boolean(mode.responsePath));
if (sceneIr.workflowArchetype === "single_request_enrichment") {
const hasColumns = (sceneIr.mergePlan?.outputColumns || []).length > 0 || (sceneIr.columnDefs || []).length > 0;
return endpointCount >= 2 && Boolean(sceneIr.mergePlan) && hasColumns
? { passed: true }
: {
passed: false,
reason: endpointCount >= 2 ? "merge_plan" : "response_path",
message: endpointCount >= 2
? "G1-E workflow is missing merge/output evidence."
: "G1-E workflow lacks enough response-side endpoint evidence.",
};
}
if (sceneIr.workflowArchetype === "page_state_eval") {
return { passed: true };
}
if (sceneIr.workflowArchetype === "paginated_enrichment") {
return endpointCount >= 2 && hasResponsePath
? { passed: true }
: {
passed: false,
reason: !hasResponsePath ? "response_path" : "response_endpoint",
message: !hasResponsePath
? "Paginated enrichment is missing response extraction path."
: "Paginated enrichment lacks enough response-side endpoints.",
};
}
return hasResponsePath || hasTransform || endpointCount > 0
? { passed: true }
: {
passed: false,
reason: "response_path",
message: "Response contract is missing response extraction evidence.",
};
}
function previewWorkflowContract(sceneIr, businessEndpoints) {
const steps = sceneIr.workflowSteps || [];
if (!steps.length) {
return {
passed: false,
reason: "workflow_steps",
message: "Workflow contract is missing executable steps.",
};
}
if (sceneIr.workflowArchetype === "single_request_enrichment") {
const hasRequest = steps.some((step) => step.type === "request");
const hasEnrichment = steps.some((step) => step.type === "enrichment_request");
const hasTransform = steps.some((step) => step.type === "transform");
return hasRequest && hasEnrichment && hasTransform && Boolean(sceneIr.mergePlan)
? { passed: true }
: {
passed: false,
reason: !hasRequest ? "workflow_request" : !hasEnrichment ? "enrichment_requests" : !hasTransform ? "workflow_transform" : "merge_plan",
message: "G1-E workflow requires request, enrichment_request, transform, and merge_plan.",
};
}
if (sceneIr.workflowArchetype === "multi_mode_request") {
const hasRequest = steps.some((step) => step.type === "request");
const hasTransform = steps.some((step) => step.type === "transform");
return hasRequest && hasTransform
? { passed: true }
: {
passed: false,
reason: !hasRequest ? "workflow_request" : "workflow_transform",
message: "Multi-mode request requires request and transform steps.",
};
}
if (sceneIr.workflowArchetype === "paginated_enrichment") {
const hasPaginate =
steps.some((step) => step.type === "paginate") ||
(sceneIr.workflowEvidence?.paginationFields || []).length > 0;
const hasSecondary =
steps.some((step) => step.type === "secondary_request") ||
(sceneIr.workflowEvidence?.secondaryRequestEntries || []).length > 0;
const hasPostProcess =
steps.some((step) => ["filter", "transform", "export"].includes(step.type)) ||
(sceneIr.workflowEvidence?.postProcessSteps || []).length > 0;
if (!hasPaginate) {
return {
passed: false,
reason: "paginate_step",
message: "Paginated enrichment is missing a pagination step.",
};
}
if (!hasSecondary || (businessEndpoints || []).length < 2) {
return {
passed: false,
reason: "secondary_request",
message: "Paginated enrichment is missing a strong secondary request path.",
};
}
if (!hasPostProcess) {
return {
passed: false,
reason: "post_process",
message: "Paginated enrichment is missing filter/transform/export evidence.",
};
}
}
return { passed: true };
}
function getGenerationBlockers({ sceneIr, sceneId, sceneName, sourceDir }) {
const blockers = [];
const validation = validateSceneIdCandidate(sceneId, { sceneName, sourceDir });
if (!validation.valid) {
blockers.push(`invalid_scene_id:${validation.reason}`);
}
if (sceneIr?.sceneIdDiagnostics && sceneIr.sceneIdDiagnostics.valid === false) {
blockers.push(
`analysis_invalid_scene_id:${sceneIr.sceneIdDiagnostics.invalidReason || "invalid_scene_id"}`
);
}
for (const gate of sceneIr?.readiness?.gates || []) {
if (!gate.passed) {
blockers.push(`gate_failed:${gate.name}${gate.reason ? `:${gate.reason}` : ""}`);
}
}
return uniqueStrings(blockers);
}
function ensureObject(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function sanitizeStringList(value) {
if (!Array.isArray(value)) return [];
return uniqueStrings(value.map((item) => stringValue(item)).filter(Boolean));
}
function uniqueStrings(list) {
return Array.from(new Set((list || []).map((item) => stringValue(item)).filter(Boolean)));
}
function clampConfidence(value) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(1, Number(numeric.toFixed(2))));
}
function normalizeInputPath(value) {
const normalized = stringValue(value);
return normalized ? normalized.replace(/\\/g, "/") : "";
}
function normalizeJsonInput(value) {
if (!value) return null;
if (typeof value === "string") return value;
if (typeof value === "object") return JSON.stringify(value);
return null;
}
function stringValue(value) {
return typeof value === "string" ? value.trim() : "";
}
function createServer() {
return http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
try {
if (pathname === "/health" && req.method === "GET") {
handleHealth(req, res);
return;
}
if (pathname === "/analyze" && req.method === "POST") {
await handleAnalyze(req, res);
return;
}
if (pathname === "/analyze-deep" && req.method === "POST") {
await handleAnalyzeDeep(req, res);
return;
}
if (pathname === "/generate" && req.method === "POST") {
await handleGenerate(req, res);
return;
}
if (pathname === "/select-folder" && req.method === "POST") {
await handleSelectFolder(req, res);
return;
}
if (pathname === "/select-file" && req.method === "POST") {
await handleSelectFile(req, res);
return;
}
if (pathname === "/" || pathname === "/index.html") {
serveStatic(res, path.join(__dirname, "sg_scene_generator.html"));
return;
}
const filePath = path.resolve(__dirname, "." + pathname);
const baseDir = path.resolve(__dirname);
if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
writeJson(res, 403, { error: "Forbidden" });
return;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
serveStatic(res, filePath);
return;
}
writeJson(res, 404, { error: "Not found" });
} catch (error) {
console.error(`[error] ${req.method} ${pathname}: ${error.message}`);
if (!res.headersSent) {
writeJson(res, 500, { error: error.message });
return;
}
res.end(JSON.stringify({ error: error.message }));
}
});
}
if (require.main === module) {
const server = createServer();
server.listen(PORT, HOST, () => {
console.log("");
console.log(" ================================================");
console.log(" sgClaw Scene Skill Generator");
console.log(` http://${HOST}:${PORT}/`);
console.log(" Press Ctrl+C to stop");
console.log(" ================================================");
console.log("");
});
process.on("SIGINT", () => {
if (server.closing) return;
server.closing = true;
console.log("\n[info] Shutting down...");
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 2000);
});
}
module.exports = {
computeReadinessPreview,
createServer,
getGenerationBlockers,
mergeSceneIr,
sanitizeSceneIr,
sanitizeSceneIdDiagnostics,
};