Identify and push js/ directory business logic files (mca.js, sgApi.js, etc.) to the LLM prompt. Exclude third-party libraries. Capped at 4 fragments to stay within MAX_DEEP_PROMPT_CHARS budget. 🤖 Generated with [Qoder][https://qoder.com]
825 lines
29 KiB
JavaScript
825 lines
29 KiB
JavaScript
const http = require("http");
|
||
const https = require("https");
|
||
const { validateSceneIdCandidate } = require("./generator-runner");
|
||
|
||
const SYSTEM_PROMPT = `You analyze a scene source directory and return compact JSON with sceneId and sceneName only.
|
||
Rules:
|
||
- sceneId: lowercase kebab-case, business meaningful
|
||
- never return a numeric-only, version-only, or placeholder sceneId such as 2-0, scene, report
|
||
- sceneName: concise display name
|
||
- return JSON only`;
|
||
|
||
const MAX_DEEP_PROMPT_CHARS = 60000;
|
||
const MAX_JSON_SECTION_CHARS = 8000;
|
||
const JSON_REPAIR_MAX_CHARS = 24000;
|
||
const DEFAULT_REQUEST_TIMEOUT_MS = 90000;
|
||
const DEEP_REQUEST_TIMEOUT_MS = 150000;
|
||
const JSON_REPAIR_TIMEOUT_MS = 120000;
|
||
|
||
const DEEP_SYSTEM_PROMPT = `You are a workflow-semantics extractor for scene skill compilation.
|
||
Return JSON only.
|
||
Use the provided deterministic signals as hard facts.
|
||
Do not invent endpoints, domains, parameters, or workflow steps that are not supported by the input.
|
||
Prefer filling missing semantics, labels, descriptions, responsePath, requestTemplate shape, normalizeRules, workflowArchetype, confidence, uncertainties, evidence, and readiness notes.
|
||
Schema:
|
||
{
|
||
"sceneId": "string",
|
||
"sceneName": "string",
|
||
"sceneKind": "report_collection|monitoring",
|
||
"workflowArchetype": "single_request_table|multi_mode_request|paginated_enrichment|page_state_eval",
|
||
"bootstrap": {
|
||
"expectedDomain": "string",
|
||
"targetUrl": "string",
|
||
"requiresTargetPage": true,
|
||
"pageTitleKeywords": ["string"],
|
||
"source": "llm"
|
||
},
|
||
"params": [
|
||
{
|
||
"name": "string",
|
||
"resolver": "dictionary_entity|month_week_period|fixed_enum|literal_passthrough",
|
||
"required": true,
|
||
"promptMissing": "string",
|
||
"promptAmbiguous": "string",
|
||
"resolverConfig": {}
|
||
}
|
||
],
|
||
"modes": [
|
||
{
|
||
"name": "string",
|
||
"label": "string",
|
||
"condition": { "field": "string", "operator": "equals", "value": "string" },
|
||
"apiEndpoint": { "name": "string", "url": "string", "method": "POST", "contentType": "string", "description": "string" },
|
||
"columnDefs": [["field", "label"]],
|
||
"requestTemplate": {},
|
||
"normalizeRules": { "type": "validate_required", "requiredFields": ["string"], "filterNull": true },
|
||
"responsePath": "string"
|
||
}
|
||
],
|
||
"defaultMode": "string|null",
|
||
"modeSwitchField": "string|null",
|
||
"workflowSteps": [
|
||
{ "type": "request|paginate|secondary_request|filter|transform|export|page_state", "entry": "string", "endpoint": "string", "expr": "string", "description": "string" }
|
||
],
|
||
"workflowEvidence": {
|
||
"requestEntries": ["string"],
|
||
"paginationFields": ["string"],
|
||
"secondaryRequestEntries": ["string"],
|
||
"postProcessSteps": ["string"]
|
||
},
|
||
"requestTemplate": {},
|
||
"responsePath": "string",
|
||
"normalizeRules": { "type": "validate_required", "requiredFields": ["string"], "filterNull": true },
|
||
"artifactContract": { "type": "report-artifact", "successStatus": ["ok"], "failureStatus": ["blocked", "error"] },
|
||
"validationHints": { "requiresTargetPage": true, "runtimeCompatible": true, "manualCompletionRequired": false, "missingPieces": ["string"] },
|
||
"evidence": [{ "kind": "llm_semantic", "summary": "string", "source": "llm", "confidence": 0.0 }],
|
||
"readiness": { "level": "A|B|C", "confidence": 0.0, "risks": ["string"], "missingPieces": ["string"], "notes": ["string"] },
|
||
"apiEndpoints": [{ "name": "string", "url": "string", "method": "POST", "contentType": "string", "description": "string" }],
|
||
"staticParams": {},
|
||
"columnDefs": [["field", "label"]],
|
||
"confidence": 0.0,
|
||
"uncertainties": ["string"]
|
||
}
|
||
|
||
MANDATORY FIELDS (never leave empty):
|
||
- apiEndpoints[].contentType: detect from source code.
|
||
* For $.ajax({}): look for 'contentType' property. Default 'application/json' if absent.
|
||
* For $http.sendByAxios(): contentType is 'application/json' (axios default).
|
||
* For XMLHttpRequest: look for setRequestHeader('Content-Type', ...).
|
||
* For form submissions: 'application/x-www-form-urlencoded'.
|
||
- modes[].responsePath: the JSON path from raw API response to the data array.
|
||
* Common patterns: 'data.list', 'data.rcvblAcctSumAll.rcvblAcctVOS', 'content', 'data.records'
|
||
* If response is the array itself, use empty string "".
|
||
- modes[].requestTemplate: the static request body shape from the source code.
|
||
* Extract ALL keys that appear in the request body object.
|
||
* Mark dynamic values as "\${args.fieldName}" and static values as literals.
|
||
- apiEndpoints[].url: the full API URL as seen in the source code.
|
||
|
||
RULES:
|
||
- If you cannot determine contentType, default to 'application/json'.
|
||
- If you cannot determine responsePath, default to '' (empty string).
|
||
- If you cannot determine requestTemplate, use {} (empty object).
|
||
- NEVER leave these fields as null or undefined.`;
|
||
|
||
const JSON_REPAIR_SYSTEM_PROMPT = `You repair malformed JSON.
|
||
Rules:
|
||
- Return JSON only.
|
||
- Keep the original data as much as possible.
|
||
- Do not add explanations or markdown fences.
|
||
- Fix syntax only: commas, quotes, brackets, braces, trailing commas, duplicated fence text.
|
||
- If a field is unrecoverable, keep it empty instead of inventing content.`;
|
||
|
||
function buildAnalyzePrompt(sourceDir, dirContents) {
|
||
const parts = [];
|
||
parts.push(`Source: ${sourceDir}`);
|
||
parts.push("Directory tree:");
|
||
parts.push(dirContents.tree || "(empty)");
|
||
|
||
if (dirContents["scene.toml"]) {
|
||
parts.push("\nscene.toml:");
|
||
parts.push(truncate(dirContents["scene.toml"], 3000));
|
||
}
|
||
|
||
if (dirContents["SKILL.toml"]) {
|
||
parts.push("\nSKILL.toml:");
|
||
parts.push(truncate(dirContents["SKILL.toml"], 2000));
|
||
}
|
||
|
||
if (dirContents["SKILL.md"]) {
|
||
parts.push("\nSKILL.md:");
|
||
parts.push(truncate(dirContents["SKILL.md"], 2000));
|
||
}
|
||
|
||
const keyScripts = Object.entries(dirContents.scripts || {}).slice(0, 6);
|
||
if (keyScripts.length > 0) {
|
||
parts.push("\nKey scripts:");
|
||
for (const [name, content] of keyScripts) {
|
||
parts.push(`--- ${name} ---`);
|
||
parts.push(truncate(content, 1200));
|
||
}
|
||
}
|
||
|
||
return `${parts.join("\n")}\n\nReturn JSON only: {"sceneId":"...","sceneName":"..."}`;
|
||
}
|
||
|
||
function buildDeepAnalyzePrompt(sourceDir, dirContents) {
|
||
const context = dirContents.analysisContext || {};
|
||
const deterministic = dirContents.deterministic || {};
|
||
const parts = [];
|
||
|
||
parts.push(`Source: ${sourceDir}`);
|
||
parts.push("Directory summary:");
|
||
parts.push(stringifyForPrompt(compactDirectorySummary(context.directorySummary || {}), 4000));
|
||
|
||
parts.push("\nDeterministic candidate Scene IR:");
|
||
parts.push(stringifyForPrompt(compactDeterministicSceneIr(stripForPrompt(deterministic)), MAX_JSON_SECTION_CHARS));
|
||
|
||
parts.push("\nBootstrap hints:");
|
||
parts.push(stringifyForPrompt((context.bootstrapHints || []).slice(0, 6), 2000));
|
||
|
||
pushFragments(parts, "index.html chunks", context.indexHtmlChunks, 2);
|
||
pushFragments(parts, "URL-bearing fragments", context.urlFragments, 8);
|
||
pushFragments(parts, "Request-construction fragments", context.requestFragments, 8);
|
||
pushFragments(parts, "Branching fragments", context.branchingFragments, 6);
|
||
pushFragments(parts, "Response/normalization fragments", context.responseFragments, 6);
|
||
pushFragments(parts, "Export fragments", context.exportFragments, 4);
|
||
pushFragments(parts, "business JS files", context.businessJsFragments, 4);
|
||
|
||
parts.push(`
|
||
Instructions:
|
||
- Keep deterministic facts unless there is very strong contrary evidence in the snippets.
|
||
- Fill in semantic descriptions, labels, workflow steps, mode intent, readiness notes, and missing request/response structure.
|
||
- Never downgrade a Chinese business scene into a low-entropy sceneId such as 2-0, 1-0, scene, or report.
|
||
- If unsure, leave fields empty or add an uncertainty instead of guessing.
|
||
- Preserve the schema shape exactly.
|
||
- Return JSON only.`);
|
||
|
||
return truncate(parts.join("\n"), MAX_DEEP_PROMPT_CHARS);
|
||
}
|
||
|
||
function pushFragments(parts, title, fragments, limit) {
|
||
parts.push(`\n${title}:`);
|
||
const selected = Array.isArray(fragments) ? fragments.slice(0, limit) : [];
|
||
if (!selected.length) {
|
||
parts.push("[]");
|
||
return;
|
||
}
|
||
|
||
for (const fragment of selected) {
|
||
parts.push(
|
||
stringifyForPrompt(
|
||
{
|
||
path: fragment.path,
|
||
lineStart: fragment.lineStart,
|
||
lineEnd: fragment.lineEnd,
|
||
snippet: truncate(fragment.snippet || fragment.content, 1200),
|
||
},
|
||
1400
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
function stringifyForPrompt(value, maxChars) {
|
||
return truncate(JSON.stringify(value, null, 2), maxChars);
|
||
}
|
||
|
||
function compactDirectorySummary(summary) {
|
||
const files = Array.isArray(summary.files) ? summary.files.slice(0, 24) : [];
|
||
return {
|
||
sourceDir: summary.sourceDir || "",
|
||
tree: truncate(summary.tree || "", 2500),
|
||
files,
|
||
fileCount: Array.isArray(summary.files) ? summary.files.length : files.length,
|
||
};
|
||
}
|
||
|
||
function compactDeterministicSceneIr(sceneIr) {
|
||
const value = sceneIr && typeof sceneIr === "object" ? JSON.parse(JSON.stringify(sceneIr)) : {};
|
||
value.evidence = Array.isArray(value.evidence) ? value.evidence.slice(0, 8) : [];
|
||
value.apiEndpoints = Array.isArray(value.apiEndpoints) ? value.apiEndpoints.slice(0, 8) : [];
|
||
value.params = Array.isArray(value.params) ? value.params.slice(0, 8) : [];
|
||
value.modes = Array.isArray(value.modes) ? value.modes.slice(0, 4) : [];
|
||
value.workflowSteps = Array.isArray(value.workflowSteps) ? value.workflowSteps.slice(0, 8) : [];
|
||
value.columnDefs = Array.isArray(value.columnDefs) ? value.columnDefs.slice(0, 12) : [];
|
||
value.uncertainties = Array.isArray(value.uncertainties) ? value.uncertainties.slice(0, 8) : [];
|
||
value.readiness = value.readiness && typeof value.readiness === "object"
|
||
? {
|
||
level: value.readiness.level,
|
||
confidence: value.readiness.confidence,
|
||
risks: Array.isArray(value.readiness.risks) ? value.readiness.risks.slice(0, 8) : [],
|
||
missingPieces: Array.isArray(value.readiness.missingPieces) ? value.readiness.missingPieces.slice(0, 8) : [],
|
||
notes: Array.isArray(value.readiness.notes) ? value.readiness.notes.slice(0, 6) : [],
|
||
}
|
||
: value.readiness;
|
||
return value;
|
||
}
|
||
|
||
function truncate(text, maxLength) {
|
||
const value = typeof text === "string" ? text : String(text || "");
|
||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||
}
|
||
|
||
function stripForPrompt(sceneIr) {
|
||
if (!sceneIr || typeof sceneIr !== "object") return {};
|
||
const clone = JSON.parse(JSON.stringify(sceneIr));
|
||
delete clone.deterministicSignals;
|
||
return clone;
|
||
}
|
||
|
||
function requestChatCompletion(
|
||
messages,
|
||
{ apiKey, baseUrl, model, maxTokens = 1024, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS }
|
||
) {
|
||
const requestBody = JSON.stringify({
|
||
model,
|
||
messages,
|
||
temperature: 0.1,
|
||
max_tokens: maxTokens,
|
||
});
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const url = new URL(baseUrl.replace(/\/v1\/?$/, "") + "/v1/chat/completions");
|
||
const options = {
|
||
hostname: url.hostname,
|
||
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
||
path: url.pathname,
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${apiKey}`,
|
||
"Content-Length": Buffer.byteLength(requestBody),
|
||
},
|
||
};
|
||
|
||
const transport = url.protocol === "https:" ? https : http;
|
||
const req = transport.request(options, (res) => {
|
||
let data = "";
|
||
res.on("data", (chunk) => {
|
||
data += chunk;
|
||
});
|
||
res.on("end", () => {
|
||
if (res.statusCode !== 200) {
|
||
reject(new Error(`LLM API error ${res.statusCode}: ${data}`));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
const content = parsed.choices?.[0]?.message?.content;
|
||
if (!content) {
|
||
reject(new Error("LLM returned empty response"));
|
||
return;
|
||
}
|
||
resolve(content);
|
||
} catch (error) {
|
||
reject(new Error(`Failed to parse LLM transport response: ${error.message}`));
|
||
}
|
||
});
|
||
});
|
||
|
||
req.on("error", reject);
|
||
req.setTimeout(timeoutMs, () => {
|
||
req.destroy(new Error("LLM API request timed out"));
|
||
});
|
||
|
||
req.write(requestBody);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
async function requestChatCompletionWithRetry(messages, options) {
|
||
const maxAttempts = Number.isFinite(options?.retryAttempts) ? options.retryAttempts : 2;
|
||
let lastError = null;
|
||
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
try {
|
||
return await requestChatCompletion(messages, options);
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (!isRetryableLlmError(error) || attempt === maxAttempts) {
|
||
throw error;
|
||
}
|
||
await sleep(600 * attempt);
|
||
}
|
||
}
|
||
|
||
throw lastError || new Error("LLM request failed");
|
||
}
|
||
|
||
function isRetryableLlmError(error) {
|
||
const message = String(error?.message || "").toLowerCase();
|
||
return (
|
||
message.includes("timed out") ||
|
||
message.includes("timeout") ||
|
||
message.includes("429") ||
|
||
message.includes("502") ||
|
||
message.includes("503") ||
|
||
message.includes("504") ||
|
||
message.includes("socket hang up") ||
|
||
message.includes("econnreset") ||
|
||
message.includes("etimedout")
|
||
);
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function extractJsonFromResponse(text) {
|
||
const candidates = extractJsonCandidates(text);
|
||
let lastError = null;
|
||
|
||
for (const candidate of candidates) {
|
||
try {
|
||
return JSON.parse(candidate);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
|
||
const repaired = repairCommonJsonIssues(candidate);
|
||
if (repaired && repaired !== candidate) {
|
||
try {
|
||
return JSON.parse(repaired);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
}
|
||
|
||
throw lastError || new Error("Unable to parse JSON response");
|
||
}
|
||
|
||
function extractJsonCandidates(text) {
|
||
const raw = typeof text === "string" ? text : String(text || "");
|
||
const candidates = [];
|
||
const codeBlockMatch = raw.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
||
if (codeBlockMatch) {
|
||
candidates.push(codeBlockMatch[1]);
|
||
}
|
||
|
||
const objectStart = raw.indexOf("{");
|
||
const objectEnd = raw.lastIndexOf("}");
|
||
if (objectStart !== -1 && objectEnd !== -1 && objectEnd > objectStart) {
|
||
candidates.push(raw.slice(objectStart, objectEnd + 1));
|
||
}
|
||
|
||
const arrayStart = raw.indexOf("[");
|
||
const arrayEnd = raw.lastIndexOf("]");
|
||
if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
|
||
candidates.push(raw.slice(arrayStart, arrayEnd + 1));
|
||
}
|
||
|
||
candidates.push(raw);
|
||
return Array.from(new Set(candidates.map((value) => value.trim()).filter(Boolean)));
|
||
}
|
||
|
||
function repairCommonJsonIssues(text) {
|
||
let value = typeof text === "string" ? text : String(text || "");
|
||
if (!value) return value;
|
||
|
||
value = value
|
||
.replace(/^\uFEFF/, "")
|
||
.replace(/[“”]/g, '"')
|
||
.replace(/[‘’]/g, "'")
|
||
.replace(/```(?:json)?/gi, "")
|
||
.replace(/```/g, "")
|
||
.replace(/\r\n/g, "\n");
|
||
|
||
value = stripJsonComments(value);
|
||
value = removeTrailingCommas(value);
|
||
value = insertMissingArrayCommas(value);
|
||
value = insertMissingObjectCommas(value);
|
||
return value.trim();
|
||
}
|
||
|
||
function stripJsonComments(text) {
|
||
return text
|
||
.replace(/^\s*\/\/.*$/gm, "")
|
||
.replace(/\/\*[\s\S]*?\*\//g, "");
|
||
}
|
||
|
||
function removeTrailingCommas(text) {
|
||
let output = text;
|
||
let previous = null;
|
||
while (output !== previous) {
|
||
previous = output;
|
||
output = output.replace(/,\s*([}\]])/g, "$1");
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function insertMissingArrayCommas(text) {
|
||
return text
|
||
.replace(/}\s*{/g, "},{")
|
||
.replace(/]\s*{/g, "],{")
|
||
.replace(/}\s*\[/g, "},[")
|
||
.replace(/"\s*{/g, '",{')
|
||
.replace(/}\s*"/g, '},"')
|
||
.replace(/]\s*"/g, '],"')
|
||
.replace(/"\s*\[/g, '",[');
|
||
}
|
||
|
||
function insertMissingObjectCommas(text) {
|
||
return text
|
||
.replace(/([0-9}\]"'])\s+("([A-Za-z0-9_]+)"\s*:)/g, "$1,$2")
|
||
.replace(/(true|false|null)\s+("([A-Za-z0-9_]+)"\s*:)/g, "$1,$2");
|
||
}
|
||
|
||
async function extractJsonFromResponseWithRepair(text, config) {
|
||
try {
|
||
return extractJsonFromResponse(text);
|
||
} catch (error) {
|
||
const malformed = extractJsonCandidates(text)[0] || String(text || "");
|
||
if (!config || !config.apiKey || !config.baseUrl || !config.model) {
|
||
throw error;
|
||
}
|
||
|
||
const repairPrompt = [
|
||
"Repair this malformed JSON and return valid JSON only.",
|
||
"",
|
||
`Original parse error: ${error.message}`,
|
||
"",
|
||
truncate(malformed, JSON_REPAIR_MAX_CHARS),
|
||
].join("\n");
|
||
|
||
const repairedContent = await requestChatCompletionWithRetry(
|
||
[
|
||
{ role: "system", content: JSON_REPAIR_SYSTEM_PROMPT },
|
||
{ role: "user", content: repairPrompt },
|
||
],
|
||
{
|
||
...config,
|
||
maxTokens: 2600,
|
||
timeoutMs: JSON_REPAIR_TIMEOUT_MS,
|
||
retryAttempts: 2,
|
||
}
|
||
);
|
||
|
||
try {
|
||
return extractJsonFromResponse(repairedContent);
|
||
} catch (repairError) {
|
||
throw new Error(`${error.message}; repair failed: ${repairError.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeSceneIr(input) {
|
||
const sceneIr = input && typeof input === "object" ? input : {};
|
||
return {
|
||
sceneId: safeString(sceneIr.sceneId),
|
||
sceneIdDiagnostics: normalizeSceneIdDiagnostics(sceneIr.sceneIdDiagnostics),
|
||
sceneName: safeString(sceneIr.sceneName),
|
||
sceneKind: safeString(sceneIr.sceneKind) || "report_collection",
|
||
workflowArchetype: safeString(sceneIr.workflowArchetype),
|
||
bootstrap: normalizeBootstrap(sceneIr.bootstrap),
|
||
params: Array.isArray(sceneIr.params) ? sceneIr.params.map(normalizeParam) : [],
|
||
modes: Array.isArray(sceneIr.modes) ? sceneIr.modes.map(normalizeMode) : [],
|
||
defaultMode: sceneIr.defaultMode || null,
|
||
modeSwitchField: sceneIr.modeSwitchField || null,
|
||
workflowSteps: Array.isArray(sceneIr.workflowSteps) ? sceneIr.workflowSteps.map(normalizeWorkflowStep) : [],
|
||
workflowEvidence: normalizeWorkflowEvidence(sceneIr.workflowEvidence),
|
||
requestTemplate: normalizeObject(sceneIr.requestTemplate),
|
||
responsePath: safeString(sceneIr.responsePath),
|
||
normalizeRules: normalizeNormalizeRules(sceneIr.normalizeRules),
|
||
artifactContract: normalizeArtifactContract(sceneIr.artifactContract),
|
||
validationHints: normalizeValidationHints(sceneIr.validationHints),
|
||
evidence: Array.isArray(sceneIr.evidence) ? sceneIr.evidence.map(normalizeEvidence) : [],
|
||
readiness: normalizeReadiness(sceneIr.readiness),
|
||
apiEndpoints: Array.isArray(sceneIr.apiEndpoints) ? sceneIr.apiEndpoints.map(normalizeApiEndpoint) : [],
|
||
staticParams: normalizeObject(sceneIr.staticParams),
|
||
columnDefs: Array.isArray(sceneIr.columnDefs) ? sceneIr.columnDefs : [],
|
||
confidence: normalizeConfidence(sceneIr.confidence),
|
||
uncertainties: Array.isArray(sceneIr.uncertainties)
|
||
? sceneIr.uncertainties.map((item) => safeString(item)).filter(Boolean)
|
||
: [],
|
||
};
|
||
}
|
||
|
||
function normalizeWorkflowEvidence(value) {
|
||
const item = value && typeof value === "object" ? value : {};
|
||
return {
|
||
requestEntries: Array.isArray(item.requestEntries)
|
||
? item.requestEntries.map((entry) => safeString(entry)).filter(Boolean)
|
||
: [],
|
||
paginationFields: Array.isArray(item.paginationFields)
|
||
? item.paginationFields.map((entry) => safeString(entry)).filter(Boolean)
|
||
: [],
|
||
secondaryRequestEntries: Array.isArray(item.secondaryRequestEntries)
|
||
? item.secondaryRequestEntries.map((entry) => safeString(entry)).filter(Boolean)
|
||
: [],
|
||
postProcessSteps: Array.isArray(item.postProcessSteps)
|
||
? item.postProcessSteps.map((entry) => safeString(entry)).filter(Boolean)
|
||
: [],
|
||
};
|
||
}
|
||
|
||
function normalizeSceneIdDiagnostics(value) {
|
||
const item = value && typeof value === "object" ? value : {};
|
||
return {
|
||
candidateSource: safeString(item.candidateSource),
|
||
valid: item.valid !== false,
|
||
invalidReason: safeString(item.invalidReason) || null,
|
||
candidates: Array.isArray(item.candidates)
|
||
? item.candidates
|
||
.map((candidate) => ({
|
||
value: safeString(candidate?.value),
|
||
source: safeString(candidate?.source),
|
||
valid: candidate?.valid !== false,
|
||
reason: safeString(candidate?.reason) || null,
|
||
}))
|
||
.filter((candidate) => candidate.value)
|
||
: [],
|
||
};
|
||
}
|
||
|
||
function normalizeBootstrap(bootstrap) {
|
||
const value = bootstrap && typeof bootstrap === "object" ? bootstrap : {};
|
||
return {
|
||
expectedDomain: safeString(value.expectedDomain),
|
||
targetUrl: safeString(value.targetUrl),
|
||
requiresTargetPage: value.requiresTargetPage !== false,
|
||
pageTitleKeywords: Array.isArray(value.pageTitleKeywords)
|
||
? value.pageTitleKeywords.map((item) => safeString(item)).filter(Boolean)
|
||
: [],
|
||
source: safeString(value.source),
|
||
};
|
||
}
|
||
|
||
function normalizeParam(param) {
|
||
const value = param && typeof param === "object" ? param : {};
|
||
return {
|
||
name: safeString(value.name),
|
||
resolver: safeString(value.resolver),
|
||
required: Boolean(value.required),
|
||
promptMissing: safeString(value.promptMissing),
|
||
promptAmbiguous: safeString(value.promptAmbiguous),
|
||
resolverConfig: normalizeObject(value.resolverConfig),
|
||
};
|
||
}
|
||
|
||
function normalizeMode(mode) {
|
||
const value = mode && typeof mode === "object" ? mode : {};
|
||
return {
|
||
name: safeString(value.name),
|
||
label: safeString(value.label) || null,
|
||
condition: value.condition && typeof value.condition === "object"
|
||
? {
|
||
field: safeString(value.condition.field),
|
||
operator: safeString(value.condition.operator) || "equals",
|
||
value: value.condition.value,
|
||
}
|
||
: null,
|
||
apiEndpoint: normalizeApiEndpoint(value.apiEndpoint),
|
||
columnDefs: Array.isArray(value.columnDefs) ? value.columnDefs : [],
|
||
requestTemplate: normalizeObject(value.requestTemplate),
|
||
normalizeRules: normalizeNormalizeRules(value.normalizeRules),
|
||
responsePath: safeString(value.responsePath),
|
||
};
|
||
}
|
||
|
||
function normalizeWorkflowStep(step) {
|
||
const value = step && typeof step === "object" ? step : {};
|
||
return {
|
||
type: safeString(value.type),
|
||
entry: safeString(value.entry) || null,
|
||
source: safeString(value.source) || null,
|
||
expr: safeString(value.expr) || null,
|
||
description: safeString(value.description) || null,
|
||
endpoint: safeString(value.endpoint) || null,
|
||
};
|
||
}
|
||
|
||
function normalizeNormalizeRules(rules) {
|
||
const value = rules && typeof rules === "object" ? rules : {};
|
||
const normalized = {
|
||
type: safeString(value.type),
|
||
requiredFields: Array.isArray(value.requiredFields)
|
||
? value.requiredFields.map((item) => safeString(item)).filter(Boolean)
|
||
: [],
|
||
filterNull: value.filterNull !== false,
|
||
};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
if (!(key in normalized)) {
|
||
normalized[key] = item;
|
||
}
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function normalizeArtifactContract(contract) {
|
||
const value = contract && typeof contract === "object" ? contract : {};
|
||
return {
|
||
type: safeString(value.type) || "report-artifact",
|
||
successStatus: Array.isArray(value.successStatus)
|
||
? value.successStatus.map((item) => safeString(item)).filter(Boolean)
|
||
: ["ok", "partial", "empty"],
|
||
failureStatus: Array.isArray(value.failureStatus)
|
||
? value.failureStatus.map((item) => safeString(item)).filter(Boolean)
|
||
: ["blocked", "error"],
|
||
};
|
||
}
|
||
|
||
function normalizeValidationHints(hints) {
|
||
const value = hints && typeof hints === "object" ? hints : {};
|
||
return {
|
||
requiresTargetPage: value.requiresTargetPage !== false,
|
||
runtimeCompatible: value.runtimeCompatible !== false,
|
||
manualCompletionRequired: Boolean(value.manualCompletionRequired),
|
||
missingPieces: Array.isArray(value.missingPieces)
|
||
? value.missingPieces.map((item) => safeString(item)).filter(Boolean)
|
||
: [],
|
||
};
|
||
}
|
||
|
||
function normalizeEvidence(item) {
|
||
const value = item && typeof item === "object" ? item : {};
|
||
return {
|
||
kind: safeString(value.kind),
|
||
summary: safeString(value.summary),
|
||
source: safeString(value.source) || null,
|
||
confidence: normalizeConfidence(value.confidence),
|
||
};
|
||
}
|
||
|
||
function normalizeReadiness(readiness) {
|
||
const value = readiness && typeof readiness === "object" ? readiness : {};
|
||
return {
|
||
level: safeString(value.level),
|
||
confidence: normalizeConfidence(value.confidence),
|
||
gates: Array.isArray(value.gates)
|
||
? value.gates
|
||
.map((gate) => ({
|
||
name: safeString(gate?.name),
|
||
passed: Boolean(gate?.passed),
|
||
reason: safeString(gate?.reason) || null,
|
||
}))
|
||
.filter((gate) => gate.name)
|
||
: [],
|
||
risks: Array.isArray(value.risks) ? value.risks.map((item) => safeString(item)).filter(Boolean) : [],
|
||
missingPieces: Array.isArray(value.missingPieces)
|
||
? value.missingPieces.map((item) => safeString(item)).filter(Boolean)
|
||
: [],
|
||
notes: Array.isArray(value.notes) ? value.notes.map((item) => safeString(item)).filter(Boolean) : [],
|
||
};
|
||
}
|
||
|
||
function normalizeApiEndpoint(endpoint) {
|
||
const value = endpoint && typeof endpoint === "object" ? endpoint : {};
|
||
const url = safeString(value.url);
|
||
if (!url) return null;
|
||
return {
|
||
name: safeString(value.name) || inferNameFromUrl(url),
|
||
url,
|
||
method: safeString(value.method).toUpperCase() || "GET",
|
||
contentType: safeString(value.contentType) || null,
|
||
description: safeString(value.description) || null,
|
||
};
|
||
}
|
||
|
||
function inferNameFromUrl(url) {
|
||
const parts = url.split(/[/?#]/).filter(Boolean);
|
||
return parts[parts.length - 1] || "endpoint";
|
||
}
|
||
|
||
function normalizeObject(value) {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||
}
|
||
|
||
function normalizeConfidence(value) {
|
||
const number = typeof value === "number" ? value : Number(value);
|
||
if (!Number.isFinite(number)) return 0;
|
||
return Math.max(0, Math.min(1, Number(number.toFixed(2))));
|
||
}
|
||
|
||
function safeString(value) {
|
||
return typeof value === "string" ? value.trim() : "";
|
||
}
|
||
|
||
async function analyzeScene(sourceDir, dirContents, config) {
|
||
const content = await requestChatCompletionWithRetry(
|
||
[
|
||
{ role: "system", content: SYSTEM_PROMPT },
|
||
{ role: "user", content: buildAnalyzePrompt(sourceDir, dirContents) },
|
||
],
|
||
{
|
||
...config,
|
||
maxTokens: 256,
|
||
timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
|
||
retryAttempts: 2,
|
||
}
|
||
);
|
||
|
||
const result = await extractJsonFromResponseWithRepair(content, config);
|
||
if (!result.sceneId || !result.sceneName) {
|
||
throw new Error(`LLM response missing sceneId/sceneName: ${content}`);
|
||
}
|
||
const validation = validateSceneIdCandidate(result.sceneId, {
|
||
sceneName: result.sceneName,
|
||
sourceDir,
|
||
});
|
||
if (!validation.valid) {
|
||
throw new Error(`LLM returned invalid sceneId (${validation.reason}): ${result.sceneId}`);
|
||
}
|
||
return {
|
||
sceneId: result.sceneId,
|
||
sceneName: result.sceneName,
|
||
};
|
||
}
|
||
|
||
async function analyzeSceneDeep(sourceDir, dirContents, config) {
|
||
const content = await requestChatCompletionWithRetry(
|
||
[
|
||
{ role: "system", content: DEEP_SYSTEM_PROMPT },
|
||
{ role: "user", content: buildDeepAnalyzePrompt(sourceDir, dirContents) },
|
||
],
|
||
{
|
||
...config,
|
||
maxTokens: 2400,
|
||
timeoutMs: DEEP_REQUEST_TIMEOUT_MS,
|
||
retryAttempts: 2,
|
||
}
|
||
);
|
||
|
||
const normalized = normalizeSceneIr(await extractJsonFromResponseWithRepair(content, config));
|
||
const validation = validateSceneIdCandidate(normalized.sceneId, {
|
||
sceneName: normalized.sceneName,
|
||
sourceDir,
|
||
});
|
||
normalized.sceneIdDiagnostics = {
|
||
candidateSource: "llm_semantic",
|
||
valid: validation.valid,
|
||
invalidReason: validation.valid ? null : validation.reason,
|
||
candidates: normalized.sceneId
|
||
? [
|
||
{
|
||
value: normalized.sceneId,
|
||
source: "llm_semantic",
|
||
valid: validation.valid,
|
||
reason: validation.valid ? null : validation.reason,
|
||
},
|
||
]
|
||
: [],
|
||
};
|
||
if (!validation.valid && normalized.sceneId) {
|
||
normalized.uncertainties = Array.from(
|
||
new Set([...(normalized.uncertainties || []), `invalid_scene_id:${validation.reason}`])
|
||
);
|
||
}
|
||
|
||
// AUTO-WRAP: single-mode scenes → modes array
|
||
if (normalized.modes.length === 0 && normalized.apiEndpoints.length > 0) {
|
||
normalized.modes.push({
|
||
name: "default",
|
||
label: "default",
|
||
condition: { field: "period_mode", operator: "equals", value: "default" },
|
||
apiEndpoint: normalized.apiEndpoints[0],
|
||
columnDefs: normalized.columnDefs || [],
|
||
requestTemplate: normalized.requestTemplate || {},
|
||
normalizeRules: normalized.normalizeRules || { type: "validate_required", requiredFields: [], filterNull: true },
|
||
responsePath: normalized.responsePath || "",
|
||
});
|
||
normalized.defaultMode = "default";
|
||
normalized.modeSwitchField = "period_mode";
|
||
// Upgrade archetype if it was single_request_table
|
||
if (normalized.workflowArchetype === "single_request_table") {
|
||
normalized.workflowArchetype = "multi_mode_request";
|
||
}
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
module.exports = {
|
||
analyzeScene,
|
||
analyzeSceneDeep,
|
||
buildAnalyzePrompt,
|
||
buildDeepAnalyzePrompt,
|
||
extractJsonFromResponse,
|
||
extractJsonFromResponseWithRepair,
|
||
isRetryableLlmError,
|
||
normalizeSceneIr,
|
||
requestChatCompletionWithRetry,
|
||
repairCommonJsonIssues,
|
||
};
|