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
+66 -12
View File
@@ -629,26 +629,80 @@ def propose_improvement(project_id: str, payload: ImprovementInput, request: Req
return run(request, action)
def _revision_draft(db, ref: dict) -> dict:
"""把候选声明的 `proposed_revision_ref` 解析成**那一份**草稿。
坚持按 `组件id@版本` 精确定位:只按组件 id 取"当前草稿"会解析到另一个修订
(`revise()` 升版本后旧草稿仍在),于是出现"审核的是 A、发布的是 B"。
解析不到就报未完成,**不退回猜一个草稿**。
"""
ref = ref or {}
component_id = str(ref.get("id") or "").strip()
version = str(ref.get("version") or "").strip()
if not component_id or not version:
raise improvements.NotValidated("候选必须声明要发布的组件版本(proposed_revision_ref)")
draft_id = component_id if "@" in component_id else f"{component_id}@{version}"
try:
return knowledge_cards.current_draft(db, draft_id)
except KeyError as exc:
raise improvements.NotValidated(
f"候选指向的草稿 {draft_id} 不存在;请先形成草稿修订再发布") from exc
def _knowledge_publisher(db, actor: str, preview: bool):
"""把改进候选的发布接到**真实知识发布入口**(BR-51 / AC-57)。
走 `knowledge_cards.publish` → `component_registry.release`,因此"自批拒绝、
隔离评测门禁、依赖可解析"三道既有保证原样生效,发布成功才覆盖 `release` 指针、
后续运行由 `freeze() → resolve()` 取到新版本。候选只是**指向**这次真实发布。
`db` 必须是调用方同一个连接:另开连接会让"写组件版本"与"写候选状态"分裂成
两个事务,出现"版本已发布、候选没标记"的裂口。
publisher 抛异常时 `improvements.transition` 不写候选状态(发布失败保留旧版)。
"""
def publish(candidate: dict) -> dict:
if candidate.get("target") != "knowledge":
raise improvements.NotValidated(
f"target={candidate.get('target')} 尚无真实发布入口;不得用改状态冒充发布")
draft = _revision_draft(db, candidate.get("proposed_revision_ref"))
try:
published = knowledge_cards.publish(db, draft["id"], actor, preview)
except store.Conflict as exc:
# 评测/依赖门禁未过:统一按"未完成门禁"回报,界面只需处理一个码(E-24)
raise improvements.NotValidated(str(exc)) from exc
return {"id": published["component_id"], "version": published["version"]}
return publish
@router.post("/improvements/{candidate_id}/transitions")
def transition_improvement(candidate_id: str, payload: TransitionInput, request: Request):
"""候选状态机。泄漏按 E-11 拒绝,未通过对照/审核按 E-24 拒绝。"""
"""候选状态机。泄漏按 E-11 拒绝,未通过对照/审核按 E-24 拒绝。
`release` **不接受调用方自报的版本号**:版本由服务端调用真实知识发布入口产生
(BR-51 / AC-57)。只改 `released` 状态或返回一个版本号都不算发布,因此带了
`released_ref` 的请求一律 422 拒绝,而不是静默忽略它。
"""
def action():
if payload.released_ref is not None:
raise ValueError("发布版本由服务端实际发布产生,不接受客户端提交的 released_ref")
with _db() as db:
probe = store.get(db, improvements.KIND, candidate_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
try:
with _db() as db:
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
try:
return improvements.transition(
db, candidate_id, payload.action, _actor(request),
payload.expected_revision, evaluation=payload.evaluation,
contrast=payload.contrast, released_ref=payload.released_ref,
reason=payload.reason)
except improvements.LeakageDetected as exc:
return _envelope(request, ErrorCode.EVALUATION_CONTAMINATED, str(exc))
except improvements.NotValidated as exc:
return _envelope(request, ErrorCode.IMPROVEMENT_NOT_VALIDATED, str(exc))
contrast=payload.contrast, reason=payload.reason,
publisher=_knowledge_publisher(
db, _actor(request),
request.state.identity.get("preview", False)))
except improvements.LeakageDetected as exc:
return _envelope(request, ErrorCode.EVALUATION_CONTAMINATED, str(exc))
except improvements.NotValidated as exc:
return _envelope(request, ErrorCode.IMPROVEMENT_NOT_VALIDATED, str(exc))
return run(request, action)