1376 lines
46 KiB
JavaScript
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,
|
|
};
|