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:
+66
-12
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""纠偏第四批 · 改进发布接到真实知识发布入口(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()
|
||||
@@ -222,13 +222,32 @@ class ImprovementApiTests(unittest.TestCase):
|
||||
candidate = created.json()
|
||||
self.assertEqual(candidate["status"], "draft")
|
||||
|
||||
# release 不再接受调用方自报的版本号(BR-51 / AC-57);未过门禁在此先命中
|
||||
blocked = self.client.post(
|
||||
f"{self.base}/improvements/{candidate['id']}/transitions",
|
||||
json={"action": "release",
|
||||
"released_ref": {"id": "card-1", "version": "2.0.0"}}, headers=HEADERS)
|
||||
json={"action": "release"}, headers=HEADERS)
|
||||
self.assertEqual(blocked.status_code, 409)
|
||||
self.assertEqual(blocked.json()["error"]["code"], "IMPROVEMENT_NOT_VALIDATED")
|
||||
|
||||
def test_client_supplied_released_ref_is_rejected(self):
|
||||
"""发布版本必须由服务端从真实发布结果产生,不接受调用方提交。"""
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
"proposed_revision_ref": {"id": "card-1", "version": "2.0.0"},
|
||||
"change_summary": "扩适用范围",
|
||||
"source_run_ids": ["run-1"]}, headers=HEADERS).json()
|
||||
response = self.client.post(
|
||||
f"{self.base}/improvements/{created['id']}/transitions",
|
||||
json={"action": "release",
|
||||
"released_ref": {"id": "card-1", "version": "2.0.0"}}, headers=HEADERS)
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertEqual(response.json()["error"]["code"], "INVALID_REQUEST")
|
||||
# 自报的版本号不得让候选变成"已发布"
|
||||
stored = self.client.get(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
headers=HEADERS).json()["items"][0]
|
||||
self.assertEqual(stored["status"], "draft")
|
||||
self.assertIsNone(stored["released_ref"])
|
||||
|
||||
def test_leakage_is_reported_as_e11(self):
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
|
||||
@@ -147,7 +147,7 @@ TrajectoryStep 的输入/输出正文通过授权存储引用访问;cost 缺
|
||||
| GET /runs/{id}/trajectory | 200 {run_id,steps:TrajectoryStep[],next_cursor};seq稳定分页 | 项目查看;404;按当前权限解析引用 |
|
||||
| POST /projects/{project_id}/review-outcomes | meeting_id,occurred_at,reviewed_snapshot_id?,raw_code,conditions,evidence_ids,supersedes_id?;201 ReviewOutcome;服务端映射label | 指定评审记录人;E-03/04/07,E-23/26为已保存记录警示 |
|
||||
| POST /projects/{project_id}/improvements | target,target_ref,source_run_ids,outcome_ids,change_summary,proposed_revision_ref;201 ImprovementCandidate draft | 提议者;E-03/04/07 |
|
||||
| POST /improvements/{id}/transitions | action:evaluate/submit/approve/reject/release/withdraw,expected_revision,note;200 ImprovementCandidate | 审核与发布独立资格;E-03/05/24;release委托既有发布入口 |
|
||||
| POST /improvements/{id}/transitions | action:evaluate/submit/approve/reject/release/withdraw,expected_revision,note;200 ImprovementCandidate | 审核与发布独立资格;E-03/05/24;release委托既有发布入口,**版本引用由服务端从真实发布结果产生,不接受客户端提交 `released_ref`**(提交即 422) |
|
||||
|
||||
提炼作业、数据生成与评测先做内部受控任务;不开放任意prompt/文件路径/SQL执行API。新增读取卡片、方案、会议结果和改进列表在T02沿既有组件目录/对象查询接口扩展;实际URL与所有前端消费字段须在Contract gate前补齐,当前明确为阻塞项,不由实现者临时猜路径。
|
||||
|
||||
@@ -161,7 +161,8 @@ TrajectoryStep 的输入/输出正文通过授权存储引用访问;cost 缺
|
||||
| 会议结果 | 追加保存matched/unmatched;核对关联通过追加修订记录 | 无证据拒绝;E-23/26可保存但不计有效标签;更正不改原运行 |
|
||||
| ImprovementCandidate | draft→evaluating→ready或rejected;ready→in_review→approved或rejected;approved→released;released→withdrawn | rejected可新建修订draft;评测失败保留原因;release引用已有不可变发布结果 |
|
||||
|
||||
改进发布与卡片发布同一事务/幂等命令边界,不允许一边标released一边没有对应版本;若复用服务跨事务,须在详细实现设计中明确outbox/恢复协议并有故障测试。评测锁定后才揭示结果;标签污染任何阶段均可追加作废记录。
|
||||
改进发布与卡片发布同一事务/幂等命令边界,不允许一边标released一边没有对应版本;若复用服务跨事务,须在详细实现设计中明确outbox/恢复协议并有故障测试。
|
||||
**已实现(纠偏第四批)**:`release` 由端点注入的 publisher 在不新增连接的同一个 `_db()` 事务内执行——先按候选的 `proposed_revision_ref` 以 `组件id@版本` **精确**定位草稿(不按组件 id 取"最新草稿",否则会发布与候选声明不符的版本),再调 `knowledge_cards.publish` → `component_registry.release`;publisher 抛异常即整体回滚,组件版本与候选状态要么一起生效、要么都不写。"发布失败保留旧版"与"自报版本号被拒"两条反例均有测试。评测锁定后才揭示结果;标签污染任何阶段均可追加作废记录。
|
||||
|
||||
存储优先现有对象/版本/审计机制,通过追加迁移保存新增类型;唯一键至少覆盖run+sequence、project+版本引用、命令幂等范围。完整DDL、索引、读写白名单及迁移前后恢复测试属于T02门禁,当前未建表。新数据根与旧库物理隔离由T01验证。
|
||||
|
||||
|
||||
@@ -105,53 +105,59 @@ docs/project-process/README.md、XINGBANG-ADAPTATION.md,执行 git status --sh
|
||||
> 交付定义与四批顺序以 [16 交付纠偏规格](16-delivery-corrections.md) 为准;
|
||||
> 下表的"已交付"只表示该任务包的代码与测试存在,不再表示产品功能可用。
|
||||
|
||||
- 日期:2026-10-01(**纠偏第三批实施轮**;第二批见 `2e7e194`/`01c88af`,第一批见更早提交)。
|
||||
- 日期:2026-10-01(**纠偏第四批实施轮**;第三批见 `ae89462`/`3e15bda`,
|
||||
第二批见 `2e7e194`/`01c88af`,第一批见更早提交)。
|
||||
- 仓库:`F:/mini-OpenClaw-v2`;远程 `http://192.168.10.111:3000/qiaoqichao/mini-openclaw-v2.git`
|
||||
(OQ-06 Q2 的远程归属已定案;端口/资源/CI 三问仍 open;**本轮未推送**)。
|
||||
- 完成范围:**纠偏第三批「按原型完成界面」**——深色导航+浅色工作区、
|
||||
三个模块的独立入口(AI 预审 / 历史经验加工 / 轨迹复盘)、轨迹回放界面。
|
||||
`scripts/verify.py` **13 项检查**全通过(新增 `module-shell-ui` 源码守卫)。
|
||||
- 完成范围:**纠偏第四批「接通评审反馈与改进发布」**——会议结果从页面录入并与材料版本对照;
|
||||
改进候选接上**真实知识发布入口**,发布后新运行用新版本、旧运行仍可回放。
|
||||
界面并入「轨迹复盘」模块(`/trajectory-center`),**不新增第四个导航入口**。
|
||||
`scripts/verify.py` **14 项检查**全通过(新增 `improvement-panel-ui` 源码守卫)。
|
||||
- 验证入口:`cd F:/mini-OpenClaw-v2 && backend/venv/Scripts/python.exe scripts/verify.py`;
|
||||
本轮证据:`test-evidence/20261001-005027-ae89462/`(13/13 PASS)。
|
||||
- 测试规模:全量 **567 项** unittest 无回归(第二批已含新增 36 项;本批为界面改动,
|
||||
证据是源码守卫 + 生产构建,未新增后端单测)。
|
||||
- 本轮提交:`ae89462` —— `feat(v2): 纠偏第三批——深色导航、三模块独立入口与轨迹复盘`。
|
||||
- 卡片验收对照(详见 [17 四批交付记录](17-batch-delivery-records.md) 第三批):
|
||||
- AC-53:`/` 呈现三个模块入口;导航为深色(`#102B27`/选中 `#244D41`),画布保持浅色;
|
||||
原落地页内容保留在 `/assistant`,不再"只保留旧版布局"。
|
||||
- AC-54:概率状态与依据、动态维度、推进沙盘在 `PrescreenPanel`;
|
||||
可回放轨迹由新增 `TrajectoryPanel` 消费既有只读端点(失败保留、成本缺失留空、不渲染推理字段)。
|
||||
- AC-55:新路由与样式通过守卫——已合并旧页仍为重定向、导航按角色裁剪、
|
||||
模块以 `?project=` 为上下文且不硬编码项目 id、前后端路径一致。
|
||||
- BR-50:三个模块各有独立路由与入口,均在**复用**既有组件而不是在新页里重写一份。
|
||||
本轮证据:`test-evidence/20261001-013517-3e15bda/`(14/14 PASS)。
|
||||
- 测试规模:全量 **572 项** unittest 无回归(新增 `test_improvement_release.py` 4 项 +
|
||||
`test_improvements.py` 1 项)。
|
||||
- 卡片验收对照(详见 [17 四批交付记录](17-batch-delivery-records.md) 第四批):
|
||||
- **AC-57**:端点把 `_knowledge_publisher` 注入 `improvements.transition`;publisher 用候选声明的
|
||||
`proposed_revision_ref` **精确**定位草稿(`组件id@版本`),调 `knowledge_cards.publish`
|
||||
→ `component_registry.release`,写入不可变 `component_version` 并**覆盖 `release` 指针**。
|
||||
端到端证明:同一方案发布前运行冻结 `卡片@1.0.0`,发布后**再跑一次**得到 `卡片@1.0.1`,
|
||||
旧运行回放仍是 `1.0.0`。**不接受客户端自报版本号**(422);非知识类目标 409 诚实拒绝。
|
||||
- **AC-56**:`TrajectoryPanel` 可追加会议结果(原始结果码、会议编号、材料版本快照、
|
||||
更正走 `supersedes_id`);`matched` / `unmatched` 分组,未匹配单列且不计入分母。
|
||||
- 未决项(阻塞后续):
|
||||
- **F17(新)真实模型提炼未实测**:本机未设置 `XINGBANG_LOCAL_MODEL_*`,
|
||||
- **F17** 真实模型提炼未实测:本机未设置 `XINGBANG_LOCAL_MODEL_*`,
|
||||
AC-50 前半句目前只有"不可用路径 + 假传输成功路径"两类证据。
|
||||
需端点后按 17 的操作步骤补一次真实调用;在此之前不得宣称"真实提炼已跑通"。
|
||||
- **F18(新)例外边界未进入可求值条件语言**:注册表条件只支持数值阈值算子
|
||||
(`present/absent` 被既有断言锁定拒绝),模型给出的例外保存为文本 `exception_note`。
|
||||
- **F19(新)卡片适用条件不参与运行内逐项判定**:只用于域/范围/有效期过滤
|
||||
(`knowledge_cards.effective()`);运行内条件求值需独立设计。
|
||||
- **F18** 例外边界未进入可求值条件语言;**F19** 卡片适用条件不参与运行内逐项判定。
|
||||
(同第二批,本批未动。)
|
||||
- **F11** 界面仍未经浏览器验证:`/experience-center`、`/prescreen-workbench`、
|
||||
`/trajectory-center` 与深色导航都只有"源码守卫 + 生产构建 + 代码走查"三层证据
|
||||
(本机无 playwright/puppeteer/chromium)。深色底对比度已改为守卫内按 WCAG 实算
|
||||
(最低 8.37:1),但配色达标 ≠ 实机版面已验收。
|
||||
- **F20(新)新旧经验入口并存**:工作台仍保留旧「评审经验」页签(走
|
||||
`assistant_experiences`),与第二批的知识卡片治理不是同一套。删除是用户可见的
|
||||
破坏性动作,本批未擅自下线,需确认后处理。
|
||||
- **F21(新)首页默认路由变更**:`/` 由聊天页改为业务工作区,原内容移到 `/assistant`。
|
||||
若部署脚本、书签或外部链接依赖旧的 `/`,需另行确认。
|
||||
- **F22(新)轨迹/会议结果的录入属第四批**:本批只做只读回放与只读对照。
|
||||
`/trajectory-center`(本批新增的会议结果录入与改进面板也在内)都只有
|
||||
"源码守卫 + 生产构建 + 代码走查"三层证据(本机无 playwright/puppeteer/chromium)。
|
||||
本批另加了**真实服务器接口冒烟**(走界面同一串 HTTP 请求),但它同样不是浏览器点击。
|
||||
深色底对比度已改为守卫内按 WCAG 实算(最低 8.37:1),配色达标 ≠ 实机版面已验收。
|
||||
- **F20** 新旧经验入口并存(工作台旧「评审经验」页签);**F21** 首页默认路由已变更
|
||||
`/` → `/assistant`。两者都需确认后处理。
|
||||
- **F23(新)改进端点仍无角色门禁**:既有候选/方案端点对评测与发布有 `role()` 门禁,
|
||||
改进端点只有项目范围检查。本批 release 接上真实发布后后果变重,但加门禁会改变既有
|
||||
端点行为(现有 API 测试以 `manager` 身份调用),未擅自扩大改动。
|
||||
"不自批"已由服务端强制(`must_not_self_approve` + `registry.release()` 的 `edited_by`)。
|
||||
- **F24(新)对照样本是人工录入的 JSON**,非真实保留集;`evaluation.independent` 仍是
|
||||
客户端声明的布尔——服务端无法自证独立性。
|
||||
- **F25(新)会议结果是模拟录入**,未经真实会议系统;真实标签映射与身份权限待确认(M5/M6)。
|
||||
- **F26(新)题库/编排无真实发布入口**(`question_profile`/`orchestration`):已按 409
|
||||
诚实拒绝,非假接通。
|
||||
- **F22 已关闭**(会议结果与轨迹的录入已在第四批实现)。
|
||||
- **F15 已关闭**(改进发布已接真实知识发布入口,并经真实发布指针验证)。
|
||||
- **F7** 各卡门禁仍 `proposed`,审阅者未指派——用户授权不等于评审通过。
|
||||
- **F8** Docker 运行时冒烟未实测(本机 Docker Desktop 守护进程不可达)。
|
||||
- **F6** OQ-06 Q3–Q5:端口冲突核验、运行资源、Gitea CI 是否启用仍 open。
|
||||
- **F12/F14/F16** 仍有效;**F13** 已由第一批关闭(运行视图改由执行链出口产生)。
|
||||
- **F15** 改进发布仍未接真实知识发布入口——属第四批。
|
||||
- 下一任务:**第四批「评审反馈与改进发布」**(AC-56/57,含会议结果录入与
|
||||
改进候选的真实知识发布接线);或先补 **F17**(需要你提供模型端点与调用时机)。
|
||||
不要为了推进而把演示当验收。
|
||||
- 待输入:模型端点与调用时机(F17);真实数据/制度/会议标签映射与客户身份权限(M5/M6);
|
||||
Laya 硬件与中文效果需实机探测;Jev 端点与凭据。
|
||||
- Git:本轮提交在 `codex/xingbang-v2-model-field-fix` 分支,**尚未推送**;
|
||||
核对命令 `git log --oneline -3`(当前 HEAD `ae89462`)。
|
||||
- **F12/F14/F16** 仍有效;**F13** 已由第一批关闭。
|
||||
- 下一任务:四批纠偏已全部有交付(均记**部分完成**)。继续推进前先定:
|
||||
补 **F17**(需要你提供模型端点与调用时机)或处理 **F11**(需要可用的浏览器自动化环境);
|
||||
两者都需要你提供的输入,不要为了推进而把演示当验收。
|
||||
- 待输入:模型端点与调用时机(F17);浏览器自动化环境(F11);真实数据/制度/会议标签映射与
|
||||
客户身份权限(M5/M6);Laya 硬件与中文效果需实机探测;Jev 端点与凭据。
|
||||
- Git:本轮改动在 `codex/xingbang-v2-model-field-fix` 分支,**尚未推送**;
|
||||
核对命令 `git log --oneline -3`。
|
||||
不得从旧恢复点 4d5a420 推断最新版本已备份。
|
||||
|
||||
@@ -271,4 +271,88 @@ XINGBANG_LOCAL_MODEL_KEY=<你的 DeepSeek API Key>
|
||||
|
||||
## 第四批:接通评审反馈与改进发布
|
||||
|
||||
状态:**未开始**。
|
||||
状态:**部分完成**。会议结果可从页面录入并与材料版本对照;改进候选走**真实知识发布入口**,
|
||||
发布后新运行使用新版本、旧运行仍可回放——两条断言都由真实执行链(enqueue + worker)证明。
|
||||
**未闭合**:界面仍未经浏览器点击/截图验证(同 F11);会议结果是**模拟录入**,未经真实会议系统。
|
||||
|
||||
覆盖:AC-56、AC-57、BR-51。
|
||||
|
||||
### 页面入口
|
||||
|
||||
| 入口 | 位置 |
|
||||
|---|---|
|
||||
| 轨迹复盘(既有入口,**不新增第四个**) | 深色导航「轨迹复盘」→ `/trajectory-center` |
|
||||
| 会议结果录入与对照 | 同页 `TrajectoryPanel` 下半部「会议结果对照」→「追加会议结果」表单 |
|
||||
| 改进对照与发布 | 同页 `ImprovementPanel`「改进对照与发布」 |
|
||||
|
||||
置于轨迹复盘之内而非新开模块:14 §7 本就把「轨迹、会议记录与反向改进」写在同一节,
|
||||
AC-53 又要求恰好三个模块入口。`AgentNav.tsx` 未改动。
|
||||
|
||||
### 操作步骤(实现并按此核对)
|
||||
|
||||
1. **录入会议结果**:选项目 → `TrajectoryPanel` 底部填原始结果码、会议编号、可从
|
||||
`project.runs[].input_snapshot_id` 选「对应的材料版本」→ 保存。
|
||||
2. **更正**:在「更正哪条记录」里选旧记录 → 保存为**追加**并带 `supersedes_id`,历史不被覆盖。
|
||||
3. **提改进候选**:`ImprovementPanel` ① 选来源运行/会议结果(必填其一)、改进目标、
|
||||
要发布的草稿版本、变更说明 → 「提出改进候选」。
|
||||
4. **两道门**:② 「对草稿做隔离评测」(发布门禁)与「提交旧/新对照」(候选门禁)是两件事,缺一不可。
|
||||
5. **审核与发布**:提交审核 → 由**另一位**身份「通过审核」→「审核发布」。
|
||||
发布请求体**只有 action**,界面不提交任何版本号。
|
||||
|
||||
### 实际结果
|
||||
|
||||
- **发布是真的发布**(本批核心):端点把 `_knowledge_publisher` 注入 `improvements.transition`,
|
||||
publisher 用候选声明的 `proposed_revision_ref` **精确**定位草稿(`组件id@版本`,
|
||||
不按组件 id 猜"最新草稿",否则会出现"审核的是 A、发布的是 B"),再调
|
||||
`knowledge_cards.publish` → `component_registry.release`:自批拒绝、隔离评测门禁、
|
||||
依赖可解析三道既有保证原样生效,写入不可变 `component_version` 并**覆盖 `release` 指针**。
|
||||
- **不接受自报版本号**:release 请求带 `released_ref` 一律 422 `INVALID_REQUEST`,
|
||||
候选状态不前进、不产生任何组件版本(反例已测)。
|
||||
- **发布失败保留旧版**:未过隔离评测的草稿 → 409 `IMPROVEMENT_NOT_VALIDATED`,
|
||||
`release` 指针仍指旧版本,候选仍是 `approved`。候选状态与组件版本写在**同一个 `_db()`
|
||||
连接/事务**里,publisher 抛异常即整体回滚,不会出现"版本已发布、候选没标记"。
|
||||
- **非知识类目标诚实拒绝**:`orchestration` / `question_profile` 无真实发布入口 →
|
||||
409 并说明原因,不用"改状态"冒充发布。
|
||||
- **AC-57 端到端**(`test_improvement_release.py`,真实 enqueue + `DurableWorker.process_next()`):
|
||||
同一个已发布方案,发布前运行的 `component_versions` 含 `卡片@1.0.0`、
|
||||
`basis_ids=[卡片@1.0.0]`;经改进候选发布 `1.0.1` 后**再跑一次**得到 `卡片@1.0.1`;
|
||||
回放第一次运行仍是 `1.0.0`,其冻结版本未被改写(`replay.read_only=true`)。
|
||||
- **AC-56**:会议结果按 `matched` / `unmatched` 分组;未匹配的单列并显示「不计入分母」,
|
||||
与服务端 `denominator()` 一致(未送审、撤回、附条件、未知与未匹配都不进分母)。
|
||||
- **真实服务器冒烟**(不是单测的替代,脚本 `scripts/xingbang_batch4_smoke.py`):
|
||||
对运行中的 uvicorn 按界面同一串请求走通
|
||||
建卡 → 隔离评测 → 发布 → 结构化修订 → 录入会议结果 → 提候选 → 对照 → 换身份审核
|
||||
→ 发布(不带版本号);服务端返回 `released_ref=卡片@1.0.1`,且 `release` 指针实测指向 `1.0.1`。
|
||||
只用了脚本新建的组件 id,未触碰既有组件的发布指针。
|
||||
**副作用**:它在演示库(`backend/xingbang_data/project_assistant.sqlite3`)新建了若干
|
||||
「冒烟:回款集中依赖核查」卡片与 `SMOKE-4` 会议结果;重复运行会累积。
|
||||
这些是演示库里的可见脏数据,需要时可删——本记录如实列出,不做隐藏。
|
||||
|
||||
### 证据位置
|
||||
|
||||
| 内容 | 位置 |
|
||||
|---|---|
|
||||
| AC-57 端到端与四条反例(4 项) | `backend/test_improvement_release.py`(新增) |
|
||||
| 自报版本号被拒(API 层) | `backend/test_improvements.py` `test_client_supplied_released_ref_is_rejected` |
|
||||
| 界面源码守卫(新增) | `scripts/test_improvement_panel.cjs`(已登记 `scripts/verify.py`,共 **14** 项检查) |
|
||||
| 轨迹面板守卫(改:区分「回放只读」与「结果只可追加」) | `scripts/test_module_shell_ui.cjs` |
|
||||
| 全量验证 | `test-evidence/20261001-013517-3e15bda/`(`scripts/verify.py` **14/14 PASS**,含 `backend-unit` 全量 **572** 项) |
|
||||
| 真实服务器冒烟 | `scripts/xingbang_batch4_smoke.py`(对 127.0.0.1:8012 实跑,退出码 0) |
|
||||
|
||||
### 剩余缺口
|
||||
|
||||
- **界面仍未经浏览器验证(F11 未变)**:本批新增的两个表面只有「源码守卫 + 生产构建
|
||||
(`/trajectory-center` 3.87 → 8.86 kB)+ 真实服务器接口冒烟」三层证据,**没有浏览器点击或截图**;
|
||||
表单交互、按钮、权限隐藏这些路径没有端到端证明。
|
||||
- **会议结果是模拟录入**:AC-56 验的是"录入后与材料版本对照"的机制,不是真实会议系统对接;
|
||||
真实标签映射与客户身份权限仍待确认(M5/M6)。
|
||||
- **对照样本是人工录入的 JSON**,不是真实保留集;`evaluation.independent` 仍是**客户端声明**
|
||||
的布尔(既有设计)——界面不把它包装成"已证明独立",但服务端也确实无法自证。
|
||||
- **改进端点仍无角色门禁**:既有 `transition_knowledge_candidate` / `transition_review_plan` 对
|
||||
评测与发布有 `role()` 门禁,改进端点(propose/evaluate/approve/release)只做项目范围检查。
|
||||
本批给 release 接上真实发布后这个缺口的后果变重了,但加门禁会改变既有端点行为
|
||||
(现有 API 测试以 `manager` 身份调用),**未擅自扩大改动**,留待确认。安全上真正关键的
|
||||
"不自批"已由服务端强制(`must_not_self_approve` + `registry.release()` 的 `edited_by == actor`)。
|
||||
- **题库/编排无真实发布入口**:已按 409 诚实拒绝,非假接通;需要时另行设计。
|
||||
- **顺带发现、未修**:`backend/test_trajectory.py:173` 依赖 `recorded_at` 排序,两条记录落在
|
||||
同一时钟刻度时顺序会翻转(本次全量跑中出现过一次,重跑即通过)。属既有测试脆弱性,非本批引入。
|
||||
|
||||
@@ -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");
|
||||
@@ -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"'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -2,6 +2,7 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import AgentNav from "@/components/xingbang/AgentNav";
|
||||
import TrajectoryPanel from "@/components/xingbang/TrajectoryPanel";
|
||||
import ImprovementPanel from "@/components/xingbang/ImprovementPanel";
|
||||
import {messageOf} from "@/lib/prescreen";
|
||||
import "../data-access/workspace.css";
|
||||
|
||||
@@ -9,6 +10,9 @@ import "../data-access/workspace.css";
|
||||
*
|
||||
* 原 `/trajectory` 是合并后的重定向桩,本页是**新路由**,不复用也不复活那个旧页
|
||||
* (AC-55 的"防重复页面")。只负责项目上下文与请求助手,回放逻辑在 TrajectoryPanel。
|
||||
*
|
||||
* 纠偏第四批把「会议记录」与「反向改进」并入本模块(14 §7 本就把三者写在一节),
|
||||
* 因此**不新增第四个导航入口**(AC-53 要求恰好三个)。
|
||||
*/
|
||||
export default function TrajectoryCenterPage() {
|
||||
const [identity, setIdentity] = useState<any>(null);
|
||||
@@ -26,9 +30,11 @@ export default function TrajectoryCenterPage() {
|
||||
...(role ? {"X-Xingbang-Demo-Role": role} : {})};
|
||||
}
|
||||
|
||||
async function request(path: string, body?: any) {
|
||||
// 必须支持 PATCH:改进候选的"自然语言修改适用范围"走的是既有候选端点的 PATCH。
|
||||
// 形参与 /experience-center 一致,两个模块不各写一份请求助手。
|
||||
async function send(method: string, path: string, body?: any) {
|
||||
const response = await fetch(`/api/risk-agent/project-assistant${path}`,
|
||||
{method: body === undefined ? "GET" : "POST", headers: headers(),
|
||||
{method, headers: headers(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(messageOf(data));
|
||||
@@ -40,7 +46,7 @@ export default function TrajectoryCenterPage() {
|
||||
try {
|
||||
const [context, list] = await Promise.all([
|
||||
fetch("/api/risk-agent/access/context", {headers: headers(false)}).then((r) => r.ok ? r.json() : null).catch(() => null),
|
||||
request("/projects"),
|
||||
send("GET", "/projects"),
|
||||
]);
|
||||
setIdentity(context);
|
||||
const items = list.items || list || [];
|
||||
@@ -48,7 +54,7 @@ export default function TrajectoryCenterPage() {
|
||||
const wanted = new URLSearchParams(location.search).get("project") || items[0]?.id || "";
|
||||
if (wanted) {
|
||||
setProjectId(wanted);
|
||||
setProject(await request(`/projects/${encodeURIComponent(wanted)}`));
|
||||
setProject(await send("GET", `/projects/${encodeURIComponent(wanted)}`));
|
||||
}
|
||||
} catch (e: any) {setError(e.message);} finally {setLoaded(true);}
|
||||
})();
|
||||
@@ -57,7 +63,7 @@ export default function TrajectoryCenterPage() {
|
||||
async function open(id: string) {
|
||||
setProjectId(id); setError("");
|
||||
try {
|
||||
setProject(await request(`/projects/${encodeURIComponent(id)}`));
|
||||
setProject(await send("GET", `/projects/${encodeURIComponent(id)}`));
|
||||
history.replaceState({}, "", `?project=${encodeURIComponent(id)}`);
|
||||
} catch (e: any) {setError(e.message);}
|
||||
}
|
||||
@@ -86,7 +92,9 @@ export default function TrajectoryCenterPage() {
|
||||
{projects.map((item) => <option key={item.id} value={item.id}>{item.id} · {item.customer || ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{project && <TrajectoryPanel key={project.id} project={project} request={request}/>}
|
||||
{project && <TrajectoryPanel key={project.id} project={project} send={send}/>}
|
||||
{project && <ImprovementPanel key={`imp-${project.id}`} project={project}
|
||||
identity={identity} send={send}/>}
|
||||
</>}
|
||||
</main>
|
||||
</div>;
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
import {useEffect, useState} from "react";
|
||||
import {AlertTriangle, ArrowUpRight, CheckCircle2, Gauge, ShieldCheck, Wrench} from "lucide-react";
|
||||
import {PRESCREEN} from "@/lib/prescreen";
|
||||
|
||||
/** 改进对照(纠偏第四批 · BR-51 / AC-57;04-ui-spec §9 的「改进对照 / ImprovementPanel」)。
|
||||
*
|
||||
* 挂在 /trajectory-center 之内——14 §7 本就把「轨迹、会议记录与反向改进」写在同一节,
|
||||
* AC-53 又要求恰好三个模块入口,因此**不新增第四个导航入口**。
|
||||
*
|
||||
* 五条界面纪律:
|
||||
* 1. **发布不由界面给版本号**:release 请求体只有 action;版本由服务端调用真实知识发布入口
|
||||
* 产生。界面里没有任何地方提交 released_ref——否则又变成"返回一个版本号"。
|
||||
* 2. **两道门分开**:草稿的「隔离评测」与候选的「旧/新对照」是两件事,缺任一门都发布不了。
|
||||
* 3. **不自批**:提出者不显示"通过审核"按钮(后端另有强制,界面不诱导违规)。
|
||||
* 4. **不提供自动上线**:没有"一键发布/自动上线",也不在发布后自动发起运行。
|
||||
* 5. **泄漏不是成功**:对照被判泄漏时按失败显示,不渲染成"评测完成"。
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿", evaluating: "评测中", ready: "待提交审核", in_review: "待审核",
|
||||
approved: "已通过审核", rejected: "已拒绝", released: "已发布", withdrawn: "已撤回",
|
||||
};
|
||||
const TARGETS: [string, string, boolean][] = [
|
||||
["knowledge", "知识(Skill/Rule 卡片)", true],
|
||||
["orchestration", "编排配置", false],
|
||||
["question_profile", "题库", false],
|
||||
];
|
||||
const SAMPLE_PLACEHOLDER = `[
|
||||
{"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}
|
||||
]`;
|
||||
|
||||
/** 未完成门禁的只读镜像;服务端返回的文案为准(这里只用于提前提示)。 */
|
||||
function blockersOf(candidate: any): string[] {
|
||||
const list: string[] = [];
|
||||
const contrast = candidate.contrast;
|
||||
if (!contrast) list.push("缺少旧/新对照证据");
|
||||
else {
|
||||
if (contrast.contaminated?.length) list.push(`对照样本存在泄漏:${contrast.contaminated.join("、")}`);
|
||||
if (!contrast.independent) list.push("对照不是独立评测(同模型自评不算证据)");
|
||||
if (contrast.verdict === "regressed") list.push("对照显示退化,不得发布");
|
||||
if (contrast.verdict === "inconclusive") list.push("对照结论不明确,不得发布");
|
||||
}
|
||||
if (candidate.status !== "approved") list.push(`当前状态为「${STATUS_LABEL[candidate.status] || candidate.status}」,未通过审核`);
|
||||
return list;
|
||||
}
|
||||
|
||||
export default function ImprovementPanel({project, identity, send}: any) {
|
||||
const [runs, setRuns] = useState<any[]>([]);
|
||||
const [outcomes, setOutcomes] = useState<any[]>([]);
|
||||
const [cards, setCards] = useState<any[]>([]);
|
||||
const [candidates, setCandidates] = useState<any[]>([]);
|
||||
const [draft, setDraft] = useState<any>({target: "knowledge", target_ref: "", change_summary: ""});
|
||||
const [evaluationFor, setEvaluationFor] = useState("");
|
||||
const [samples, setSamples] = useState("");
|
||||
const [rejectFor, setRejectFor] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const path = `/projects/${encodeURIComponent(project.id)}`;
|
||||
|
||||
const canReview = Boolean(identity?.preview || ["admin", "reviewer"].includes(identity?.role));
|
||||
const canPublish = Boolean(identity?.preview || identity?.role === "reviewer");
|
||||
// 不自批:提出者看不到自己的"通过审核"(后端 must_not_self_approve 另有强制)
|
||||
const mayApprove = (item: any) => canReview && item.proposed_by !== identity?.id;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [found, list] = await Promise.all([
|
||||
send("GET", `${PRESCREEN}${path}/improvements`),
|
||||
send("GET", `${PRESCREEN}${path}/knowledge-candidates`),
|
||||
]);
|
||||
setCandidates(found.items || []);
|
||||
setCards(list.items || []);
|
||||
} catch (e: any) {setError(e.message);}
|
||||
try {setOutcomes((await send("GET", `${PRESCREEN}${path}/review-outcomes`)).items || []);}
|
||||
catch {setOutcomes([]);}
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setRuns((project?.runs || []).filter((item: any) => item.id));
|
||||
refresh();
|
||||
}, [project.id]);
|
||||
|
||||
async function act(work: () => Promise<any>, message: string) {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {const result = await work(); setNotice(message); await refresh(); return result;}
|
||||
catch (e: any) {setError(e.message); return null;} finally {setBusy(false);}
|
||||
}
|
||||
|
||||
const draftCards = cards.filter((card: any) => card.status === "draft");
|
||||
const proposedVersion = draftCards.find((card: any) => card.id === draft.target_ref);
|
||||
// 被改进的是"当前已发布的版本",拟发布的是那张草稿——两者是不同版本,不能混为一谈
|
||||
const published = proposedVersion && cards.find(
|
||||
(card: any) => card.status === "published" && card.id === proposedVersion.id);
|
||||
|
||||
const propose = () => act(
|
||||
() => send("POST", `${PRESCREEN}${path}/improvements`, {
|
||||
target: draft.target,
|
||||
target_ref: published
|
||||
? {id: published.id, version: published.version}
|
||||
: {id: proposedVersion.id, version: proposedVersion.version},
|
||||
proposed_revision_ref: {id: proposedVersion.id, version: proposedVersion.version},
|
||||
change_summary: draft.change_summary,
|
||||
source_run_ids: draft.source_run_id ? [draft.source_run_id] : [],
|
||||
outcome_ids: draft.outcome_id ? [draft.outcome_id] : [],
|
||||
}),
|
||||
"改进候选已提出(草稿)。发布仍需通过旧/新对照与独立审核两道门。");
|
||||
|
||||
const transition = (item: any, action: string, extra: any = {}) => act(
|
||||
() => send("POST", `${PRESCREEN}/improvements/${encodeURIComponent(item.id)}/transitions`,
|
||||
{action, ...extra}),
|
||||
action === "evaluate" ? "旧/新对照已提交;泄漏与指标由服务端判定。"
|
||||
: action === "submit" ? "已提交审核,等待独立审核人处理。"
|
||||
: action === "approve" ? "已通过审核;仍需发布动作才真正生效。"
|
||||
: action === "reject" ? "已拒绝,并留下原因。"
|
||||
: "发布请求已提交。");
|
||||
|
||||
const submitEvaluation = (item: any) => {
|
||||
let parsed: any;
|
||||
try {parsed = JSON.parse(samples);}
|
||||
catch {setError("对照样本必须是合法 JSON 数组。"); return Promise.resolve(null);}
|
||||
return transition(item, "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: parsed}});
|
||||
};
|
||||
|
||||
// 只传组件 id 会评测到"当前最新草稿";候选声明的却是某个具体版本,
|
||||
// 因此这里用 `组件id@版本` 精确定位,避免"评测的是 A、发布的是 B"。
|
||||
const evaluateDraft = (item: any) => act(
|
||||
() => send("POST", `${PRESCREEN}/knowledge-candidates/`
|
||||
+ `${encodeURIComponent(item.proposed_revision_ref.id + "@" + item.proposed_revision_ref.version)}/transitions`,
|
||||
{action: "evaluate"}),
|
||||
"草稿隔离评测已完成(这是发布门禁之一,与候选的旧/新对照不是同一道门)。");
|
||||
|
||||
const publishedRef = (item: any) => item.released_ref
|
||||
? `${item.released_ref.id}@${item.released_ref.version}` : "";
|
||||
|
||||
return <section className="xb-panel xb-wide">
|
||||
<h2>改进对照与发布</h2>
|
||||
<p>从可核验的偏差出发:选来源运行或会议结果 → 提出候选修订 → 固定旧版做对照 → 独立审核 →
|
||||
**由服务端调用真实知识发布入口**。发布只影响后续新运行;旧运行的冻结版本不变,仍可回放。</p>
|
||||
{error && <div role="alert" className="ra-alert error">{error}</div>}
|
||||
{notice && <div role="status" className="ra-alert">{notice}</div>}
|
||||
{!loaded && <p>正在读取改进候选…</p>}
|
||||
|
||||
<div className="xb-task">
|
||||
<h4>① 偏差来源与候选修订</h4>
|
||||
<p className="ra-note">候选必须引用来源运行或会议结果——凭空提出的改动不进入审核。
|
||||
新版本草稿在<a href={`/experience-center?project=${encodeURIComponent(project.id)}`}>历史经验加工</a>里修订或新增;
|
||||
本面板只负责把它送进对照与审核。</p>
|
||||
<label className="ra-field"><span>来源运行(可选)</span>
|
||||
<select aria-label="来源运行" value={draft.source_run_id || ""} disabled={busy}
|
||||
onChange={(e) => setDraft({...draft, source_run_id: e.target.value})}>
|
||||
<option value="">不引用运行</option>
|
||||
{runs.map((item: any) => <option key={item.id} value={item.id}>
|
||||
{new Date(item.created_at).toLocaleString()} · {item.status}
|
||||
</option>)}
|
||||
</select></label>
|
||||
<label className="ra-field"><span>来源会议结果(可选)</span>
|
||||
<select aria-label="来源会议结果" value={draft.outcome_id || ""} disabled={busy}
|
||||
onChange={(e) => setDraft({...draft, outcome_id: e.target.value})}>
|
||||
<option value="">不引用会议结果</option>
|
||||
{outcomes.map((item: any) => <option key={item.id} value={item.id}>
|
||||
{item.raw_code} · {new Date(item.occurred_at).toLocaleString()} · {item.status}
|
||||
</option>)}
|
||||
</select></label>
|
||||
<label className="ra-field"><span>改进目标</span>
|
||||
<select aria-label="改进目标" value={draft.target} disabled={busy}
|
||||
onChange={(e) => setDraft({...draft, target: e.target.value})}>
|
||||
{TARGETS.map(([value, text, enabled]) =>
|
||||
<option key={value} value={value} disabled={!enabled}>
|
||||
{text}{enabled ? "" : "(尚无真实发布入口,不可发布)"}
|
||||
</option>)}
|
||||
</select></label>
|
||||
<label className="ra-field"><span>要发布的草稿版本(来自历史经验加工)</span>
|
||||
<select aria-label="要发布的草稿版本" value={draft.target_ref} disabled={busy}
|
||||
onChange={(e) => setDraft({...draft, target_ref: e.target.value})}>
|
||||
<option value="">选择一张草稿卡片</option>
|
||||
{draftCards.map((card: any) => <option key={card.id} value={card.id}>
|
||||
{card.title} · v{card.version}
|
||||
</option>)}
|
||||
</select></label>
|
||||
<label className="ra-field"><span>变更说明(必填,会随候选取证)</span>
|
||||
<input value={draft.change_summary} disabled={busy} aria-label="变更说明"
|
||||
onChange={(e) => setDraft({...draft, change_summary: e.target.value})}/></label>
|
||||
<div className="xb-actions">
|
||||
<button className="ra-button" disabled={busy || !proposedVersion || !draft.change_summary.trim()}
|
||||
onClick={propose}><Wrench size={15}/>提出改进候选</button>
|
||||
</div>
|
||||
{!draftCards.length && <p className="ra-note">当前没有草稿卡片可发布:先在上方链接里修订或新增一张草稿。</p>}
|
||||
</div>
|
||||
|
||||
<h4>② 候选:对照、审核与发布</h4>
|
||||
{!candidates.length && <div className="ra-empty"><h2>尚无改进候选</h2><p>先在上一步提出一个候选。</p></div>}
|
||||
|
||||
{candidates.map((item: any) => {
|
||||
const blockers = blockersOf(item);
|
||||
return <div className="xb-finding" key={item.id}>
|
||||
<div>
|
||||
<b>{item.change_summary}</b>
|
||||
<p>
|
||||
<span className="ra-badge">{STATUS_LABEL[item.status] || item.status}</span>{" "}
|
||||
<span className="ra-badge">目标 {item.target}</span>{" "}
|
||||
<span className="ra-badge">拟发布 {item.proposed_revision_ref.id}@{item.proposed_revision_ref.version}</span>
|
||||
</p>
|
||||
<p><small>提出人 {item.proposed_by}{item.reviewer_id ? ` · 审核人 ${item.reviewer_id}` : ""}</small></p>
|
||||
|
||||
{item.contrast && <p className={item.contrast.contaminated?.length ? "xb-warning" : ""}>
|
||||
{item.contrast.contaminated?.length && <AlertTriangle size={13}/>}
|
||||
{" "}对照结论 <b>{item.contrast.verdict}</b>({item.contrast.independent ? "独立评测" : "非独立评测"},
|
||||
样本 {item.contrast.sample_size} 条)
|
||||
{item.contrast.contaminated?.length ? ` · 泄漏样本 ${item.contrast.contaminated.join("、")},整次作废` : ""}
|
||||
</p>}
|
||||
|
||||
{blockers.length > 0 && <div className="xb-warning">
|
||||
<b><AlertTriangle size={13}/> 未完成门禁(以服务端返回为准)</b>
|
||||
<ul>{blockers.map((text) => <li key={text}>{text}</li>)}</ul>
|
||||
</div>}
|
||||
|
||||
{item.status === "released" && <p className="xb-warning">
|
||||
<CheckCircle2 size={13}/> 已发布真实版本 <code>{publishedRef(item)}</code>:
|
||||
后续新运行将使用该版本,旧运行的冻结版本不变、仍可回放。
|
||||
</p>}
|
||||
|
||||
<div className="xb-actions">
|
||||
<button className="ra-button" disabled={busy} onClick={() => evaluateDraft(item)}>
|
||||
<Gauge size={15}/>对草稿做隔离评测
|
||||
</button>
|
||||
<button className="ra-button" disabled={busy}
|
||||
onClick={() => {setEvaluationFor(evaluationFor === item.id ? "" : item.id); setSamples("");}}>
|
||||
提交旧/新对照
|
||||
</button>
|
||||
{item.status !== "in_review" && item.status !== "released" &&
|
||||
<button className="ra-button" disabled={busy} onClick={() => transition(item, "submit")}>
|
||||
提交审核
|
||||
</button>}
|
||||
{mayApprove(item) && item.status !== "released" &&
|
||||
<button className="ra-button" disabled={busy} onClick={() => transition(item, "approve")}>
|
||||
<ShieldCheck size={15}/>通过审核
|
||||
</button>}
|
||||
{canReview && item.status !== "released" &&
|
||||
<button className="ra-button" disabled={busy}
|
||||
onClick={() => {setRejectFor(rejectFor === item.id ? "" : item.id); setReason("");}}>
|
||||
拒绝
|
||||
</button>}
|
||||
{canPublish && item.status === "approved" &&
|
||||
<button className="ra-button primary" disabled={busy} onClick={() => transition(item, "release")}>
|
||||
<ArrowUpRight size={15}/>审核发布
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
{canReview && item.proposed_by === identity?.id &&
|
||||
<p className="ra-note">本候选由你本人提出,因此不显示"通过审核":审核需要独立的另一位(BR-42 / AC-43)。</p>}
|
||||
|
||||
{evaluationFor === item.id && <div className="xb-task">
|
||||
<label className="ra-field"><span>固定版本的旧/新对照样本(JSON 数组;泄漏与指标由服务端判定)</span>
|
||||
<textarea className="ra-code" value={samples} disabled={busy}
|
||||
placeholder={SAMPLE_PLACEHOLDER}
|
||||
onChange={(e) => setSamples(e.target.value)}/>
|
||||
</label>
|
||||
<button className="ra-button" disabled={busy || !samples.trim()} onClick={() => submitEvaluation(item)}>
|
||||
提交对照并评测
|
||||
</button>
|
||||
</div>}
|
||||
|
||||
{rejectFor === item.id && <div className="xb-task">
|
||||
<label className="ra-field"><span>拒绝原因(必填)</span>
|
||||
<input value={reason} disabled={busy} onChange={(e) => setReason(e.target.value)}/>
|
||||
</label>
|
||||
<button className="ra-button" disabled={busy || !reason.trim()}
|
||||
onClick={() => transition(item, "reject", {reason})}>确认拒绝</button>
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
|
||||
<p className="ra-note">
|
||||
发布由服务端调用真实知识发布入口,**版本号不由界面提交**;发布失败会保留旧版本,
|
||||
候选也不会被标成已发布。这里没有"自动上线",发布后也不会自动发起运行。
|
||||
</p>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
import {useEffect, useState} from "react";
|
||||
import {AlertTriangle, Clock, FileText, RefreshCw} from "lucide-react";
|
||||
import {AlertTriangle, Clock, FileText, Plus, RefreshCw} from "lucide-react";
|
||||
import {PRESCREEN} from "@/lib/prescreen";
|
||||
|
||||
/** 轨迹复盘(AC-54 的"可回放轨迹";T10 的只读回放语义在界面上落地)。
|
||||
/** 轨迹复盘(AC-54 的"可回放轨迹";纠偏第四批补 AC-56 的会议结果录入)。
|
||||
*
|
||||
* 三条界面纪律,直接对应后端已有的规则:
|
||||
* 1. **只读**:这里没有任何写操作;回放不改运行、不改历史结果。
|
||||
* 四条界面纪律,直接对应后端已有的规则:
|
||||
* 1. **回放只读**:回放投影既有运行,不改运行、不改历史结果。本面板唯一的写操作是
|
||||
* 在下方**追加**一条会议结果——追加不是改写。
|
||||
* 2. **失败保留**:failed / skipped 步骤照常列出并带错误码,不因为"看起来不好"被过滤。
|
||||
* 3. **成本未知留空**:没有成本口径时显示"未记录",**不填 0**——0 会被读成"零成本"。
|
||||
* 4. **未匹配单列**:找不到材料版本的会议结果单独一组,既不当否决、也不进通过率分母。
|
||||
*
|
||||
* 不展示模型内部推理:轨迹只记录可观察动作与版本,界面也不假装能看到思维链。
|
||||
* 会议结果的**录入**属第四批;本页只读展示已保存的记录。
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
@@ -22,21 +23,38 @@ const KIND_LABEL: Record<string, string> = {
|
||||
plan: "方案", skill: "Skill", tool: "工具", rule: "规则",
|
||||
judgment: "专项判断", synthesis: "综合",
|
||||
};
|
||||
// 与后端 EventOutcome 一致;留空表示"由服务端按原始结果码的别名归一,未知就记 unknown"
|
||||
const NORMALIZED = [
|
||||
["", "未指定(由服务端按原始结果码归一)"],
|
||||
["approved", "通过"], ["rejected", "否决"], ["conditional", "附条件通过"],
|
||||
["deferred", "暂缓"], ["withdrawn", "撤回"], ["not_submitted", "未送审"],
|
||||
["unknown", "未知(不推断成通过或否决)"],
|
||||
];
|
||||
|
||||
export default function TrajectoryPanel({project, request}: any) {
|
||||
const EMPTY_FORM = {
|
||||
raw_code: "", meeting_id: "", normalized: "", reviewed_snapshot_id: "",
|
||||
occurred_at: "", supersedes_id: "", conditions: "",
|
||||
};
|
||||
|
||||
export default function TrajectoryPanel({project, identity, send}: any) {
|
||||
const [runId, setRunId] = useState("");
|
||||
const [trajectory, setTrajectory] = useState<any>(null);
|
||||
const [outcomes, setOutcomes] = useState<any[]>([]);
|
||||
const [outcomes, setOutcomes] = useState<any>(null);
|
||||
const [form, setForm] = useState<any>(EMPTY_FORM);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const runs = (project?.runs || []).filter((item: any) => item.id);
|
||||
const path = `/projects/${encodeURIComponent(project.id)}`;
|
||||
// 记录人可追加会议结果;只读访客不显示录入表单(后端每次动作仍会重验)
|
||||
const canRecord = Boolean(identity?.preview
|
||||
|| ["manager", "reviewer", "admin"].includes(identity?.role));
|
||||
|
||||
async function load(id: string, cursor?: string) {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const page = await request(`${PRESCREEN}${path}/runs/${encodeURIComponent(id)}/trajectory`
|
||||
const page = await send("GET", `${PRESCREEN}${path}/runs/${encodeURIComponent(id)}/trajectory`
|
||||
+ (cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""));
|
||||
setTrajectory((current: any) => cursor && current
|
||||
? {...page, steps: [...current.steps, ...page.steps]}
|
||||
@@ -44,17 +62,49 @@ export default function TrajectoryPanel({project, request}: any) {
|
||||
} catch (e: any) {setError(e.message);} finally {setBusy(false); setLoaded(true);}
|
||||
}
|
||||
|
||||
async function loadOutcomes() {
|
||||
try {
|
||||
setOutcomes(await send("GET", `${PRESCREEN}${path}/review-outcomes`));
|
||||
} catch {setOutcomes({items: [], matched: [], unmatched: []});}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!runs.length) {setTrajectory(null); setLoaded(true); return;}
|
||||
const first = runs[0].id;
|
||||
setRunId(first);
|
||||
load(first);
|
||||
request(`${PRESCREEN}${path}/review-outcomes`)
|
||||
.then((data: any) => setOutcomes(data.items || []))
|
||||
.catch(() => setOutcomes([]));
|
||||
if (runs.length) {
|
||||
const first = runs[0].id;
|
||||
setRunId(first);
|
||||
load(first);
|
||||
} else {setTrajectory(null); setLoaded(true);}
|
||||
setForm(EMPTY_FORM); setNotice("");
|
||||
loadOutcomes();
|
||||
}, [project.id]);
|
||||
|
||||
async function saveOutcome() {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const body: any = {
|
||||
raw_code: form.raw_code, meeting_id: form.meeting_id,
|
||||
conditions: form.conditions.split(",").map((item: string) => item.trim()).filter(Boolean),
|
||||
};
|
||||
if (form.normalized) body.normalized = form.normalized;
|
||||
if (form.reviewed_snapshot_id) body.reviewed_snapshot_id = form.reviewed_snapshot_id;
|
||||
if (form.supersedes_id) body.supersedes_id = form.supersedes_id;
|
||||
if (form.occurred_at) body.occurred_at = new Date(form.occurred_at).toISOString();
|
||||
const saved = await send("POST", `${PRESCREEN}${path}/review-outcomes`, body);
|
||||
setNotice(saved.status === "matched"
|
||||
? `已保存并关联材料版本 ${saved.reviewed_snapshot_id};计入分母前仍需口径映射。`
|
||||
: "已保存为**未匹配**:找不到对应材料版本,单列且不计入通过率分母。");
|
||||
setForm(EMPTY_FORM);
|
||||
await loadOutcomes();
|
||||
} catch (e: any) {setError(e.message);} finally {setBusy(false);}
|
||||
}
|
||||
|
||||
const steps = trajectory?.steps || [];
|
||||
const matched = outcomes?.matched || [];
|
||||
const unmatched = outcomes?.unmatched || [];
|
||||
const counts = outcomes?.counts_toward_denominator;
|
||||
const snapshots = runs.filter((item: any) => item.input_snapshot_id);
|
||||
const fill = (key: string, value: string) => setForm((current: any) => ({...current, [key]: value}));
|
||||
|
||||
return <section className="xb-panel xb-wide">
|
||||
<h2>运行轨迹回放</h2>
|
||||
<p>一次运行是一条轨迹,多次工具与模型调用是其中的步骤。这里只记录**可观察的动作与版本**,不包含模型内部推理;回放是只读的,不会改动这次运行。</p>
|
||||
@@ -110,17 +160,83 @@ export default function TrajectoryPanel({project, request}: any) {
|
||||
</div>}
|
||||
</>}
|
||||
|
||||
<h3>会议结果对照(只读)</h3>
|
||||
<p>AI 轨迹与会议记录并列查看。找不到材料版本的结果单列,既不当否决、也不进通过率分母。<b>录入属第四批</b>,这里只读。</p>
|
||||
{!outcomes.length && <p className="ra-note">尚未保存会议结果。</p>}
|
||||
{outcomes.map((item: any) => <div className="xb-finding" key={item.id}>
|
||||
<div>
|
||||
<b>{item.raw_code} · {item.status === "matched" ? "已关联材料版本" : "未匹配(单列)"}</b>
|
||||
<p>{item.normalized ? `归一化结果 ${item.normalized}` : "口径未映射,保留原码"}
|
||||
{item.label === true ? " · 计入通过" : item.label === false ? " · 计入未通过" : " · 不计入分母"}</p>
|
||||
<p><small><Clock size={12} aria-hidden="true"/> {new Date(item.occurred_at).toLocaleString()} · 记录人 {item.recorded_by}
|
||||
{item.supersedes_id ? ` · 更正自 ${item.supersedes_id}` : ""}</small></p>
|
||||
<h3>会议结果对照</h3>
|
||||
<p>AI 轨迹与人工会议记录并列查看。匹配只基于可核验的项目、材料版本:找不到材料版本的结果**单列**,
|
||||
既不当否决、也不进已观察通过率的分母。更正只能**追加**并指向旧记录,历史不被覆盖。</p>
|
||||
|
||||
{notice && <div role="status" className="ra-alert">{notice}</div>}
|
||||
{!outcomes && <p>正在读取会议结果…</p>}
|
||||
{outcomes && !matched.length && !unmatched.length && <p className="ra-note">尚未保存会议结果。</p>}
|
||||
|
||||
{matched.length > 0 && <>
|
||||
<h4>已关联材料版本({matched.length})</h4>
|
||||
{matched.map((item: any) => <OutcomeRow key={item.id} item={item}/>)}
|
||||
</>}
|
||||
|
||||
{unmatched.length > 0 && <>
|
||||
<h4>未匹配材料版本({unmatched.length})· 单列,不计入分母</h4>
|
||||
{unmatched.map((item: any) => <OutcomeRow key={item.id} item={item}/>)}
|
||||
</>}
|
||||
|
||||
{counts && <p className="ra-note">
|
||||
计入已观察分母 {counts.observed} 条;排除 {counts.excluded.length} 条(未送审、撤回、附条件、未知
|
||||
与未匹配均不计入)。{counts.note}
|
||||
</p>}
|
||||
|
||||
{canRecord && <div className="xb-task">
|
||||
<h4>追加会议结果</h4>
|
||||
<p className="ra-note">原始结果码必填;未提供时服务端不会把它推断成通过或否决。找不到对应材料版本时如实记为未匹配。</p>
|
||||
<label className="ra-field"><span>原始结果码(如:通过 / 否决 / 附条件通过 / 撤回)</span>
|
||||
<input value={form.raw_code} disabled={busy} aria-label="原始结果码"
|
||||
onChange={(e) => fill("raw_code", e.target.value)}/></label>
|
||||
<label className="ra-field"><span>会议编号</span>
|
||||
<input value={form.meeting_id} disabled={busy} aria-label="会议编号"
|
||||
onChange={(e) => fill("meeting_id", e.target.value)}/></label>
|
||||
<label className="ra-field"><span>归一化口径</span>
|
||||
<select aria-label="归一化口径" value={form.normalized} disabled={busy}
|
||||
onChange={(e) => fill("normalized", e.target.value)}>
|
||||
{NORMALIZED.map(([value, text]) => <option key={value} value={value}>{text}</option>)}
|
||||
</select></label>
|
||||
<label className="ra-field"><span>对应的材料版本(该次运行的输入快照)</span>
|
||||
<select aria-label="对应材料版本" value={form.reviewed_snapshot_id} disabled={busy}
|
||||
onChange={(e) => fill("reviewed_snapshot_id", e.target.value)}>
|
||||
<option value="">无法对应材料版本(记为未匹配)</option>
|
||||
{snapshots.map((item: any) => <option key={item.id} value={item.input_snapshot_id}>
|
||||
{item.input_snapshot_id} · 运行于 {new Date(item.created_at).toLocaleString()}
|
||||
</option>)}
|
||||
</select></label>
|
||||
<label className="ra-field"><span>会议时间(可空,空则记保存时刻)</span>
|
||||
<input type="datetime-local" value={form.occurred_at} disabled={busy} aria-label="会议时间"
|
||||
onChange={(e) => fill("occurred_at", e.target.value)}/></label>
|
||||
<label className="ra-field"><span>更正哪条记录(可空;更正只能新增,不覆盖历史)</span>
|
||||
<select aria-label="更正哪条记录" value={form.supersedes_id} disabled={busy}
|
||||
onChange={(e) => fill("supersedes_id", e.target.value)}>
|
||||
<option value="">不是更正,新增一条</option>
|
||||
{(outcomes?.items || []).map((item: any) => <option key={item.id} value={item.id}>
|
||||
{item.raw_code} · {new Date(item.occurred_at).toLocaleString()} · {item.status}
|
||||
</option>)}
|
||||
</select></label>
|
||||
<label className="ra-field"><span>附加条件(逗号分隔,可空)</span>
|
||||
<input value={form.conditions} disabled={busy} aria-label="附加条件"
|
||||
onChange={(e) => fill("conditions", e.target.value)}/></label>
|
||||
<div className="xb-actions">
|
||||
<button className="ra-button primary" disabled={busy || !form.raw_code.trim() || !form.meeting_id.trim()}
|
||||
onClick={saveOutcome}><Plus size={15}/>保存会议结果</button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>}
|
||||
|
||||
{!canRecord && <p className="ra-note">当前身份为只读查看:录入会议结果需要记录人身份。</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function OutcomeRow({item}: any) {
|
||||
return <div className="xb-finding">
|
||||
<div>
|
||||
<b>{item.raw_code} · {item.status === "matched" ? "已关联材料版本" : "未匹配(单列)"}</b>
|
||||
<p>{item.normalized ? `归一化结果 ${item.normalized}` : "口径未映射,保留原码"}
|
||||
{item.label === true ? " · 计入通过" : item.label === false ? " · 计入未通过" : " · 不计入分母"}</p>
|
||||
<p><small><Clock size={12} aria-hidden="true"/> {new Date(item.occurred_at).toLocaleString()} · 记录人 {item.recorded_by}
|
||||
{item.supersedes_id ? ` · 更正自 ${item.supersedes_id}` : ""}</small></p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user