Files
claw/tests/scene_generator_llm_test.js

319 lines
13 KiB
JavaScript

const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
buildAnalyzePrompt,
extractJsonFromResponse,
isRetryableLlmError,
repairCommonJsonIssues,
} = require("../frontend/scene-generator/llm-client");
const {
buildDeterministicSceneIr,
readDirectory,
validateSceneIdCandidate,
} = require("../frontend/scene-generator/generator-runner");
const {
getGenerationBlockers,
mergeSceneIr,
sanitizeSceneIr,
} = require("../frontend/scene-generator/server");
function testBuildAnalyzePromptIncludesFileContents() {
const dirContents = {
"scene.toml": '[scene]\nid = "test-scene"',
scripts: { "collect_test.js": "async function main() {}" },
tree: "├── scene.toml\n└── collect_test.js",
};
const prompt = buildAnalyzePrompt("D:/test/scenario", dirContents);
assert.ok(prompt.includes("scene.toml"), "should include scene.toml");
assert.ok(prompt.includes("collect_test.js"), "should include script name");
assert.ok(prompt.includes("D:/test/scenario"), "should include sourceDir");
console.log("PASS: testBuildAnalyzePromptIncludesFileContents");
}
function testExtractJsonFromResponse() {
const withMarkdown =
'```json\n{"sceneId": "test", "sceneName": "测试"}\n```';
const plain = '{"sceneId": "test", "sceneName": "测试"}';
const withPrefix =
'Here is the result:\n{"sceneId": "test", "sceneName": "测试"}';
assert.deepStrictEqual(extractJsonFromResponse(withMarkdown), {
sceneId: "test",
sceneName: "测试",
});
assert.deepStrictEqual(extractJsonFromResponse(plain), {
sceneId: "test",
sceneName: "测试",
});
assert.deepStrictEqual(extractJsonFromResponse(withPrefix), {
sceneId: "test",
sceneName: "测试",
});
console.log("PASS: testExtractJsonFromResponse");
}
function testExtractJsonFromResponseRepairsMissingArrayComma() {
const malformed =
'{"sceneId":"marketing-zero-consumer-report","evidence":[{"kind":"a"} {"kind":"b"}],"sceneName":"营销"}';
const result = extractJsonFromResponse(malformed);
assert.strictEqual(result.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(Array.isArray(result.evidence), true);
assert.strictEqual(result.evidence.length, 2);
console.log("PASS: testExtractJsonFromResponseRepairsMissingArrayComma");
}
function testRepairCommonJsonIssuesRemovesTrailingCommas() {
const malformed =
'{\n "sceneId": "marketing-zero-consumer-report",\n "evidence": [{"kind":"a",},],\n}';
const repaired = repairCommonJsonIssues(malformed);
const parsed = JSON.parse(repaired);
assert.strictEqual(parsed.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(parsed.evidence.length, 1);
console.log("PASS: testRepairCommonJsonIssuesRemovesTrailingCommas");
}
function testIsRetryableLlmErrorRecognizesTimeouts() {
assert.strictEqual(isRetryableLlmError(new Error("LLM API request timed out")), true);
assert.strictEqual(isRetryableLlmError(new Error("LLM API error 503: upstream unavailable")), true);
assert.strictEqual(isRetryableLlmError(new Error("LLM response missing sceneId")), false);
console.log("PASS: testIsRetryableLlmErrorRecognizesTimeouts");
}
function testDeterministicNamingAvoidsDegenerateSlugFallback() {
const sceneIr = buildDeterministicSceneIr(
{ deterministicSignals: {} },
"D:/tmp/营销2.0零度户报表数据生成"
);
assert.strictEqual(sceneIr.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(sceneIr.sceneIdDiagnostics.valid, true);
assert.strictEqual(sceneIr.sceneIdDiagnostics.candidateSource, "deterministic_keywords");
console.log("PASS: testDeterministicNamingAvoidsDegenerateSlugFallback");
}
function testValidateSceneIdCandidateRejectsLowEntropyIds() {
const invalid = validateSceneIdCandidate("2-0", {
sceneName: "营销2.0零度户报表数据生成",
sourceDir: "D:/tmp/营销2.0零度户报表数据生成",
});
assert.strictEqual(invalid.valid, false);
assert.ok(
["numeric_only_scene_id", "numeric_dominant_scene_id", "scene_id_too_short"].includes(invalid.reason),
`unexpected invalid reason: ${invalid.reason}`
);
console.log("PASS: testValidateSceneIdCandidateRejectsLowEntropyIds");
}
function testMergeSceneIrPrefersValidSceneIdOverInvalidLlmValue() {
const deterministic = sanitizeSceneIr({
sceneId: "marketing-zero-consumer-report",
sceneIdDiagnostics: {
candidateSource: "deterministic_keywords",
valid: true,
candidates: [{ value: "marketing-zero-consumer-report", source: "deterministic_keywords", valid: true }],
},
sceneName: "营销2.0零度户报表数据生成",
bootstrap: { expectedDomain: "yx.gs.sgcc.com.cn", targetUrl: "http://yx.gs.sgcc.com.cn" },
workflowSteps: [{ type: "request" }],
apiEndpoints: [{ name: "userList", url: "http://yx.gs.sgcc.com.cn/list", method: "POST" }],
validationHints: { runtimeCompatible: true },
readiness: { level: "B" },
});
const llm = sanitizeSceneIr({
sceneId: "2-0",
sceneIdDiagnostics: {
candidateSource: "llm_semantic",
valid: false,
invalidReason: "numeric_dominant_scene_id",
candidates: [{ value: "2-0", source: "llm_semantic", valid: false, reason: "numeric_dominant_scene_id" }],
},
sceneName: "营销2.0零度户报表数据生成",
bootstrap: { expectedDomain: "yx.gs.sgcc.com.cn", targetUrl: "http://yx.gs.sgcc.com.cn" },
workflowSteps: [{ type: "request" }],
apiEndpoints: [{ name: "userList", url: "http://yx.gs.sgcc.com.cn/list", method: "POST" }],
validationHints: { runtimeCompatible: true },
readiness: { level: "B" },
});
const warnings = [];
const merged = mergeSceneIr(deterministic, llm, warnings);
assert.strictEqual(merged.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(merged.sceneIdDiagnostics.valid, true);
assert.ok(warnings.some((item) => item.includes("SceneId conflict")));
console.log("PASS: testMergeSceneIrPrefersValidSceneIdOverInvalidLlmValue");
}
function testGetGenerationBlockersRejectsInvalidSceneId() {
const blockers = getGenerationBlockers({
sceneIr: {
sceneIdDiagnostics: {
valid: false,
invalidReason: "numeric_dominant_scene_id",
},
},
sceneId: "2-0",
sceneName: "营销2.0零度户报表数据生成",
sourceDir: "D:/tmp/营销2.0零度户报表数据生成",
});
assert.ok(
blockers.some((item) => item.startsWith("invalid_scene_id:")),
`expected invalid_scene_id blocker, got ${JSON.stringify(blockers)}`
);
assert.ok(blockers.includes("analysis_invalid_scene_id:numeric_dominant_scene_id"));
console.log("PASS: testGetGenerationBlockersRejectsInvalidSceneId");
}
function testBootstrapPrefersBusinessEntryOverLocalhostExport() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-bootstrap-"));
const sceneDir = path.join(tempRoot, "bootstrap");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
const sourceUrl = "http://yx.gs.sgcc.com.cn";
const apiUrl = "http://yxgateway.gs.sgcc.com.cn/api";
function getRows() {
return $.ajax({ url: "http://yxgateway.gs.sgcc.com.cn/marketing/userList", type: "POST" });
}
function exportExcel() {
return $.ajax({ url: "http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSX", type: "POST" });
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.strictEqual(sceneIr.bootstrap.expectedDomain, "yx.gs.sgcc.com.cn");
assert.strictEqual(sceneIr.bootstrap.targetUrl, "http://yx.gs.sgcc.com.cn/");
console.log("PASS: testBootstrapPrefersBusinessEntryOverLocalhostExport");
}
function testBootstrapBecomesUnresolvedWhenOnlyLocalhostExists() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-bootstrap-local-"));
const sceneDir = path.join(tempRoot, "bootstrap-local");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
function exportExcel() {
return $.ajax({ url: "http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSX", type: "POST" });
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.strictEqual(sceneIr.bootstrap.expectedDomain, "");
assert.strictEqual(sceneIr.bootstrap.targetUrl, "");
assert.ok(sceneIr.readiness.missingPieces.includes("bootstrap_target"));
console.log("PASS: testBootstrapBecomesUnresolvedWhenOnlyLocalhostExists");
}
function testWorkflowClassificationPrefersPaginatedOverGenericModeNoise() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-workflow-"));
const sceneDir = path.join(tempRoot, "workflow");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
const type = "list";
const status = "ready";
async function loadData(page, pageSize) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userList", type: "POST", data: JSON.stringify({ page, pageSize }) });
}
async function getChargeInfo(custNo) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userCharges", type: "POST", data: JSON.stringify({ custNo }) });
}
function exportExcel(rows) { return rows.length; }
function run(rows) {
return rows.filter((row) => row.charge !== 0);
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.strictEqual(sceneIr.workflowArchetype, "paginated_enrichment");
assert.ok(sceneIr.workflowEvidence.paginationFields.length > 0);
assert.ok(sceneIr.workflowEvidence.secondaryRequestEntries.length > 0);
assert.ok(sceneIr.workflowEvidence.postProcessSteps.length > 0);
console.log("PASS: testWorkflowClassificationPrefersPaginatedOverGenericModeNoise");
}
function testWorkflowClassificationDoesNotEmitPaginatedWithoutPostProcess() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-workflow-no-post-"));
const sceneDir = path.join(tempRoot, "workflow-no-post");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
async function loadData(page, pageSize) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userList", type: "POST", data: JSON.stringify({ page, pageSize }) });
}
async function getChargeInfo(custNo) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userCharges", type: "POST", data: JSON.stringify({ custNo }) });
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.notStrictEqual(sceneIr.workflowArchetype, "paginated_enrichment");
console.log("PASS: testWorkflowClassificationDoesNotEmitPaginatedWithoutPostProcess");
}
function testGenerationBlockersIncludeFailedReadinessGates() {
const blockers = getGenerationBlockers({
sceneIr: {
readiness: {
gates: [
{ name: "bootstrap_resolved", passed: false, reason: "bootstrap_target" },
{ name: "request_contract_complete", passed: false, reason: "request_endpoint" },
{ name: "response_contract_complete", passed: false, reason: "response_path" },
{ name: "workflow_contract_complete", passed: false, reason: "post_process" },
{ name: "workflow_complete_for_archetype", passed: false, reason: "post_process" },
],
},
},
sceneId: "marketing-zero-consumer-report",
sceneName: "营销2.0零度户报表数据生成",
sourceDir: "D:/tmp/营销2.0零度户报表数据生成",
});
assert.ok(blockers.includes("gate_failed:bootstrap_resolved:bootstrap_target"));
assert.ok(blockers.includes("gate_failed:request_contract_complete:request_endpoint"));
assert.ok(blockers.includes("gate_failed:response_contract_complete:response_path"));
assert.ok(blockers.includes("gate_failed:workflow_contract_complete:post_process"));
assert.ok(blockers.includes("gate_failed:workflow_complete_for_archetype:post_process"));
console.log("PASS: testGenerationBlockersIncludeFailedReadinessGates");
}
testBuildAnalyzePromptIncludesFileContents();
testExtractJsonFromResponse();
testExtractJsonFromResponseRepairsMissingArrayComma();
testRepairCommonJsonIssuesRemovesTrailingCommas();
testIsRetryableLlmErrorRecognizesTimeouts();
testDeterministicNamingAvoidsDegenerateSlugFallback();
testValidateSceneIdCandidateRejectsLowEntropyIds();
testMergeSceneIrPrefersValidSceneIdOverInvalidLlmValue();
testGetGenerationBlockersRejectsInvalidSceneId();
testBootstrapPrefersBusinessEntryOverLocalhostExport();
testBootstrapBecomesUnresolvedWhenOnlyLocalhostExists();
testWorkflowClassificationPrefersPaginatedOverGenericModeNoise();
testWorkflowClassificationDoesNotEmitPaginatedWithoutPostProcess();
testGenerationBlockersIncludeFailedReadinessGates();