@customize-agent/server 4.57.0 → 4.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +132 -143
- package/.next/prerender-manifest.json +3 -3
- package/.next/routes-manifest.json +117 -9
- package/.next/server/chunks/1001.js +56 -0
- package/.next/server/chunks/1928.js +1 -1
- package/.next/server/chunks/1936.js +2 -2
- package/.next/server/chunks/2434.js +4 -0
- package/.next/server/chunks/299.js +50 -0
- package/.next/server/chunks/5547.js +350 -0
- package/.next/server/chunks/6087.js +3 -0
- package/.next/server/chunks/6321.js +3 -0
- package/.next/server/chunks/8204.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/pages/api/assets/generated/preview.js +1 -1
- package/.next/server/pages/api/assets/generated/preview.js.nft.json +1 -1
- package/.next/server/pages/api/assets/generated.js +1 -1
- package/.next/server/pages/api/assets/generated.js.nft.json +1 -1
- package/.next/server/pages/api/config/embedding/healthCheck.js.nft.json +1 -1
- package/.next/server/pages/api/config/healthCheck.js +2 -2
- package/.next/server/pages/api/config/providers.js +2 -2
- package/.next/server/pages/api/documents/canvas/[id].js +2 -0
- package/.next/server/pages/api/documents/canvas/[id].js.nft.json +1 -0
- package/.next/server/pages/api/documents/export.js +3 -3
- package/.next/server/pages/api/documents/export.js.nft.json +1 -1
- package/.next/server/pages/api/documents/generate/preview.js +2 -0
- package/.next/server/pages/api/documents/generate/preview.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generate.js +2 -2
- package/.next/server/pages/api/documents/generate.js.nft.json +1 -1
- package/.next/server/pages/api/documents/generated/[id]/events.js +10 -0
- package/.next/server/pages/api/documents/generated/[id]/events.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/figure/[name].js +1 -0
- package/.next/server/pages/api/documents/generated/[id]/figure/[name].js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/nodes/[nodeId].js +2 -0
- package/.next/server/pages/api/documents/generated/[id]/nodes/[nodeId].js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/nodes.js +2 -0
- package/.next/server/pages/api/documents/generated/[id]/nodes.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/pause.js +2 -0
- package/.next/server/pages/api/documents/generated/[id]/pause.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/preview.js +2 -0
- package/.next/server/pages/api/documents/generated/[id]/preview.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/repair/[rid]/confirm.js +1 -0
- package/.next/server/pages/api/documents/generated/[id]/repair/[rid]/confirm.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/repair/[rid]/rollback.js +1 -0
- package/.next/server/pages/api/documents/generated/[id]/repair/[rid]/rollback.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/repair.js +1 -0
- package/.next/server/pages/api/documents/generated/[id]/repair.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id]/resume-generate.js +2 -0
- package/.next/server/pages/api/documents/generated/[id]/resume-generate.js.nft.json +1 -0
- package/.next/server/pages/api/documents/generated/[id].js +2 -2
- package/.next/server/pages/api/documents/generated/[id].js.nft.json +1 -1
- package/.next/server/pages/api/documents/generated.js +1 -1
- package/.next/server/pages/api/documents/generated.js.nft.json +1 -1
- package/.next/server/pages/api/jobs/[id].js +1 -1
- package/.next/server/pages/api/jobs/[id].js.nft.json +1 -1
- package/.next/server/pages/api/jobs.js +1 -1
- package/.next/server/pages/api/jobs.js.nft.json +1 -1
- package/.next/server/pages/api/kb/categories.js +2 -2
- package/.next/server/pages/api/kb/categories.js.nft.json +1 -1
- package/.next/server/pages/api/kb/duplicates.js +2 -2
- package/.next/server/pages/api/kb/duplicates.js.nft.json +1 -1
- package/.next/server/pages/api/kb/evaluate.js +3 -3
- package/.next/server/pages/api/kb/evaluate.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files/detail.js +4 -4
- package/.next/server/pages/api/kb/files/detail.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files/export.js +3 -3
- package/.next/server/pages/api/kb/files/export.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files/open.js +2 -2
- package/.next/server/pages/api/kb/files/open.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files/preview-pdf-page.js +1 -1
- package/.next/server/pages/api/kb/files/preview-pdf-page.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files/reindex.js +1 -1
- package/.next/server/pages/api/kb/files/reindex.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files/search.js +3 -3
- package/.next/server/pages/api/kb/files/search.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files/tree.js +3 -3
- package/.next/server/pages/api/kb/files/tree.js.nft.json +1 -1
- package/.next/server/pages/api/kb/files.js +4 -4
- package/.next/server/pages/api/kb/files.js.nft.json +1 -1
- package/.next/server/pages/api/kb/ignore.js +2 -2
- package/.next/server/pages/api/kb/ignore.js.nft.json +1 -1
- package/.next/server/pages/api/kb/intelligence.js +1 -1
- package/.next/server/pages/api/kb/intelligence.js.nft.json +1 -1
- package/.next/server/pages/api/kb/material-groups.js +2 -0
- package/.next/server/pages/api/kb/material-groups.js.nft.json +1 -0
- package/.next/server/pages/api/kb/operations.js +1 -1
- package/.next/server/pages/api/kb/operations.js.nft.json +1 -1
- package/.next/server/pages/api/kb/reindex.js +1 -1
- package/.next/server/pages/api/kb/reindex.js.nft.json +1 -1
- package/.next/server/pages/api/kb/search.js +2 -2
- package/.next/server/pages/api/kb/search.js.nft.json +1 -1
- package/.next/server/pages/api/kb/stats.js +2 -2
- package/.next/server/pages/api/kb/stats.js.nft.json +1 -1
- package/.next/server/pages/api/kb/tags.js +1 -1
- package/.next/server/pages/api/kb/tags.js.nft.json +1 -1
- package/.next/server/pages/api/kb/upload/complete.js +1 -1
- package/.next/server/pages/api/kb/upload/complete.js.nft.json +1 -1
- package/.next/server/pages/api/kb/upload/progress.js +2 -2
- package/.next/server/pages/api/kb/upload/progress.js.nft.json +1 -1
- package/.next/server/pages/api/kb/upload.js +1 -1
- package/.next/server/pages/api/kb/upload.js.nft.json +1 -1
- package/.next/server/pages/api/prompt.js +1 -1
- package/.next/server/pages/api/prompt.js.nft.json +1 -1
- package/.next/server/pages/api/skills/[id].js +1 -0
- package/.next/server/pages/api/skills/[id].js.nft.json +1 -0
- package/.next/server/pages/api/skills/validate.js +1 -0
- package/.next/server/pages/api/skills/validate.js.nft.json +1 -0
- package/.next/server/pages/api/skills.js +1 -0
- package/.next/server/pages/api/skills.js.nft.json +1 -0
- package/.next/server/pages/api/system/logs.js +2 -2
- package/.next/server/pages/api/system/stats.js +1 -1
- package/.next/server/pages/canvas/[id].html +1 -0
- package/.next/server/pages/canvas/[id].js.nft.json +1 -0
- package/.next/server/pages/canvas.html +1 -0
- package/.next/server/pages/canvas.js.nft.json +1 -0
- package/.next/server/pages/documents.html +1 -1
- package/.next/server/pages/documents.js.nft.json +1 -1
- package/.next/server/pages/guide.html +1 -1
- package/.next/server/pages/index.html +1 -1
- package/.next/server/pages/knowledge/file-detail.html +1 -1
- package/.next/server/pages/knowledge/files.html +1 -1
- package/.next/server/pages/knowledge/files.js.nft.json +1 -1
- package/.next/server/pages/knowledge/manage.html +1 -1
- package/.next/server/pages/knowledge/manage.js.nft.json +1 -1
- package/.next/server/pages/knowledge.html +1 -1
- package/.next/server/pages/knowledge.js.nft.json +1 -1
- package/.next/server/pages/models.html +1 -1
- package/.next/server/pages/overview.html +1 -1
- package/.next/server/pages/prompt.html +1 -1
- package/.next/server/pages/settings.html +1 -1
- package/.next/server/pages/skills.html +1 -0
- package/.next/server/pages/system/logs.html +1 -1
- package/.next/server/pages-manifest.json +45 -32
- package/.next/static/Z8psgNSpy4sSg2lIho8Uw/_buildManifest.js +1 -0
- package/.next/static/chunks/127-20f09f6ebe1e6c1f.js +1 -0
- package/.next/static/chunks/174-1bc48f295d7dd075.js +10 -0
- package/.next/static/chunks/189-3f4f4e237056adaf.js +4 -0
- package/.next/static/chunks/243-03eba51d5d737e98.js +1 -0
- package/.next/static/chunks/269-7a3502478bf67fff.js +11 -0
- package/.next/static/chunks/289-136ebb30e0f6723a.js +78 -0
- package/.next/static/chunks/310-6c678cb70e4ba2d6.js +19 -0
- package/.next/static/chunks/422-46f07037c58d2309.js +1 -0
- package/.next/static/chunks/426-90638907336adcb8.js +21 -0
- package/.next/static/chunks/445-d12731f107978b10.js +6 -0
- package/.next/static/chunks/530-1b18bb11570dc44a.js +1 -0
- package/.next/static/chunks/569-2bfdde9e402af5b2.js +16 -0
- package/.next/static/chunks/700-5d29b0475311f590.js +1 -0
- package/.next/static/chunks/709-a7c652953ccd3b4b.js +1 -0
- package/.next/static/chunks/787-207289f4bb577708.js +10 -0
- package/.next/static/chunks/795-9e0cb0bb11e8c89f.js +1 -0
- package/.next/static/chunks/914-292edce0ba2a212d.js +1 -0
- package/.next/static/chunks/{framework-161be364fd69a7ce.js → framework-4b565b3ddc94cdac.js} +1 -1
- package/.next/static/chunks/main-0c91f573696223ca.js +1 -0
- package/.next/static/chunks/pages/_app-74fce107cacfaee7.js +185 -0
- package/.next/static/chunks/pages/canvas/[id]-9f4e9f49e512418b.js +1 -0
- package/.next/static/chunks/pages/canvas-323d44fe56d685a7.js +1 -0
- package/.next/static/chunks/pages/documents-e6a80f2de39c6fd2.js +1 -0
- package/.next/static/chunks/pages/guide-7367305082617e3c.js +1 -0
- package/.next/static/chunks/pages/knowledge/file-detail-aee7c15541d1d130.js +1 -0
- package/.next/static/chunks/pages/knowledge/files-b3ef5324e4b74c4a.js +1 -0
- package/.next/static/chunks/pages/knowledge/manage-d9eaebf3a8307953.js +1 -0
- package/.next/static/chunks/pages/knowledge-ca8ad5501faed486.js +1 -0
- package/.next/static/chunks/pages/models-8b96ba85131d5408.js +1 -0
- package/.next/static/chunks/pages/prompt-dd9a9f9105216e2b.js +1 -0
- package/.next/static/chunks/pages/settings-0a7709bf039a2a4d.js +1 -0
- package/.next/static/chunks/pages/skills-3d77213c3e9ef0b5.js +1 -0
- package/.next/static/chunks/pages/system/logs-9deb1217739490af.js +1 -0
- package/.next/static/chunks/webpack-6224d37324e372cb.js +1 -0
- package/.next/static/css/0e46e39b61e4e262.css +1 -0
- package/messages/en-US.json +108 -344
- package/messages/zh-CN.json +109 -345
- package/package.json +6 -5
- package/scripts/capture-baseline.mjs +541 -0
- package/.next/server/chunks/208.js +0 -136
- package/.next/server/chunks/4435.js +0 -249
- package/.next/server/chunks/5840.js +0 -1
- package/.next/server/pages/api/documents/drafts.js +0 -1
- package/.next/server/pages/api/documents/drafts.js.nft.json +0 -1
- package/.next/server/pages/api/documents/refine.js +0 -13
- package/.next/server/pages/api/documents/refine.js.nft.json +0 -1
- package/.next/server/pages/api/documents/roles.js +0 -1
- package/.next/server/pages/api/documents/roles.js.nft.json +0 -1
- package/.next/server/pages/api/documents/templates.js +0 -1
- package/.next/server/pages/api/documents/templates.js.nft.json +0 -1
- package/.next/server/pages/asset-library.html +0 -1
- package/.next/server/pages/document-roles.html +0 -1
- package/.next/server/pages/document-roles.js.nft.json +0 -1
- package/.next/static/chunks/195-2c0168eacab722d2.js +0 -1
- package/.next/static/chunks/289-12dfcb3abec1c4b4.js +0 -78
- package/.next/static/chunks/44-d3545eedd5c75ef1.js +0 -4
- package/.next/static/chunks/455-e1b41ac4946d2b6f.js +0 -6
- package/.next/static/chunks/563-2b116a2500a97dba.js +0 -1
- package/.next/static/chunks/665-88e20299084b9e73.js +0 -1
- package/.next/static/chunks/7-20bcc985742a9ae1.js +0 -10
- package/.next/static/chunks/717-743b697a417dd5ee.js +0 -4
- package/.next/static/chunks/760-f8bb8e969c5e0d57.js +0 -1
- package/.next/static/chunks/787-c18af810af3433f6.js +0 -25
- package/.next/static/chunks/788-961761f5858f9189.js +0 -1
- package/.next/static/chunks/795-cd94e8d4e9768eb9.js +0 -1
- package/.next/static/chunks/81-c3a7f04d96ea3f7d.js +0 -1
- package/.next/static/chunks/827-a6dc09e42833e6a3.js +0 -1
- package/.next/static/chunks/883-506cbca959974997.js +0 -16
- package/.next/static/chunks/986-49b865de7906320a.js +0 -8
- package/.next/static/chunks/994-241f87efc61005b8.js +0 -4
- package/.next/static/chunks/main-91fb767ebd74c9c1.js +0 -1
- package/.next/static/chunks/pages/_app-66149ba52ad1dc26.js +0 -185
- package/.next/static/chunks/pages/asset-library-a1047694e888e655.js +0 -1
- package/.next/static/chunks/pages/document-roles-5620a98d6312f81c.js +0 -1
- package/.next/static/chunks/pages/documents-8e1a30170e9311b0.js +0 -1
- package/.next/static/chunks/pages/guide-a411c554520b7109.js +0 -1
- package/.next/static/chunks/pages/knowledge/file-detail-fe69eb38d70adea0.js +0 -1
- package/.next/static/chunks/pages/knowledge/files-7b78e962e432d199.js +0 -1
- package/.next/static/chunks/pages/knowledge/manage-7535b873ab505397.js +0 -1
- package/.next/static/chunks/pages/knowledge-905248d8dcb15453.js +0 -1
- package/.next/static/chunks/pages/models-6ef838f3e00ba21e.js +0 -1
- package/.next/static/chunks/pages/prompt-2c242e541f2e77a9.js +0 -1
- package/.next/static/chunks/pages/settings-810eb1a6cf1a6b02.js +0 -1
- package/.next/static/chunks/pages/system/logs-c5a8920c6506a7e8.js +0 -1
- package/.next/static/chunks/webpack-4224e970a97a4a86.js +0 -1
- package/.next/static/css/18018ee565a1deba.css +0 -1
- package/.next/static/uVLt2UAGpslnZIBzcFv3n/_buildManifest.js +0 -1
- /package/.next/server/pages/{asset-library.js.nft.json → skills.js.nft.json} +0 -0
- /package/.next/static/{uVLt2UAGpslnZIBzcFv3n → Z8psgNSpy4sSg2lIho8Uw}/_ssgManifest.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@customize-agent/server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.59.0",
|
|
4
4
|
"description": "Customize Agent Web 管理控制台",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json"
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@ant-design/icons": "^6.3.2",
|
|
39
|
-
"@customize-agent/knowledge": "^4.
|
|
40
|
-
"@customize-agent/llm": "^3.1.
|
|
41
|
-
"@customize-agent/runtime": "^3.1.
|
|
39
|
+
"@customize-agent/knowledge": "^4.16.0",
|
|
40
|
+
"@customize-agent/llm": "^3.1.3",
|
|
41
|
+
"@customize-agent/runtime": "^3.1.2",
|
|
42
42
|
"@napi-rs/canvas": "^0.1.82",
|
|
43
43
|
"@types/nprogress": "^0.2.3",
|
|
44
44
|
"antd": "^6.5.0",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"build": "node -e \"require('fs').rmSync('.next', { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })\" && next build && node -e \"const fs=require('fs'); for (const p of ['.next/cache','.next/static/development','.next/static/webpack']) fs.rmSync(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })\" && node scripts/verify-next-static.js",
|
|
77
77
|
"prestart": "node scripts/verify-next-static.js",
|
|
78
78
|
"start": "next start -p 17321",
|
|
79
|
-
"lint": "eslint src/"
|
|
79
|
+
"lint": "eslint src/",
|
|
80
|
+
"typecheck": "tsc --noEmit"
|
|
80
81
|
}
|
|
81
82
|
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* capture-baseline:验收基线采集(方案 P8.5 / Z5 的 A 组)。
|
|
4
|
+
*
|
|
5
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* ## 为什么需要这个脚本(方案 14.2 的原话:归档作为评估基线的成色)
|
|
7
|
+
*
|
|
8
|
+
* `~/.customize-agent/projects/<id>/generatedDocuments/` 里有 288 份 draft、281 份 assets、
|
|
9
|
+
* 46 份交付报告。**够**判据行为回归;**不够**做「改造是否让文档更好」的判定,因为归档里缺两样:
|
|
10
|
+
*
|
|
11
|
+
* - ❌ **无 `blockLedger`**(块级目标/实际/失败类别)——没有它就答不出「篇幅失守是写作侧还是修复侧」;
|
|
12
|
+
* - ❌ **无逐调用 token/缓存遥测**——成本与缓存命中率**当前不可重建**。
|
|
13
|
+
*
|
|
14
|
+
* 于是「改了之后是不是退化了」在数据上无从判断(S4.2)。本脚本把这两样**在生成结束的那一刻**
|
|
15
|
+
* 抄下来,与六维分一起冻结成一份可 diff 的基线。
|
|
16
|
+
*
|
|
17
|
+
* ## 为什么是「采集归档」而不是「另跑一遍管线」
|
|
18
|
+
*
|
|
19
|
+
* 生成只发生一次,**产物就是归档**。脚本不重跑任何东西——它读归档、按 baseline schema 抽取、
|
|
20
|
+
* 落成一份 `*.baseline.json`。这样:
|
|
21
|
+
* 1. 采集本身**零 LLM 成本**(跑一次真实文档 ~50 分钟,重跑一遍纯属浪费);
|
|
22
|
+
* 2. 基线与被评的那份文档**逐字段同源**(不会出现「基线是另一轮跑的」这种不可比);
|
|
23
|
+
* 3. A/B 两组的差异**只来自开关**(`REPAIR_CHAIN=off` / `AGENT_TOOLS=on`),不是两套采集代码。
|
|
24
|
+
*
|
|
25
|
+
* ## 用法
|
|
26
|
+
*
|
|
27
|
+
* ```bash
|
|
28
|
+
* # 采集一份既有归档(docId 见 generatedDocuments/index.json)
|
|
29
|
+
* node apps/server/scripts/capture-baseline.mjs --project-root . --doc doc-1790407778481-d7a36837
|
|
30
|
+
* # 采集最近 N 份(自带 --run 时先触发一次生成再采集)
|
|
31
|
+
* node apps/server/scripts/capture-baseline.mjs --project-root . --latest 2
|
|
32
|
+
* node apps/server/scripts/capture-baseline.mjs --project-root . --run tpl-1790019955043 --run tpl-1790187804600
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* 落盘:`~/.customize-agent/baselines/<时间戳>-<label>.baseline.json`(**append-only**,
|
|
36
|
+
* 不覆盖历史基线——「同代码重跑波动 ±7」这件事只能靠多份基线看出来)。
|
|
37
|
+
*
|
|
38
|
+
* ## A/B 模式(S11.2:**同一组 N 份归档,开关开/关各跑一次**)
|
|
39
|
+
*
|
|
40
|
+
* ```bash
|
|
41
|
+
* # 腿 1(off)——需要**服务端以 REPAIR_CHAIN=off 启动**(开关读的是服务端进程的 env,脚本设不了)
|
|
42
|
+
* node apps/server/scripts/capture-baseline.mjs --project-root . --latest 8 --ab REPAIR_CHAIN --ab-leg off
|
|
43
|
+
* # 腿 2(on)——服务端以默认 env 重启后再跑;配对文件由腿 1 打印,无需手抄
|
|
44
|
+
* node apps/server/scripts/capture-baseline.mjs --project-root . --latest 8 --ab REPAIR_CHAIN --ab-leg on
|
|
45
|
+
* # 判定(**不在本脚本里**):两腿各跑完后由 TS 侧 `services/agent/abExecution.ts` 出结论
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* 分工(这是本模式最重要的一条):**脚本只做机制 + 抄写原始证据,绝不下结论**。
|
|
49
|
+
* | 环节 | 谁 |
|
|
50
|
+
* |---|---|
|
|
51
|
+
* | 拒绝"这场 A/B 根本不成立"(腿值非法 / 同一腿跑两次 / 两腿输入不同 / 标签与历史基线撞车) | **本脚本**(全部在任何网络调用**之前**或落盘**之前**) |
|
|
52
|
+
* | 从归档里抄 `repairChainMode` 等**原始**证据 | 本脚本(抄写,不判定) |
|
|
53
|
+
* | 「这一腿的开关真的切了吗」+「on 腿是否退化」 | TS 侧 `services/agent/abExecution.ts`(复用 S4.2 的 `costModel`) |
|
|
54
|
+
*
|
|
55
|
+
* 为什么判定不放这儿:脚本的产物是**文件**,它不会拒绝给结论——而"标签写错的一腿"这种错
|
|
56
|
+
* 只能靠"声明值 vs 归档证据"的对账抓出来,那需要两腿的归档同时在手,且必须**宁可不出结论**。
|
|
57
|
+
* 脚本在此处的克制是刻意的:它连"declare=on 且归档无标记 ⇒ 视为 on"这种推断都不做。
|
|
58
|
+
*
|
|
59
|
+
* ★ 未核实:`AGENT_TOOLS` 这条腿今天**采集得出、判定不出**——写手循环的 `onTurn`
|
|
60
|
+
* (`writerLoop.ts:134` 的 `turn.*` 载荷)在生产路径上零消费点,节点日志里没有 `turn` 节点,
|
|
61
|
+
* 归档里也没有任何字段记录这个开关。脚本会对这种腿打出显式警告(跑完 8+8 份仍会拒判),
|
|
62
|
+
* 免得白跑 16 次真实生成。
|
|
63
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import crypto from 'node:crypto';
|
|
67
|
+
import fs from 'node:fs';
|
|
68
|
+
import os from 'node:os';
|
|
69
|
+
import path from 'node:path';
|
|
70
|
+
|
|
71
|
+
const DEFAULT_BASE_URL = process.env.BASELINE_BASE_URL || 'http://localhost:17321';
|
|
72
|
+
|
|
73
|
+
function parseArgs(argv) {
|
|
74
|
+
const args = { projectRoot: process.cwd(), docs: [], runs: [], latest: 0, label: '', baseUrl: DEFAULT_BASE_URL, timeoutMinutes: 90, ab: '', abLeg: '', abPair: '', abSize: null };
|
|
75
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
76
|
+
const token = argv[index];
|
|
77
|
+
const next = () => argv[++index];
|
|
78
|
+
if (token === '--project-root') args.projectRoot = path.resolve(next());
|
|
79
|
+
else if (token === '--doc') args.docs.push(next());
|
|
80
|
+
else if (token === '--run') args.runs.push(next());
|
|
81
|
+
else if (token === '--latest') args.latest = Number(next());
|
|
82
|
+
else if (token === '--label') args.label = next();
|
|
83
|
+
else if (token === '--base-url') args.baseUrl = next();
|
|
84
|
+
else if (token === '--timeout-minutes') args.timeoutMinutes = Number(next());
|
|
85
|
+
else if (token === '--ab') args.ab = next();
|
|
86
|
+
else if (token === '--ab-leg') args.abLeg = next();
|
|
87
|
+
else if (token === '--ab-pair') args.abPair = next();
|
|
88
|
+
else if (token === '--ab-size') args.abSize = Number(next());
|
|
89
|
+
else if (token === '--help' || token === '-h') { printHelp(); process.exit(0); }
|
|
90
|
+
else throw new Error(`未知参数:${token}(--help 看用法)`);
|
|
91
|
+
}
|
|
92
|
+
return args;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printHelp() {
|
|
96
|
+
process.stdout.write([
|
|
97
|
+
'capture-baseline:把既有归档抽成可 diff 的验收基线(方案 P8.5)。',
|
|
98
|
+
'',
|
|
99
|
+
' --project-root <dir> 文档所属项目根(默认当前目录;决定归档目录)',
|
|
100
|
+
' --doc <docId> 采集指定文档(可重复)',
|
|
101
|
+
' --latest <N> 采集最近 N 份已完成文档',
|
|
102
|
+
' --run <templateId> 先触发一次生成再采集(可重复;需要服务在跑)',
|
|
103
|
+
' --label <name> 基线标签(默认按开关自动命名 a-on / b-off)',
|
|
104
|
+
' --base-url <url> 服务地址(默认 http://localhost:17321)',
|
|
105
|
+
' --timeout-minutes <N> 单份生成等待上限(默认 90)',
|
|
106
|
+
'',
|
|
107
|
+
'A/B(S11.2:同 N 份归档,开/关各跑一次;判定在 TS 侧 abExecution.ts):',
|
|
108
|
+
' --ab <SWITCH> 开关名(本工具支持 REPAIR_CHAIN / AGENT_TOOLS)',
|
|
109
|
+
' --ab-leg <on|off> 本腿的开关取值(同一腿只能采一次)',
|
|
110
|
+
' --ab-pair <file> 配对文件(默认按 开关+项目 定形,腿 1 打印出来供腿 2 复用)',
|
|
111
|
+
' --ab-size <N> 本场 A/B 的归档份数(腿 1 默认 8;腿 2 缺省沿用本场已声明的份数)',
|
|
112
|
+
'',
|
|
113
|
+
].join('\n'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 归档读取(与 generatedDocumentService 的落盘布局同源:~/.customize-agent/projects/<projectId>/generatedDocuments)──
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 项目 id(`sha256(绝对路径)` 前 12 位,与 `@customize-agent/knowledge` 的 `computeProjectId` 逐字同构)。
|
|
120
|
+
* **只用于定位归档目录**,不参与任何评分口径——口径单源在服务端,本脚本只读。
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ 刻意不 import 那个包:脚本要在**服务未启动 / 依赖未构建**时也能跑(验收现场最需要它的时候,
|
|
123
|
+
* 往往正是服务刚崩的时候)。同构关系由下面这条自检守住——算法一旦漂移,脚本会立刻找不到归档,
|
|
124
|
+
* 而不是悄悄采到空基线。
|
|
125
|
+
*/
|
|
126
|
+
function projectIdOf(projectRoot) {
|
|
127
|
+
return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function archiveDir(projectRoot) {
|
|
131
|
+
return path.join(os.homedir(), '.customize-agent', 'projects', projectIdOf(projectRoot), 'generatedDocuments');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 抽取:只抽「归档里已有、但没被结构化保存」的那些量 ─────────────────────
|
|
135
|
+
|
|
136
|
+
function num(value) {
|
|
137
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 六维 + 专业分 + 阻断数(评分口径单源在服务端;脚本只做**抄写**,不做换算)。
|
|
142
|
+
* 换算会让基线与被评文档的差异混入「脚本口径」这个第三变量。
|
|
143
|
+
*/
|
|
144
|
+
function scoresOf(record) {
|
|
145
|
+
const report = record?.reviewMetadata?.qualityReport || {};
|
|
146
|
+
const dims = report.dimensions || report.dimensionScores || null;
|
|
147
|
+
return {
|
|
148
|
+
overall: num(report.overall ?? report.overallScore),
|
|
149
|
+
dimensions: dims,
|
|
150
|
+
requirementChecklist: Array.isArray(report.requirementChecklist) ? report.requirementChecklist.length : null,
|
|
151
|
+
// 阻断数:导出闸门里的 blockingIssues(**不是**六维分——两套口径,方案 §六 顺带发现)
|
|
152
|
+
blockingCount: Array.isArray(record?.draft?.exportGate?.blockingIssues) ? record.draft.exportGate.blockingIssues.length : null,
|
|
153
|
+
exportGatePassed: record?.draft?.exportGate?.passed ?? null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 块级篇幅账(方案 14.2 点名的缺口之一) */
|
|
158
|
+
function blockLedgerOf(record) {
|
|
159
|
+
const ledger = record?.reviewMetadata?.diagnostics?.blockLedger || record?.draft?.reviewMetadata?.diagnostics?.blockLedger || [];
|
|
160
|
+
const rows = Array.isArray(ledger) ? ledger : [];
|
|
161
|
+
const primary = rows.filter(row => !row.isolatedRetry);
|
|
162
|
+
return {
|
|
163
|
+
rows: primary.length,
|
|
164
|
+
targetChars: primary.reduce((sum, row) => sum + (num(row.target) || 0), 0),
|
|
165
|
+
actualChars: primary.reduce((sum, row) => sum + (num(row.actual) || 0), 0),
|
|
166
|
+
firstPassRate: primary.length > 0 ? primary.filter(row => row.passed && (num(row.attempt) || 0) === 0).length / primary.length : null,
|
|
167
|
+
failureKinds: primary.flatMap(row => row.failureKinds || []).reduce((acc, kind) => { acc[kind] = (acc[kind] || 0) + 1; return acc; }, {}),
|
|
168
|
+
ledger, // 明细整段落盘:缺口归因要靠它,不要在脚本里预先聚合掉信息
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 逐调用 token/缓存遥测(方案 14.2 点名的缺口之二) */
|
|
173
|
+
function llmTelemetryOf(record) {
|
|
174
|
+
const diagnostics = record?.reviewMetadata?.diagnostics || record?.draft?.reviewMetadata?.diagnostics || {};
|
|
175
|
+
const llm = diagnostics.llm || {};
|
|
176
|
+
return {
|
|
177
|
+
calls: num(llm.calls), failures: num(llm.failures), retries: num(llm.retries), schemaFailures: num(llm.schemaFailures),
|
|
178
|
+
maxActive: num(llm.maxActive),
|
|
179
|
+
inputTokens: num(llm.inputTokens), outputTokens: num(llm.outputTokens),
|
|
180
|
+
promptCacheHitTokens: num(llm.promptCacheHitTokens), promptCacheMissTokens: num(llm.promptCacheMissTokens),
|
|
181
|
+
inputChars: num(llm.inputChars),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 修复链净增 + 逐轮明细(Z7 R1:退役决策的输入)与关链状态(Z5) */
|
|
186
|
+
function repairChainOf(record) {
|
|
187
|
+
const diagnostics = record?.reviewMetadata?.diagnostics || record?.draft?.reviewMetadata?.diagnostics || {};
|
|
188
|
+
const telemetry = record?.reviewMetadata?.telemetry || {};
|
|
189
|
+
return {
|
|
190
|
+
netChars: diagnostics.repairChainNetChars || telemetry.repairChainNetChars || null,
|
|
191
|
+
switchState: diagnostics.repairChain || null,
|
|
192
|
+
repairHeat: telemetry.repairHeat || null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function baselineOf(record, options) {
|
|
197
|
+
const diagnostics = record?.reviewMetadata?.diagnostics || {};
|
|
198
|
+
return {
|
|
199
|
+
schema: 'capture-baseline/v1',
|
|
200
|
+
capturedAt: new Date().toISOString(),
|
|
201
|
+
// ★ 开关状态必须进基线:A/B 两组**只差开关**,基线里没有开关就分不清这份是哪一组
|
|
202
|
+
switches: {
|
|
203
|
+
repairChain: diagnostics.repairChain?.mode || 'on',
|
|
204
|
+
agentTools: process.env.AGENT_TOOLS || 'off',
|
|
205
|
+
agendEmitNodeStageMirror: process.env.AGENT_EMIT_NODE_STAGE_MIRROR || 'default',
|
|
206
|
+
},
|
|
207
|
+
doc: {
|
|
208
|
+
id: record.id, title: record.title, templateId: record.templateId, templateName: record.templateName,
|
|
209
|
+
status: record.status, elapsedMs: num(record.elapsedMs), wordCount: num(record.wordCount),
|
|
210
|
+
markdownChars: typeof record.markdown === 'string' ? record.markdown.length : null,
|
|
211
|
+
completedAt: record.completedAt ?? null,
|
|
212
|
+
},
|
|
213
|
+
scores: scoresOf(record),
|
|
214
|
+
blockLedger: blockLedgerOf(record),
|
|
215
|
+
llm: llmTelemetryOf(record),
|
|
216
|
+
repairChain: repairChainOf(record),
|
|
217
|
+
note: options.note || '',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── A/B 配对(S11.2:同 N 份归档,开/关各跑一次) ─────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 本工具认识的 A/B 开关(与 TS 侧 `services/agent/abExecution.ts` 的 `abSwitchEvidence()` 逐条同集合)。
|
|
225
|
+
*
|
|
226
|
+
* ★ 刻意**不**接受 `SKILLS`/`PAUSE`:它们的关闭效果在归档里没有可对账的痕迹
|
|
227
|
+
* (技能不参与 ≠ 模板缺字段,暂停被忽略 ≠ 终态不是 paused,两者都可能由别的原因造成)。
|
|
228
|
+
* 在这里放行会让操作者以为"A/B 做过了",而判定侧只会拒判——把"跑过"当成"验过"是本仓红线。
|
|
229
|
+
*/
|
|
230
|
+
const AB_SWITCHES = ['REPAIR_CHAIN', 'AGENT_TOOLS'];
|
|
231
|
+
const AB_LEGS = ['on', 'off'];
|
|
232
|
+
/** 归档证据**当前不可用**的开关(跑完仍会拒判)——采集不拦,但必须**事先**说清 */
|
|
233
|
+
const AB_UNVERIFIABLE = { AGENT_TOOLS: '归档里没有任何字段记录这个开关:写手循环的 onTurn(writerLoop.ts:134 的 turn.* 载荷)在生产路径上零消费点,节点日志里没有 turn 节点' };
|
|
234
|
+
|
|
235
|
+
function baselinesDir() {
|
|
236
|
+
return path.join(os.homedir(), '.customize-agent', 'baselines');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 配对文件:显式 `--ab-pair` 优先;否则按「开关 + 项目」定形(腿 2 无需手抄路径) */
|
|
240
|
+
function abPairPathOf(args) {
|
|
241
|
+
if (args.abPair) return path.resolve(args.abPair);
|
|
242
|
+
return path.join(baselinesDir(), `ab-${args.ab.toLowerCase()}-${projectIdOf(args.projectRoot)}.abpair.json`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function loadPairOrNull(file) {
|
|
246
|
+
if (!fs.existsSync(file)) return null;
|
|
247
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
248
|
+
if (parsed?.schema !== 'ab-pair/v1' || !Array.isArray(parsed.legs)) {
|
|
249
|
+
throw new Error(`配对文件形状不对(期望 schema=ab-pair/v1):${file}`);
|
|
250
|
+
}
|
|
251
|
+
return parsed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A/B 的全部「这场实验不成立」判定——**在任何网络调用之前**跑完。
|
|
256
|
+
*
|
|
257
|
+
* 为什么必须前置:`--run` 会触发真实生成(一份 40–60 分钟、真金白银)。等到采完 8+8 份
|
|
258
|
+
* 才发现"同一腿采了两次",代价是十几小时机时与一份**没法用**的数据——而那种数据最危险的地方
|
|
259
|
+
* 是它看起来能用。故这里宁可多拦。
|
|
260
|
+
*/
|
|
261
|
+
function abPreflight(args) {
|
|
262
|
+
const touched = args.ab !== '' || args.abLeg !== '' || args.abPair !== '' || args.abSize !== null;
|
|
263
|
+
if (!touched) return null;
|
|
264
|
+
if (!args.ab) throw new Error('--ab-leg / --ab-pair / --ab-size 需要同时给 --ab <SWITCH>(否则参数会被静默忽略)');
|
|
265
|
+
if (!AB_SWITCHES.includes(args.ab)) {
|
|
266
|
+
throw new Error(`--ab 不支持 ${args.ab}:本工具只做 ${AB_SWITCHES.join(' / ')}(判定侧同集合;其它开关在归档里没有可对账的痕迹)`);
|
|
267
|
+
}
|
|
268
|
+
const leg = args.abLeg;
|
|
269
|
+
if (leg === '') throw new Error(`--ab ${args.ab} 必须给 --ab-leg <${AB_LEGS.join('|')}>:不给就分不清这份归档属于哪一腿,而两腿的归档**除开关外必须一致**`);
|
|
270
|
+
if (!AB_LEGS.includes(leg)) throw new Error(`--ab-leg 只认 ${AB_LEGS.join('|')},收到「${args.abLeg}」(含糊取值在 A/B 里是结论级差错:'true'/'1' 到底算哪一腿没有唯一答案)`);
|
|
271
|
+
if (args.abSize !== null && (!Number.isInteger(args.abSize) || args.abSize <= 0)) {
|
|
272
|
+
throw new Error(`--ab-size 需要正整数,收到「${args.abSize}」(0 / 负数 / 小数都不收:含糊值一律拒绝,不静默取默认)`);
|
|
273
|
+
}
|
|
274
|
+
// 两腿必须各有自己的标签:判定侧**按标签配对**,两腿同标签会让 on/off 指到同一份基线文件
|
|
275
|
+
if (args.label !== '') {
|
|
276
|
+
throw new Error('A/B 模式下不允许 --label:两腿必须各有自己的标签(本模式自动命名为 ab-<开关>-<腿>-<时刻>),同标签会让判定侧把两腿指到同一份基线');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const pairFile = abPairPathOf(args);
|
|
280
|
+
const pair = loadPairOrNull(pairFile);
|
|
281
|
+
// 份数缺省取**本场已声明的**(腿 2 不必重抄 --ab-size;显式给了不一致的值仍然拒)
|
|
282
|
+
const size = args.abSize === null ? (pair?.size ?? 8) : args.abSize;
|
|
283
|
+
if (pair) {
|
|
284
|
+
if (pair.switchName !== args.ab) throw new Error(`配对文件是给 ${pair.switchName} 的,本次是 ${args.ab}:${pairFile}`);
|
|
285
|
+
const existing = pair.legs.map(item => item.leg);
|
|
286
|
+
if (existing.includes(leg)) {
|
|
287
|
+
throw new Error(`「${leg}」腿已经采过了(${pairFile}):A/B 是**开/关各一次**,同一腿采两次只会让配对无法收敛到"两腿只差开关"(已采:${existing.join('、')})`);
|
|
288
|
+
}
|
|
289
|
+
if (pair.size !== size) throw new Error(`配对文件的份数是 ${pair.size},本次 --ab-size ${size}:两腿必须同份数,改份数等于换了一场实验(请新建一场)`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 事前把"这条腿的证据不存在"说清——免得白跑 8 份真实生成
|
|
293
|
+
if (AB_UNVERIFIABLE[args.ab]) {
|
|
294
|
+
process.stdout.write(`[baseline] ⚠ 本腿(${args.ab}=${leg})可采集但**判定会拒绝出结论**:${AB_UNVERIFIABLE[args.ab]}\n`);
|
|
295
|
+
process.stdout.write('[baseline] ⚠ 若目的是"建立"该证据通道,先把写入接上再跑;否则这 8 份归档只能当原始素材,不能当 A/B 证据。\n');
|
|
296
|
+
}
|
|
297
|
+
// 开关读的是**服务端进程**的 env(生成跑在服务端,本脚本设不了)——本地 env 只是提示,不是证据
|
|
298
|
+
const localEnv = process.env[args.ab] || '(未设)';
|
|
299
|
+
if (args.runs.length > 0) {
|
|
300
|
+
process.stdout.write(`[baseline] ⚠ 本腿要求服务端 env:${args.ab}=${leg}(脚本设不了它;本地 env=${localEnv})——请**重启服务**后再跑 --run,否则采到的是另一腿。\n`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { switchName: args.ab, leg, size, pairFile, pair, legCount: (pair?.legs.length ?? 0) + 1 };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 归档证据:关链标记(`documentPipeline.ts:160` 只在**关**时写)+ 节点日志里 turn 节点条数(今天恒 0) */
|
|
307
|
+
function abObserveLeg({ projectRoot, records }) {
|
|
308
|
+
const modes = [];
|
|
309
|
+
const turnCounts = [];
|
|
310
|
+
const notes = [];
|
|
311
|
+
for (const record of records) {
|
|
312
|
+
const mode = record?.reviewMetadata?.diagnostics?.repairChain?.mode ?? record?.draft?.reviewMetadata?.diagnostics?.repairChain?.mode ?? null;
|
|
313
|
+
modes.push(mode);
|
|
314
|
+
const count = turnNodeCountOf({ projectRoot, docId: record.id });
|
|
315
|
+
turnCounts.push(count);
|
|
316
|
+
if (count === null) notes.push(`${record.id}:无节点日志(turn 条数记为「未知」,不是 0)`);
|
|
317
|
+
}
|
|
318
|
+
const distinct = [...new Set(modes.map(mode => mode ?? '(缺)'))].sort();
|
|
319
|
+
return {
|
|
320
|
+
// 关腿应全体带标记;混着来(如 'off+(缺)')在判定侧会被判为"标签对不上"并给出这段原文
|
|
321
|
+
repairChainMode: modes.length > 0 && distinct.length === 1 && distinct[0] === '(缺)' ? null : distinct.join('+'),
|
|
322
|
+
turnNodeCount: turnCounts.some(count => count !== null) ? turnCounts.reduce((sum, count) => sum + (count ?? 0), 0) : null,
|
|
323
|
+
notes,
|
|
324
|
+
perDoc: records.map((record, index) => ({ docId: record.id, repairChainMode: modes[index], turnNodeCount: turnCounts[index] })),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 节点日志里 `turn.*` 节点条数;**日志不存在 ⇒ null(未知)**,绝不写成 0(0 是"查过了,没有") */
|
|
329
|
+
function turnNodeCountOf({ projectRoot, docId }) {
|
|
330
|
+
const file = path.join(archiveDir(projectRoot), 'drafts', `${docId}.nodes.jsonl`);
|
|
331
|
+
if (!fs.existsSync(file)) return null;
|
|
332
|
+
let count = 0;
|
|
333
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
334
|
+
if (line.trim() === '') continue;
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(line);
|
|
337
|
+
if (String(parsed?.type || '').startsWith('turn.')) count += 1;
|
|
338
|
+
} catch { /* 撕裂尾行:不计入(也不当成 0——上面的 existsSync 已经区分了"没有日志") */ }
|
|
339
|
+
}
|
|
340
|
+
return count;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 落盘**之前**的输入集校验:两腿必须跑同一组输入。
|
|
345
|
+
*
|
|
346
|
+
* 为什么这条值钱:`--latest 8` 的选片随"最近完成"变化。若两腿之间又完成了一份文档,
|
|
347
|
+
* 腿 2 采到的就是另一组——而**分数差异会被读成开关的效果**。这种错在结果里看不出来
|
|
348
|
+
* (两组分数都"正常"),只能靠这次集合比对拦住。
|
|
349
|
+
*/
|
|
350
|
+
function abAssertInputs(ab, baselines) {
|
|
351
|
+
if (baselines.length !== ab.size) {
|
|
352
|
+
throw new Error(`本腿采到 ${baselines.length} 份,与 --ab-size ${ab.size} 不符:A/B 的前提是两腿同份数(拒绝落盘)`);
|
|
353
|
+
}
|
|
354
|
+
const templates = baselines.map(item => item.doc.templateId || item.doc.templateName || '(未知模板)');
|
|
355
|
+
const docs = baselines.map(item => item.doc.id);
|
|
356
|
+
const other = ab.pair?.legs?.[0];
|
|
357
|
+
if (other) {
|
|
358
|
+
const mine = [...templates].sort().join('\u0000');
|
|
359
|
+
const theirs = [...other.templates].sort().join('\u0000');
|
|
360
|
+
if (mine !== theirs) {
|
|
361
|
+
throw new Error(`两腿输入不是同一组模板(本腿 ${templates.join('、')} vs 另一腿 ${other.templates.join('、')}):差的就不是开关了(拒绝落盘)`);
|
|
362
|
+
}
|
|
363
|
+
const overlap = docs.filter(id => other.docIds.includes(id));
|
|
364
|
+
if (overlap.length > 0) {
|
|
365
|
+
throw new Error(`两腿归档重合 ${overlap.length} 份(${overlap.slice(0, 3).join('、')}…):同一份归档不能同时代表"开"与"关"(拒绝落盘)`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { templates, docs };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A/B 模式下拒绝与历史基线**撞标签**:标签是判定侧的配对键,撞了就会把两场会话混成一场 */
|
|
372
|
+
function abAssertLabelFree(label) {
|
|
373
|
+
const dir = baselinesDir();
|
|
374
|
+
if (!fs.existsSync(dir)) return;
|
|
375
|
+
for (const name of fs.readdirSync(dir)) {
|
|
376
|
+
if (!name.endsWith('.baseline.json')) continue;
|
|
377
|
+
try {
|
|
378
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
379
|
+
if (parsed?.label === label) {
|
|
380
|
+
throw new Error(`标签「${label}」已被 ${name} 占用:A/B 的判定按标签配对,撞标签会把两场会话混成一场(A/B 模式下 --label 不可用,把旧基线移出目录或删掉即可;正常情况不会撞——标签里带采集时刻)`);
|
|
381
|
+
}
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error instanceof Error && error.message.includes('已被')) throw error; // 上面的拒绝照原样抛出
|
|
384
|
+
/* 解析失败的历史文件不参与撞标签判定(`loadBaselines` 侧同样跳过并告警) */
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 写配对状态(腿记录**只追加不删**;文件本身重写是因为它是状态不是产物) */
|
|
390
|
+
function abWriteLeg({ ab, label, outFile, records }) {
|
|
391
|
+
const observed = abObserveLeg({ projectRoot: ab.projectRoot, records });
|
|
392
|
+
const leg = {
|
|
393
|
+
leg: ab.leg,
|
|
394
|
+
label,
|
|
395
|
+
baselineFile: path.basename(outFile),
|
|
396
|
+
declared: ab.leg,
|
|
397
|
+
capturedAt: new Date().toISOString(),
|
|
398
|
+
templates: ab.observed.templates,
|
|
399
|
+
docIds: ab.observed.docs,
|
|
400
|
+
observed,
|
|
401
|
+
};
|
|
402
|
+
const pair = {
|
|
403
|
+
schema: 'ab-pair/v1',
|
|
404
|
+
switchName: ab.switchName,
|
|
405
|
+
pairId: `ab-${ab.switchName.toLowerCase()}-${projectIdOf(ab.projectRoot)}`,
|
|
406
|
+
projectRoot: path.resolve(ab.projectRoot),
|
|
407
|
+
size: ab.size,
|
|
408
|
+
legs: [...(ab.pair?.legs ?? []), leg],
|
|
409
|
+
};
|
|
410
|
+
fs.writeFileSync(ab.pairFile, `${JSON.stringify(pair, null, 2)}\n`);
|
|
411
|
+
return { pair, openLeg: AB_LEGS.filter(item => !pair.legs.some(entry => entry.leg === item)) };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── 服务交互:触发与轮询 ────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
async function postJson(url, body, timeoutMs = 60000) {
|
|
417
|
+
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
|
|
418
|
+
const text = await response.text();
|
|
419
|
+
let parsed = null;
|
|
420
|
+
try { parsed = JSON.parse(text); } catch { /* 非 JSON 响应原样带出 */ }
|
|
421
|
+
return { status: response.status, body: parsed ?? text };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function getJson(url, timeoutMs = 60000) {
|
|
425
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
426
|
+
const text = await response.text();
|
|
427
|
+
try { return { status: response.status, body: JSON.parse(text) }; } catch { return { status: response.status, body: text }; }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const TERMINAL = new Set(['completed', 'completed_with_issues', 'warning', 'failed', 'aborted']);
|
|
431
|
+
|
|
432
|
+
async function triggerAndWait({ baseUrl, projectRoot, templateId, timeoutMinutes }) {
|
|
433
|
+
const started = await postJson(`${baseUrl}/api/documents/generate`, { templateId, requirement: '', projectRoot }, 120000);
|
|
434
|
+
if (started.status !== 202 && started.status !== 200) {
|
|
435
|
+
throw new Error(`触发失败(HTTP ${started.status}):${typeof started.body === 'string' ? started.body : JSON.stringify(started.body)}`);
|
|
436
|
+
}
|
|
437
|
+
const documentId = started.body?.documentId || started.body?.record?.id || started.body?.id;
|
|
438
|
+
if (!documentId) throw new Error(`触发返回里没有 documentId:${JSON.stringify(started.body).slice(0, 400)}`);
|
|
439
|
+
process.stdout.write(`[baseline] 已触发 ${templateId} → ${documentId}(等待生成,通常 40–60 分钟)\n`);
|
|
440
|
+
const deadline = Date.now() + timeoutMinutes * 60000;
|
|
441
|
+
let lastLog = 0;
|
|
442
|
+
for (;;) {
|
|
443
|
+
if (Date.now() > deadline) throw new Error(`等待超时(${timeoutMinutes} 分钟):${documentId}`);
|
|
444
|
+
await new Promise(resolve => setTimeout(resolve, 20000));
|
|
445
|
+
const { body } = await getJson(`${baseUrl}/api/documents/generated?id=${encodeURIComponent(documentId)}&projectRoot=${encodeURIComponent(projectRoot)}`);
|
|
446
|
+
const record = body?.document;
|
|
447
|
+
if (!record) continue;
|
|
448
|
+
if (Date.now() - lastLog > 300000) {
|
|
449
|
+
lastLog = Date.now();
|
|
450
|
+
process.stdout.write(`[baseline] ${documentId} 状态=${record.status} 字数=${record.wordCount ?? '?'}\n`);
|
|
451
|
+
}
|
|
452
|
+
if (TERMINAL.has(record.status)) return record;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function loadArchivedRecord({ projectRoot, docId }) {
|
|
457
|
+
const file = path.join(archiveDir(projectRoot), 'drafts', `${docId}.json`);
|
|
458
|
+
if (!fs.existsSync(file)) throw new Error(`归档不存在:${file}`);
|
|
459
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function latestDocIds({ projectRoot, count }) {
|
|
463
|
+
const indexFile = path.join(archiveDir(projectRoot), 'index.json');
|
|
464
|
+
if (!fs.existsSync(indexFile)) throw new Error(`索引不存在:${indexFile}`);
|
|
465
|
+
const entries = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
466
|
+
return entries
|
|
467
|
+
.filter(entry => entry.completedAt && String(entry.status).startsWith('completed'))
|
|
468
|
+
.sort((left, right) => (right.completedAt || 0) - (left.completedAt || 0))
|
|
469
|
+
.slice(0, count)
|
|
470
|
+
.map(entry => entry.id);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── 主流程 ─────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
async function main() {
|
|
476
|
+
const args = parseArgs(process.argv.slice(2));
|
|
477
|
+
// ★ A/B 的拒绝**全部**在这里(任何网络调用之前):腿值非法 / 同一腿采两次 / 份数不一致…
|
|
478
|
+
const ab = abPreflight(args);
|
|
479
|
+
if (ab) {
|
|
480
|
+
ab.projectRoot = args.projectRoot;
|
|
481
|
+
process.stdout.write(`[baseline] A/B:${ab.switchName}=${ab.leg}(第 ${ab.legCount}/2 腿,共 ${ab.size} 份)配对文件 ${ab.pairFile}\n`);
|
|
482
|
+
}
|
|
483
|
+
const records = [];
|
|
484
|
+
|
|
485
|
+
for (const templateId of args.runs) {
|
|
486
|
+
records.push(await triggerAndWait({ baseUrl: args.baseUrl, projectRoot: args.projectRoot, templateId, timeoutMinutes: args.timeoutMinutes }));
|
|
487
|
+
}
|
|
488
|
+
if (args.latest > 0) {
|
|
489
|
+
for (const docId of latestDocIds({ projectRoot: args.projectRoot, count: args.latest })) {
|
|
490
|
+
records.push(loadArchivedRecord({ projectRoot: args.projectRoot, docId }));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
for (const docId of args.docs) records.push(loadArchivedRecord({ projectRoot: args.projectRoot, docId }));
|
|
494
|
+
if (records.length === 0) { printHelp(); process.exit(1); }
|
|
495
|
+
|
|
496
|
+
const baselines = records.map(record => baselineOf(record, args));
|
|
497
|
+
const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
|
|
498
|
+
// A/B 的标签带**时刻**:判定侧按标签配对,两场会话天然可分(同开关可重复做 A/B,不必先归档旧基线)
|
|
499
|
+
const label = args.label || (ab ? `ab-${ab.switchName.toLowerCase()}-${ab.leg}-${stamp}` : (baselines[0].switches.repairChain === 'off' ? 'b-off' : 'a-on'));
|
|
500
|
+
const outDir = baselinesDir();
|
|
501
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
502
|
+
if (ab) {
|
|
503
|
+
// ★ 落盘前的最后两道拒绝:两腿输入不同 / 标签与历史基线撞车。
|
|
504
|
+
// 抛在这里 ⇒ 本腿**一份文件都不写**(不留"半套配对"这种看起来能用的状态)
|
|
505
|
+
ab.observed = abAssertInputs(ab, baselines);
|
|
506
|
+
abAssertLabelFree(label);
|
|
507
|
+
}
|
|
508
|
+
const outFile = path.join(outDir, `${stamp}-${label}.baseline.json`);
|
|
509
|
+
fs.writeFileSync(outFile, `${JSON.stringify({ capturedAt: new Date().toISOString(), label, projectRoot: path.resolve(args.projectRoot), baselines }, null, 2)}\n`);
|
|
510
|
+
|
|
511
|
+
process.stdout.write('\n[baseline] 采集完成\n');
|
|
512
|
+
for (const item of baselines) {
|
|
513
|
+
const dims = item.scores.dimensions ? JSON.stringify(item.scores.dimensions) : '(无 dimensions)';
|
|
514
|
+
process.stdout.write(
|
|
515
|
+
` ${item.doc.id} ${item.doc.title} 状态=${item.doc.status} ${Math.round((item.doc.elapsedMs || 0) / 60000)}min\n`
|
|
516
|
+
+ ` 综合=${item.scores.overall ?? '?'} 阻断=${item.scores.blockingCount ?? '?'} 六维=${dims}\n`
|
|
517
|
+
+ ` 块账=${item.blockLedger.rows} 块 Σ目标=${item.blockLedger.targetChars} Σ实际=${item.blockLedger.actualChars}\n`
|
|
518
|
+
+ ` LLM 调用=${item.llm.calls ?? '?'} 缓存命中/未命中=${item.llm.promptCacheHitTokens ?? '?'}/${item.llm.promptCacheMissTokens ?? '?'}\n`
|
|
519
|
+
+ ` 修复链净增=${item.repairChain.netChars?.delta ?? '?'}(${item.repairChain.netChars ? (item.repairChain.netChars.ratio * 100).toFixed(1) + '%' : '?'})开关=${item.switches.repairChain}\n`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
process.stdout.write(`[baseline] 落盘:${outFile}\n`);
|
|
523
|
+
if (ab) {
|
|
524
|
+
const { pair, openLeg } = abWriteLeg({ ab, label, outFile, records });
|
|
525
|
+
process.stdout.write(`[baseline] A/B 状态:${ab.pairFile}(已采 ${pair.legs.map(item => item.leg).join('、')})\n`);
|
|
526
|
+
if (openLeg.length > 0) {
|
|
527
|
+
process.stdout.write(`[baseline] 还差「${openLeg.join('、')}」腿:服务端 env 切到 ${ab.switchName}=${openLeg[0]} 并重启后,用**同一批输入**再跑一次\n`);
|
|
528
|
+
process.stdout.write(`[baseline] (--ab ${ab.switchName} --ab-leg ${openLeg[0]} --ab-pair ${ab.pairFile};--latest/--doc 的选片必须与上一腿同组)\n`);
|
|
529
|
+
} else {
|
|
530
|
+
process.stdout.write('[baseline] 两腿齐了。判定(**不在本脚本里**,跑 TS 侧车道):\n');
|
|
531
|
+
process.stdout.write(`[baseline] AB_PAIR=${ab.pairFile} npx vitest run --config vitest.manual.config.ts apps/server/test/services/agent/abExecution.manual.ts\n`);
|
|
532
|
+
}
|
|
533
|
+
process.stdout.write('[baseline] ★ 本脚本只采集与抄写证据,**不下结论**:标签对不对、是否退化,由 TS 侧 abExecution(复用 S4.2)判。\n');
|
|
534
|
+
}
|
|
535
|
+
process.stdout.write('[baseline] ★ 同代码重跑波动 ±7(方案 14.5 族 6 实测)——单点差值不足以归因,A/B 要比的是**维度构成**。\n');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
main().catch(error => {
|
|
539
|
+
process.stderr.write(`[baseline] 失败:${error instanceof Error ? error.message : String(error)}\n`);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
});
|