Files
mini-openclaw-v2/backend/test_improvement_release.py
T
qiaoqichaoandClaude Code f775c6cbe4 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>
2026-10-01 02:21:11 +08:00

289 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""纠偏第四批 · 改进发布接到真实知识发布入口(BR-51 / AC-57)。
审计(audit.json)确认的偏差模式之一是「只改 `released` 状态或返回一个版本号」就当发布成功。
本测试走**真实执行链**证明发布真的生效:
卡片草稿 → 隔离评测 → 独立审核发布 v1.0.0 → 真实运行(冻结 v1.0.0)
→ 提出改进候选(指向 v1.1.0 草稿)→ 旧/新对照 → 提交 → 他人审核
→ release(**不提交任何版本号**)→ 真实发布 v1.1.0,覆盖 release 指针
→ 再跑一次:新运行用 v1.1.0;回放第一次运行仍是 v1.0.0
反例同样锁死:客户端自报 `released_ref` 被 422 拒绝;候选指向未经评测的草稿时
发布被 409 拒绝且**不产生**新版本、不改指针;非知识类目标没有真实入口,诚实拒绝而非伪造。
不用注入记录;临时库;不触碰真实数据根。
"""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api import prescreen as prescreen_api
from api import project_assistant as api
from api import risk_agent_config as catalog
from services import assistant_store as store
from services import improvements, knowledge_cards, trajectory
from services.assistant_worker import DurableWorker
from services.project_assistant import ProjectAssistantService
PROJECT = "DEMO-B"
BASE = "/api/xingbang/risk-agent/project-assistant"
PRESCREEN = BASE + "/prescreen"
MANAGER = {"X-Xingbang-Demo-Role": "manager"}
REVIEWER = {"X-Xingbang-Demo-Role": "reviewer"}
CARD_ID = "skill.history.repayment"
V1 = "1.0.0"
V2 = "1.1.0"
CARD_CODE = "history.repayment.dependency"
# 自定义 Skill 的发布门禁要求独立测试样本(component_evaluation 的既有规则)
TEST_CASES = [
{"name": "缺证样本", "values": {"financial.operating_cashflow": None},
"expected_outputs": {}, "expected_checks": {CARD_CODE: "unknown"}},
{"name": "已提供样本", "values": {"financial.operating_cashflow": "1200"},
"expected_outputs": {}, "expected_checks": {CARD_CODE: "pass"}},
]
BASELINE = {"id": "assistant.current", "version": "1.0.0"}
CANDIDATE = {"id": "prescreen.v2", "version": "0.2.0"}
def sample(sample_id, *, baseline_pass=False, candidate_pass=True,
predicted="2026-09-29T01:00:00+00:00", revealed="2026-09-29T03:00:00+00:00"):
return {"id": sample_id, "split": "dev", "observed": True,
"baseline_pass": baseline_pass, "candidate_pass": candidate_pass,
"predicted_at": predicted, "label_revealed_at": revealed,
"source_in_dev": False}
def improvement_evaluation():
"""一次独立、无泄漏、结论为 improved 的旧/新对照。"""
return {"baseline_ref": BASELINE, "candidate_ref": CANDIDATE,
"dataset_ref": "fixtures/cases/manifest.json", "split_ref": "split.v1",
"independent": True,
"samples": [sample("S-1", baseline_pass=False),
sample("S-2", baseline_pass=True)]}
class ImprovementReleaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.path = Path(self.temp.name)
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
self.patches = [
patch.object(api, "service", lambda: self.service),
patch.object(prescreen_api, "_service", lambda: self.service),
patch.object(catalog, "DB_PATH", self.path / "catalog.sqlite3"),
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
"XINGBANG_LOCAL_MODEL_URL": "", "XINGBANG_DEMO_ROLE_LOGIN": "1"}),
]
for item in self.patches:
item.start()
self.worker = DurableWorker(self.service)
app = FastAPI()
app.include_router(api.router)
app.include_router(prescreen_api.router)
self.client = TestClient(app)
def tearDown(self):
self.worker.stop()
self.client.close()
for item in reversed(self.patches):
item.stop()
self.temp.cleanup()
# ------------------------------------------------------------ 工具
def call(self, method, path, body=None, headers=MANAGER, root=PRESCREEN):
return self.client.request(method, root + path, json=body, headers=headers)
def draft_card(self, version):
"""人工新增一个该组件的新版本草稿(不走模型,故不受模型未配置影响)。"""
with self.service.connect() as db:
return knowledge_cards.propose(db, {
"id": CARD_ID, "version": version, "kind": "skill",
"name": "核查回款集中依赖", "engine": "required",
"inputs": ["financial.operating_cashflow"],
"code": CARD_CODE, "title": "核查回款集中依赖",
"test_cases": TEST_CASES,
}, "demo-manager", PROJECT, origin="history_derived", source_event_ids=["event-1"])
def publish_v1_and_run_once(self):
"""发布 v1.0.0、装配方案并真实跑一次,返回(已发布方案 id, 第一次运行)。"""
self.draft_card(V1)
published = self.call("POST", f"/knowledge-candidates/{CARD_ID}/transitions",
{"action": "evaluate"}, REVIEWER)
self.assertEqual(published.status_code, 200, published.text)
published = self.call("POST", f"/knowledge-candidates/{CARD_ID}/transitions",
{"action": "publish"}, headers=REVIEWER)
self.assertEqual(published.status_code, 200, published.text)
self.assertEqual(published.json()["version"], V1)
plan = self.call("POST", f"/projects/{PROJECT}/review-plans", {
"name": "含历史经验卡的预审方案",
"skills": ["corporate.consistency", "corporate.cashflow", "corporate.assets",
"corporate.coverage", CARD_ID],
"rules": ["demo.coverage"], "template": "template.ai-pre-review",
"budget": {"timeout_seconds": 180, "max_tool_calls": 18},
"audience": ["manager"], "run_kind": "pre_review"})
self.assertEqual(plan.status_code, 201, plan.text)
plan_id = plan.json()["component_id"]
self.assertEqual(self.call("POST", f"/review-plans/{plan_id}/transitions",
{"action": "evaluate"}, REVIEWER).status_code, 200)
self.assertEqual(self.call("POST", f"/review-plans/{plan_id}/transitions",
{"action": "publish"}, REVIEWER).status_code, 200)
return plan_id, self.run_with(plan_id)
def run_with(self, plan_id):
queued = self.call("POST", f"/projects/{PROJECT}/runs",
{"requirement": "核对回款与现金流", "plan_id": plan_id}, root=BASE)
self.assertIn(queued.status_code, (200, 202), queued.text)
run_id = queued.json()["id"]
self.assertTrue(self.worker.process_next(), "队列里应当有一次待执行运行")
with self.service.connect() as db:
return store.get(db, "run", run_id)
@staticmethod
def checks_of(run, code):
return [check for step in run["steps"] for check in step.get("findings", [])
if check.get("code") == code]
def released_version(self):
with self.service.connect() as db:
return store.get(db, "release", CARD_ID)["version_id"]
def propose_improvement(self, *, target="knowledge", revision_ref=None, actor=MANAGER,
source_run_id=None):
body = {"target": target,
"target_ref": {"id": CARD_ID, "version": V1},
"proposed_revision_ref": revision_ref or {"id": CARD_ID, "version": V2},
"change_summary": "把回款集中依赖的核查口径扩到制造业设备直租",
"source_run_ids": [source_run_id] if source_run_id else ["run-1"]}
created = self.call("POST", f"/projects/{PROJECT}/improvements", body, actor)
self.assertEqual(created.status_code, 201, created.text)
return created.json()
def approve(self, candidate, actor=REVIEWER):
"""对照评测 → 提交 → 由**另一位**身份通过审核。"""
evaluated = self.call("POST", f"/improvements/{candidate['id']}/transitions",
{"action": "evaluate", "evaluation": improvement_evaluation()})
self.assertEqual(evaluated.status_code, 200, evaluated.text)
self.assertEqual(evaluated.json()["status"], "ready", evaluated.text)
self.assertEqual(self.call("POST", f"/improvements/{candidate['id']}/transitions",
{"action": "submit"}).status_code, 200)
approved = self.call("POST", f"/improvements/{candidate['id']}/transitions",
{"action": "approve"}, actor)
self.assertEqual(approved.status_code, 200, approved.text)
self.assertEqual(approved.json()["status"], "approved")
return approved.json()
# ------------------------------------------------------------ AC-57
def test_release_publishes_the_real_version_and_old_run_still_replays(self):
plan_id, run_old = self.publish_v1_and_run_once()
old_refs = {(item["id"], item["version"]) for item in run_old["component_versions"]}
self.assertIn((CARD_ID, V1), old_refs, "第一次运行应冻结 v1.0.0")
self.assertEqual(self.released_version(), f"{CARD_ID}@{V1}")
# 1) 新版本草稿 + 隔离评测(发布门禁,与候选的旧/新对照是两道不同的门)
self.draft_card(V2)
evaluated = self.call("POST", f"/knowledge-candidates/{CARD_ID}/transitions",
{"action": "evaluate"}, REVIEWER)
self.assertEqual(evaluated.status_code, 200, evaluated.text)
# 2) 改进候选:对照 → 提交 → 他人审核 → 发布(**请求体不含任何版本号**)
candidate = self.propose_improvement(source_run_id=run_old["id"])
self.approve(candidate)
released = self.call("POST", f"/improvements/{candidate['id']}/transitions",
{"action": "release"}, REVIEWER)
self.assertEqual(released.status_code, 200, released.text)
body = released.json()
self.assertEqual(body["status"], "released")
# 版本引用由服务端从真实发布结果产生
self.assertEqual(body["released_ref"], {"id": CARD_ID, "version": V2})
# 3) 发布真的生效:release 指针被覆盖,不可变版本已落库
self.assertEqual(self.released_version(), f"{CARD_ID}@{V2}")
with self.service.connect() as db:
versions = {row["id"] for row in store.listing(db, "component_version")}
self.assertIn(f"{CARD_ID}@{V2}", versions, "必须真的写入不可变组件版本")
# 4) 新运行使用新版本(同一个已发布方案,无需重装)
run_new = self.run_with(plan_id)
new_refs = {(item["id"], item["version"]) for item in run_new["component_versions"]}
self.assertIn((CARD_ID, V2), new_refs, "新运行必须取到新版本")
new_checks = self.checks_of(run_new, CARD_CODE)
self.assertEqual(len(new_checks), 1, "新版本必须真的产生核查项")
self.assertEqual(new_checks[0]["basis_ids"], [f"{CARD_ID}@{V2}"])
# 5) 旧运行仍可回放,且冻结的版本没有被新发布改写
with self.service.connect() as db:
replayed = trajectory.replay(db, PROJECT, run_old["id"])
reread = store.get(db, "run", run_old["id"])
step = next(item for item in replayed["steps"]
if (item["component_ref"] or {}).get("id") == CARD_ID)
self.assertEqual(step["component_ref"]["version"], V1,
"旧运行回放必须仍是发布前的版本")
self.assertEqual({(item["id"], item["version"]) for item in reread["component_versions"]},
old_refs, "旧运行的冻结版本不得被新发布改写")
self.assertTrue(replayed["read_only"])
def test_client_supplied_version_is_rejected_and_changes_nothing(self):
"""自报 `released_ref` 不得让候选变成已发布,也不得凭空造出组件版本。"""
candidate = self.propose_improvement()
self.approve(candidate)
forged = self.call("POST", f"/improvements/{candidate['id']}/transitions",
{"action": "release",
"released_ref": {"id": CARD_ID, "version": "9.9.9"}}, REVIEWER)
self.assertEqual(forged.status_code, 422, forged.text)
self.assertEqual(forged.json()["error"]["code"], "INVALID_REQUEST")
with self.service.connect() as db:
stored = store.get(db, improvements.KIND, candidate["id"])
versions = {row["id"] for row in store.listing(db, "component_version")}
self.assertEqual(stored["status"], "approved", "拒绝后候选状态不得前进")
self.assertIsNone(stored["released_ref"])
self.assertNotIn(f"{CARD_ID}@9.9.9", versions)
def test_release_keeps_the_previous_version_when_the_draft_was_not_evaluated(self):
"""未过隔离评测的草稿不得被发布;失败时保留旧版(发布不是"改状态")。"""
_, run_old = self.publish_v1_and_run_once()
self.assertEqual(self.released_version(), f"{CARD_ID}@{V1}")
self.draft_card(V2) # 只建草稿,**不做**隔离评测
candidate = self.propose_improvement(source_run_id=run_old["id"])
self.approve(candidate)
refused = self.call("POST", f"/improvements/{candidate['id']}/transitions",
{"action": "release"}, REVIEWER)
self.assertEqual(refused.status_code, 409, refused.text)
self.assertEqual(refused.json()["error"]["code"], "IMPROVEMENT_NOT_VALIDATED")
self.assertEqual(self.released_version(), f"{CARD_ID}@{V1}", "发布失败必须保留旧版")
with self.service.connect() as db:
versions = {row["id"] for row in store.listing(db, "component_version")}
stored = store.get(db, improvements.KIND, candidate["id"])
self.assertNotIn(f"{CARD_ID}@{V2}", versions, "未评测的版本不得落库")
self.assertEqual(stored["status"], "approved", "失败时候选状态不得前进")
def test_unsupported_target_is_refused_instead_of_faked(self):
"""题库/编排尚无真实发布入口:诚实拒绝,不用改状态冒充发布。"""
candidate = self.propose_improvement(target="question_profile")
self.approve(candidate)
refused = self.call("POST", f"/improvements/{candidate['id']}/transitions",
{"action": "release"}, REVIEWER)
self.assertEqual(refused.status_code, 409, refused.text)
self.assertIn("尚无真实发布入口", refused.json()["error"]["message"])
with self.service.connect() as db:
stored = store.get(db, improvements.KIND, candidate["id"])
self.assertNotEqual(stored["status"], "released")
self.assertIsNone(stored["released_ref"])
if __name__ == "__main__":
unittest.main()