feat(v2): 纠偏第四批——会议结果录入与改进发布接真实知识发布入口

- api/prescreen.py:transition_improvement 注入 _knowledge_publisher,
  release 委托 knowledge_cards.publish → component_registry.release;
  按 proposed_revision_ref 以「组件id@版本」精确解析草稿(不取"最新草稿",
  否则会出现"审核的是 A、发布的是 B");客户端自报 released_ref 一律 422;
  非 knowledge 目标 409 诚实拒绝;发布与候选状态同一事务,失败保留旧版。
- TrajectoryPanel:新增会议结果录入(追加式更正走 supersedes_id;
  未匹配单列且不计入分母)。
- ImprovementPanel(新增):偏差→候选→两道评测门→独立审核→发布;
  请求体不含版本号,无自动上线,不自批。
- 界面并入「轨迹复盘」,不新增第四个导航入口(AC-53)。
- 守卫:新增 scripts/test_improvement_panel.cjs(verify.py 13→14 项);
  test_module_shell_ui.cjs 的「只读」断言改为分别锁死「回放只读」与「结果只可追加」。
- 测试:新增 backend/test_improvement_release.py(AC-57 端到端 + 三条反例);
  test_improvements.py 补「自报版本号被拒」。
- 文档:17 第四批交付记录、15 §7 交接、03 §9.4 发布事务边界。

验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),证据
test-evidence/20261001-013517-3e15bda/;scripts/xingbang_batch4_smoke.py
对真实服务器实跑通过(发布指针实测移到新版本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 02:21:11 +08:00
co-authored by Claude Code
parent 3e15bda8cd
commit f775c6cbe4
13 changed files with 1269 additions and 91 deletions
+99
View File
@@ -0,0 +1,99 @@
/**
* 改进对照与发布 界面源码守卫(纠偏第四批 · BR-51 / AC-56 / AC-57)。
*
* 这是**源码断言,不是浏览器端到端测试**(docs/v2/06-validation.md §2)。
* 它专防审计确认过的偏差模式:「函数在、功能不在」——面板看着有按钮,
* 实际没接真实端点,或者把版本号从界面自报上去冒充发布。
*/
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const exists = (file) => fs.existsSync(path.join(root, file));
const panel = read("src/components/xingbang/ImprovementPanel.tsx");
const page = read("src/app/trajectory-center/page.tsx");
const trajectory = read("src/components/xingbang/TrajectoryPanel.tsx");
const prescreenApi = read("backend/api/prescreen.py");
// ---------------------------------------------------------------- 1. 入口与挂载
assert.ok(exists("src/components/xingbang/ImprovementPanel.tsx"), "应有改进对照面板组件");
assert.match(page, /import ImprovementPanel from "@\/components\/xingbang\/ImprovementPanel"/,
"轨迹复盘路由应复用改进面板组件,而不是在路由里再写一份");
assert.match(page, /<ImprovementPanel/, "轨迹复盘路由必须真的挂载改进面板");
assert.match(page, /<TrajectoryPanel/, "轨迹复盘路由应仍挂载轨迹面板");
// ---------------------------------------------------------------- 2. 不新增第四个入口(AC-53/BR-50)
const nav = read("src/components/xingbang/AgentNav.tsx");
assert.ok(!nav.includes("/improvement"), "改进模块必须并入轨迹复盘,不得新增导航入口");
for (const banned of ["improvement", "improvements"]) {
assert.ok(!exists(`src/app/${banned}`), `不得新建第四个模块路由 src/app/${banned}`);
}
// ---------------------------------------------------------------- 3. 端点真的接上了
for (const call of [
/`\$\{PRESCREEN\}\$\{path\}\/improvements`/, // 项目级列表与提出候选
/`\$\{PRESCREEN\}\/improvements\/\$\{encodeURIComponent\(item\.id\)\}\/transitions`/,
/knowledge-candidates/, // 选草稿 + 草稿隔离评测
/review-outcomes/, // 偏差来源:会议结果
]) {
assert.match(panel, call, `改进面板必须真的调用既有端点:${call}`);
}
assert.match(panel, /action: "evaluate"|action,\s*\.\.\.extra/, "必须能提交对照评测");
assert.match(panel, /action: "release"|transition\(item, "release"\)/, "必须提供发布动作");
// ---------------------------------------------------------------- 4. 版本号不得由界面提交(BR-51 的核心)
assert.ok(!/released_ref\s*:/.test(panel),
"界面不得提交 released_ref:发布版本必须由服务端从真实发布结果产生");
assert.ok(!/released_ref\s*:/.test(trajectory), "会议结果录入不得提交 released_ref");
// 展示时只能读服务端返回的引用
assert.match(panel, /item\.released_ref/, "发布结果应展示服务端返回的版本引用");
// ---------------------------------------------------------------- 5. 不提供自动上线
const buttonTexts = [...panel.matchAll(/<button[^>]*>([\s\S]*?)<\/button>/g)].map((m) => m[1]);
assert.ok(!buttonTexts.some((text) => /自动上线|一键|自动发布/.test(text)),
"不得提供自动上线/一键发布按钮");
assert.match(panel, /没有"自动上线"|不提供自动上线/, "界面必须写明不提供自动上线");
assert.ok(!/enqueue|\/runs`/.test(panel), "发布后不得自动发起运行");
// ---------------------------------------------------------------- 6. 不自批
assert.match(panel, /proposed_by !== identity\?\.id/, "提出者不得看到自己的通过审核按钮");
assert.match(panel, /独立审核|不能由提出者|审核需要独立/, "界面必须说明审核需要独立的人");
assert.match(panel, /release_blockers|blockersOf|未完成门禁/, "必须把未完成门禁展示出来(E-24)");
// ---------------------------------------------------------------- 7. 两道评测门必须分开
assert.match(panel, /隔离评测/, "必须有草稿的隔离评测入口(发布门禁之一)");
assert.match(panel, /旧\/新对照/, "必须有候选的旧/新对照入口(另一道门)");
// 草稿评测必须精确到 `组件id@版本`:只传组件 id 会评测到"当前最新草稿",
// 于是出现"评测的是 A、发布的是 B"。
assert.match(panel, /proposed_revision_ref\.id \+ "@" \+ item\.proposed_revision_ref\.version/,
"草稿隔离评测必须精确定位到候选声明的版本");
assert.match(panel, /泄漏/, "泄漏样本必须按失败显示,不得渲染成评测完成");
// ---------------------------------------------------------------- 8. AC-56:会议结果录入
assert.match(trajectory, /review-outcomes/, "轨迹面板应消费会议结果端点");
assert.match(trajectory, /"POST"/, "轨迹面板必须能追加会议结果");
assert.match(trajectory, /raw_code/, "录入必须围绕原始结果码,不得自行推断通过或否决");
assert.match(trajectory, /supersedes_id/, "更正只能追加并指向旧记录");
assert.match(trajectory, /不计入分母/, "未匹配的结果必须单列且不计入分母");
// ---------------------------------------------------------------- 9. 后端真的接上了(交叉断言)
assert.match(prescreenApi, /publisher=_knowledge_publisher\(/, "端点必须注入真实 publisher");
assert.match(prescreenApi, /knowledge_cards\.publish\(db, draft\["id"\], actor, preview\)/,
"publisher 必须调用真实知识发布入口");
assert.ok(!/released_ref=payload\.released_ref/.test(prescreenApi),
"端点不得再把客户端提交的 released_ref 透传给服务层");
assert.match(prescreenApi, /不接受客户端提交的 released_ref/, "必须显式拒绝客户端自报版本号");
assert.match(read("backend/services/improvements.py"), /released_ref = publisher\(body\)/,
"服务层发布必须委托注入的 publisher");
// ---------------------------------------------------------------- 10. 不新增样式表 / 既有禁令
assert.ok(!/^import .*\.css";/m.test(panel), "改进面板应复用 workspace.css,不得新增样式表");
for (const [name, source] of [["improvement", panel], ["trajectory", trajectory]]) {
assert.ok(!/0\.(42|58|51|64)\b/.test(source), `${name} 不得硬编码演示概率值`);
assert.ok(!/"(00|10|01|11)"\s*:/.test(source), `${name} 不得复制演示情景表`);
}
console.log("improvement-panel-ui: PASS");
+14 -3
View File
@@ -111,13 +111,22 @@ const trajectory = read("src/components/xingbang/TrajectoryPanel.tsx");
assert.match(trajectory, /failed/, "轨迹面板必须能显示失败步骤(失败保留)");
assert.match(trajectory, /不填 0|未记录/, "成本缺失时必须留空,不得填 0");
assert.ok(!/cost\s*[:=]\s*0\b/.test(trajectory), "不得把缺失成本写成 0");
assert.match(trajectory, /只读/, "轨迹面板必须声明只读");
// 纠偏第四批把"会议结果录入"并入了本面板,因此这里不能再只断言"整页只读"——
// 要分别锁死两件事:**回放只读**(不改运行、不改历史结果)与**结果只可追加**(不覆盖历史)。
assert.match(trajectory, /回放是只读的|只读回放/, "回放必须声明只读(第四批加的是追加,不是改写)");
assert.match(trajectory, /supersedes_id/, "会议结果更正必须指向旧记录(supersedes_id)");
assert.match(trajectory, /不覆盖|只\*\*追加\*\*/, "界面必须声明更正只追加、不覆盖历史");
// 判据落在"是否渲染推理字段"上,而不是禁用词本身——否则解释规则的注释会把守卫自己绊倒。
assert.ok(!/\.\s*(reasoning|reasoning_content|thoughts?|chain_of_thought)\b/i.test(trajectory),
"不得渲染模型隐藏推理字段");
// 服务端已声明轨迹不含内部推理,界面必须把它显示出来(而不是自己编一句)
assert.match(trajectory, /trajectory\.note|note\}/, "应展示服务端对轨迹范围的说明");
assert.match(trajectory, /review-outcomes|会议结果/, "轨迹面板应与会议结果对照");
// AC-56:既读又写——只读列表不再是本面板的全部职责
assert.match(trajectory, /review-outcomes/, "轨迹面板应消费会议结果端点");
assert.match(trajectory, /"POST",\s*`\$\{PRESCREEN\}\$\{path\}\/review-outcomes`/,
"会议结果必须真的能录入(POST 到 review-outcomes)");
assert.match(trajectory, /raw_code/, "录入必须围绕原始结果码,不得自行推断通过或否决");
assert.match(trajectory, /不计入分母/, "未匹配的结果必须标明不计入分母(单列)");
// 预审模块必须真的挂载预审面板(否则"AI 预审入口"是空壳)
assert.match(read("src/app/prescreen-workbench/page.tsx"), /<PrescreenPanel /, "AI 预审模块应挂载预审面板");
@@ -125,7 +134,9 @@ assert.match(read("src/app/prescreen-workbench/page.tsx"), /<PrescreenPanel /, "
// 界面调用的端点必须在后端真的声明过——否则"入口可点"只是连到 404。
const prescreenApi = read("backend/api/prescreen.py");
for (const route of ['"/projects/{project_id}/runs/{run_id}/trajectory"',
'"/projects/{project_id}/review-outcomes"']) {
'"/projects/{project_id}/review-outcomes"',
'"/projects/{project_id}/improvements"',
'"/improvements/{candidate_id}/transitions"']) {
assert.ok(prescreenApi.includes(route), `后端应声明路由 ${route}`);
}
assert.ok(read("backend/api/project_assistant.py").includes('"/projects/{project_id}/runs"'),
+1
View File
@@ -216,6 +216,7 @@ def main() -> int:
("prescreen-ui", [executable("node"), "scripts/test_prescreen_ui.cjs"], ROOT),
("experience-center-ui", [executable("node"), "scripts/test_experience_center_ui.cjs"], ROOT),
("module-shell-ui", [executable("node"), "scripts/test_module_shell_ui.cjs"], ROOT),
("improvement-panel-ui", [executable("node"), "scripts/test_improvement_panel.cjs"], ROOT),
("frontend-build", [npm, "run", "build"], ROOT),
("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT),
("compose-aliyun-config", [docker, "compose", "--env-file", "deploy/aliyun.env.example", "-f", "deploy/docker-compose.aliyun.yml", "config", "--quiet"], ROOT),
+199
View File
@@ -0,0 +1,199 @@
"""纠偏第四批 · 真实服务器冒烟(不是单测的替代,是"走真实 HTTP 执行链"的补充证据)。
对**运行中的** uvicorn(127.0.0.1:8012)按界面实际会发的同一串请求走一遍:
建卡片 → 隔离评测 → 发布 v1.0.0 → 结构化修订出新版本 → 隔离评测 → 录入会议结果
→ 提改进候选 → 旧/新对照 → 提交 → 换身份(reviewer)审核 → 发布(**不带任何版本号**)
→ 复查发布指针是否真的移到新版本。
只用一个本脚本新建的组件 id(服务端生成),不触碰既有组件的发布指针。
用法(先起后端,环境配置见 17 四批交付记录):
cd backend && XINGBANG_DEMO_ROLE_LOGIN=1 venv/Scripts/python.exe \
-m uvicorn app:app --host 127.0.0.1 --port 8012
backend/venv/Scripts/python.exe scripts/xingbang_batch4_smoke.py
"""
import json
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8012/api/xingbang/risk-agent/project-assistant"
PRESCREEN = BASE + "/prescreen"
PROJECT = "DEMO-B"
def call(method, url, body=None, role="manager"):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(url, data=data, method=method)
request.add_header("Content-Type", "application/json")
request.add_header("X-Xingbang-Demo-Role", role)
try:
with urllib.request.urlopen(request, timeout=60) as response:
return response.status, json.loads(response.read() or b"{}")
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read() or b"{}")
def show(label, status, body):
print(f"[{status}] {label}: {json.dumps(body, ensure_ascii=False)[:220]}")
return body
steps = []
# 1) 新建一张卡片草稿(组件 id 由服务端生成,不碰既有组件)
status, draft = call("POST", f"{PRESCREEN}/projects/{PROJECT}/knowledge-candidates", {
"kind": "skill", "title": "冒烟:回款集中依赖核查",
"content": {"engine": "required", "inputs": ["financial.operating_cashflow"],
"code": "smoke.repayment.check",
"test_cases": [
{"name": "缺证样本", "values": {"financial.operating_cashflow": None},
"expected_outputs": {}, "expected_checks": {"smoke.repayment.check": "unknown"}},
{"name": "已提供样本", "values": {"financial.operating_cashflow": "1200"},
"expected_outputs": {}, "expected_checks": {"smoke.repayment.check": "pass"}}]},
}, "manager")
show("新建草稿", status, draft)
steps.append(("新建草稿", status))
component_id = draft["id"].split("@")[0]
# 2) 隔离评测 + 独立发布 v1.0.0
status, _ = call("POST", f"{PRESCREEN}/knowledge-candidates/{component_id}@1.0.0/transitions",
{"action": "evaluate"}, "reviewer")
steps.append(("草稿隔离评测 v1.0.0", status))
status, published = call("POST", f"{PRESCREEN}/knowledge-candidates/{component_id}@1.0.0/transitions",
{"action": "publish"}, "reviewer")
show("发布 v1.0.0", status, published)
steps.append(("发布 v1.0.0", status))
# 3) 结构化修订出新版本(不调模型),并做发布门禁要求的隔离评测
status, revised = call("PATCH", f"{PRESCREEN}/knowledge-candidates/{component_id}", {
"changes": {"test_cases": [
{"name": "缺证样本", "values": {"financial.operating_cashflow": None},
"expected_outputs": {}, "expected_checks": {"smoke.repayment.check": "unknown"}},
{"name": "已提供样本", "values": {"financial.operating_cashflow": "1200"},
"expected_outputs": {}, "expected_checks": {"smoke.repayment.check": "pass"}},
{"name": "边界样本", "values": {"financial.operating_cashflow": "0"},
"expected_outputs": {}, "expected_checks": {"smoke.repayment.check": "pass"}}]},
"note": "冒烟:补充边界样本"}, "manager")
show("修订出新版本", status, revised)
steps.append(("修订出新版本", status))
# 版本号由服务端升(_bump 升 patch 位),不在这里硬编码,避免"以为发布了另一个版本"
new_draft_id = revised["id"]
new_version = revised["version"]
print(f" 修订后的草稿:{new_draft_id}")
status, _ = call("POST", f"{PRESCREEN}/knowledge-candidates/{new_draft_id}/transitions",
{"action": "evaluate"}, "reviewer")
steps.append(("草稿隔离评测新版本", status))
# 4) AC-56:录入会议结果。先看有没有可对应的材料版本(运行输入快照)
status, runs = call("GET", f"{PRESCREEN}/projects/{PROJECT}/runs")
snapshots = [item["input_snapshot_id"] for item in runs.get("items", []) if item.get("input_snapshot_id")]
outcome_body = {"raw_code": "附条件通过", "meeting_id": "SMOKE-4", "conditions": ["补充担保后生效"]}
if snapshots:
outcome_body["reviewed_snapshot_id"] = snapshots[0]
status, outcome = call("POST", f"{PRESCREEN}/projects/{PROJECT}/review-outcomes", outcome_body, "manager")
show("录入会议结果", status, outcome)
steps.append(("录入会议结果", status))
expected_status = "matched" if snapshots else "unmatched"
# 会议结果分组:未匹配必须单列且不计入分母
status, outcome_list = call("GET", f"{PRESCREEN}/projects/{PROJECT}/review-outcomes")
counts = outcome_list.get("counts_toward_denominator", {})
print(f" 未匹配单列 {len(outcome_list.get('unmatched', []))} 条 · 计入分母 {counts.get('observed')} 条")
steps.append(("会议结果分组", status))
# 5) 提改进候选(必须引用来源运行或会议结果)
status, candidate = call("POST", f"{PRESCREEN}/projects/{PROJECT}/improvements", {
"target": "knowledge",
"target_ref": {"id": component_id, "version": "1.0.0"},
"proposed_revision_ref": {"id": component_id, "version": new_version},
"change_summary": "冒烟:把回款集中依赖的核查口径扩到含零值边界",
"source_run_ids": [], "outcome_ids": [outcome["id"]]}, "manager")
show("提出改进候选", status, candidate)
steps.append(("提出改进候选", status))
status, evaluated = call("POST", f"{PRESCREEN}/improvements/{candidate['id']}/transitions", {
"action": "evaluate",
"evaluation": {"baseline_ref": {"id": "assistant.current", "version": "1.0.0"},
"candidate_ref": {"id": "prescreen.v2", "version": "0.2.0"},
"dataset_ref": "fixtures/cases/manifest.json", "split_ref": "split.v1",
"independent": True,
"samples": [
{"id": "S-1", "split": "dev", "observed": True, "baseline_pass": False,
"candidate_pass": True, "predicted_at": "2026-09-29T01:00:00+00:00",
"label_revealed_at": "2026-09-29T03:00:00+00:00", "source_in_dev": False},
{"id": "S-2", "split": "dev", "observed": True, "baseline_pass": True,
"candidate_pass": True, "predicted_at": "2026-09-29T01:00:00+00:00",
"label_revealed_at": "2026-09-29T03:00:00+00:00", "source_in_dev": False}]}},
"manager")
show("旧/新对照", status, evaluated)
steps.append(("旧/新对照", status))
status, _ = call("POST", f"{PRESCREEN}/improvements/{candidate['id']}/transitions",
{"action": "submit"}, "manager")
steps.append(("提交审核", status))
status, approved = call("POST", f"{PRESCREEN}/improvements/{candidate['id']}/transitions",
{"action": "approve"}, "reviewer")
show("独立审核通过", status, approved)
steps.append(("独立审核通过", status))
# 6) 发布:请求体**只有 action**,没有任何版本号
status, released = call("POST", f"{PRESCREEN}/improvements/{candidate['id']}/transitions",
{"action": "release"}, "reviewer")
show("发布(请求体不含 released_ref)", status, released)
steps.append(("发布", status))
released_ref = released.get("released_ref")
# 7) 反例:客户端自报版本号必须被拒
status, forged = call("POST", f"{PRESCREEN}/improvements/{candidate['id']}/transitions",
{"action": "release", "released_ref": {"id": component_id, "version": "9.9.9"}},
"reviewer")
show("反例:自报 released_ref", status, forged.get("error", forged))
steps.append(("反例:自报版本号被拒", status))
# 8) 复查:发布指针是否真的移到了新版本
status, listing = call("GET", f"{PRESCREEN}/projects/{PROJECT}/knowledge-candidates")
mine = [card for card in listing.get("items", []) if card["id"] == component_id]
show("发布后的卡片", status, mine)
published_versions = sorted(card["version"] for card in mine if card["status"] == "published")
# 最硬的一条:发布**指针**是否真的指向新版本。列表会列出全部不可变历史版本
# (旧版本保留是正确行为),只断言"列表里有新版本"证明不了发布生效。
import sqlite3
from pathlib import Path
db = sqlite3.connect(
Path(__file__).resolve().parents[1] / "backend" / "xingbang_data" / "project_assistant.sqlite3")
pointer = json.loads(db.execute(
"SELECT body FROM assistant_objects WHERE kind='release' AND id=?", (component_id,)).fetchone()[0])
db.close()
print(f" 发布指针:{pointer.get('version_id')}")
print("\n=== 冒烟结论 ===")
for label, code in steps:
print(f" {label}: HTTP {code}")
print(f" 服务端返回的 released_ref: {released_ref}")
print(f" 该组件的已发布版本: {published_versions}")
print(f" 会议结果匹配状态: {outcome.get('status')}(预期 {expected_status})")
print(f" 自报版本号被拒: {forged.get('error', {}).get('code')}")
# 反例那一步**预期**就是 4xx,不算失败
positive = [(label, code) for label, code in steps if label != "反例:自报版本号被拒"]
ok = (all(code < 300 for _, code in positive)
and released_ref == {"id": component_id, "version": new_version}
and new_version in published_versions # 不可变新版本已落库
and pointer.get("version_id") == f"{component_id}@{new_version}" # 指针真的移了
and forged.get("error", {}).get("code") == "INVALID_REQUEST"
and outcome.get("status") == expected_status)
checks = {
"正向步骤全部 < 300": ([item for item in positive if item[1] >= 300], all(c < 300 for _, c in positive)),
"released_ref 由服务端产生": (released_ref, released_ref == {"id": component_id, "version": new_version}),
"新不可变版本已落库": (published_versions, new_version in published_versions),
"发布指针已移动": (pointer.get("version_id"), pointer.get("version_id") == f"{component_id}@{new_version}"),
"自报版本号被拒": (forged.get("error", {}).get("code"),
forged.get("error", {}).get("code") == "INVALID_REQUEST"),
"会议结果匹配状态": (outcome.get("status"), outcome.get("status") == expected_status),
}
for label, (value, passed) in checks.items():
print(f" {'OK ' if passed else 'NG '} {label}: {value}")
print(f"\n结果:{'通过' if ok else '未通过'}")
raise SystemExit(0 if ok else 1)