覆盖 AC-50/51/52(BR-48/49、E-28)。审计确认的 P1-3 缺口"历史提炼与卡片 不在实时 API 调用图上"由此关闭:提炼、候选治理、卡片进方案全部有真实调用方。 后端: - contracts:新增 E-28 EXTRACTION_UNAVAILABLE(运行级,不进 HTTP 信封)与 KnowledgeExtraction 记录类型。契约层强制"失败不得带候选", AC-50「模型不可用⇒不产生候选」是类型约束而不是文档约定。 - services/experience_extraction.py(新):真实提炼入口。模型不可用或调用失败 → 记 E-28 且 candidate_id 为空;不产半成品候选。传输纪律同 assistant_model (禁进程内代理、零重试),且 tools=[] 使"越权读材料"在结构上不可能。 自然语言修订同样必须真实调用模型,没有"把中文猜成条件"的回退路径。 - services/knowledge_cards.py:新增 current_draft/revise/list_cards 与 registry_conditions。修订只产草稿、保留 origin 与来源、升版本记 parent_version; test_cases 纳入可改字段——它是 component_evaluation 对自定义 Skill 的发布门禁, 不开放则提炼出的候选永远卡在评测门外。 - services/project_assistant.py:enqueue 改用 review_plan.assemble(), 让 T06-b 的依赖预检(E-19 可定位文案)与维度解析不再被绕过。 - api/prescreen.py:case-timeline、knowledge-extractions、knowledge-candidates (创建/修订/状态机)、review-plans(装配/状态机)。角色门禁与既有 /components 入口一致;id 与数据域由服务端生成;按 id 的路由一律在 run() 内 查归属,否则 KeyError 会变成 500 而不是 404。 - component_registry/review_plan:下沉 current_draft/version_key 与 list_plans。 前端: - 新增 /experience-center 独立入口(BR-50)与 ExperienceCenter 组件; AgentNav 增加「历史经验加工」。 - originBadge 此前零调用方、.origin-* 无任何 CSS——守卫只 grep 库文件字符串, 于是"测试通过但功能不存在"。本批补齐消费方与样式,并用左边框样式区分四种来源, 保证去色仍可区分(AC-42/BR-48)。 - 守卫语义按 AC-55 调整:由"禁止新建路由"改为"禁止重复页面" (面板不得搬到独立路由、已合并页必须保持重定向),并新增 experience-center-ui 守卫(已登记 verify.py,共 12 项检查)。 测试:新增 36 项(提炼 15 / 候选治理 15 / 卡片到运行 6),全量 unittest 无回归; 两次同项目运行对照证明卡片确实改变了核查结果,且 basis_ids 指向 卡片id@版本。 未闭合(记录在 17 的剩余缺口):真实模型提炼尚未实测(需端点); 界面未经浏览器点击验证;例外边界未进入可求值语言;规则类候选不在本批。 Co-Authored-By: Claude Code <noreply@anthropic.com>
216 lines
10 KiB
Python
216 lines
10 KiB
Python
"""纠偏第二批 B-c · 卡片选入方案并影响核查(BR-49 / AC-52)。
|
|
|
|
审计(audit.json P1-3)确认:知识卡片此前没有任何调用方,"卡片影响核查"只存在于
|
|
孤立的单元测试里。本测试走**真实执行链**:
|
|
|
|
提炼/人工卡片 → 隔离评测 → 独立审核发布 → 装配方案 → 发布方案
|
|
→ service.enqueue → DurableWorker.process_next() → 运行结果
|
|
|
|
用**同项目两次运行对照**证明卡片确实改变了核查结果:基座方案里没有该卡片,
|
|
新方案里有;两次运行都能读出各自的 `component_versions` 与核查项依据。
|
|
|
|
不用注入记录;临时库;不触碰真实数据根。
|
|
"""
|
|
|
|
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 knowledge_cards
|
|
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"
|
|
CARD_VERSION = "1.0.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"}},
|
|
]
|
|
|
|
|
|
class CardToRunTests(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 propose_card(self):
|
|
with self.service.connect() as db:
|
|
return knowledge_cards.propose(db, {
|
|
"id": CARD_ID, "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(self, path, headers=REVIEWER):
|
|
evaluated = self.call("POST", path, {"action": "evaluate"}, headers)
|
|
self.assertEqual(evaluated.status_code, 200, evaluated.text)
|
|
return self.call("POST", path, {"action": "publish"}, headers)
|
|
|
|
def assemble_plan(self, skills, name="含历史经验卡的预审方案"):
|
|
return self.call("POST", f"/projects/{PROJECT}/review-plans", {
|
|
"name": name, "skills": skills, "rules": ["demo.coverage"],
|
|
"template": "template.ai-pre-review",
|
|
"budget": {"timeout_seconds": 180, "max_tool_calls": 18},
|
|
"audience": ["manager"], "run_kind": "pre_review",
|
|
})
|
|
|
|
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]
|
|
|
|
# ------------------------------------------------------------ AC-52
|
|
def test_published_card_changes_the_run_and_is_traceable(self):
|
|
# 1) 历史提炼候选 → 评测 → 独立审核发布
|
|
card = self.propose_card()
|
|
published = self.publish(f"/knowledge-candidates/{CARD_ID}/transitions")
|
|
self.assertEqual(published.status_code, 200, published.text)
|
|
self.assertEqual(published.json()["version"], CARD_VERSION)
|
|
|
|
# 2) 基座方案:卡片不在方案里 → 运行里不应出现该核查项
|
|
baseline = self.run_with("corporate.pre-review")
|
|
self.assertEqual(self.checks_of(baseline, CARD_CODE), [],
|
|
"卡片没进方案时不得凭空产生该核查项")
|
|
|
|
# 3) 把已发布卡片选入方案并发布
|
|
plan = self.assemble_plan(
|
|
["corporate.consistency", "corporate.cashflow", "corporate.assets",
|
|
"corporate.coverage", CARD_ID])
|
|
self.assertEqual(plan.status_code, 201, plan.text)
|
|
plan_id = plan.json()["component_id"]
|
|
self.assertEqual(self.publish(f"/review-plans/{plan_id}/transitions").status_code, 200)
|
|
|
|
# 4) 用新方案真实运行
|
|
run = self.run_with(plan_id)
|
|
refs = {(item["id"], item["version"]) for item in run["component_versions"]}
|
|
self.assertIn((CARD_ID, CARD_VERSION), refs, "运行必须冻结并记录卡片版本")
|
|
|
|
checks = self.checks_of(run, CARD_CODE)
|
|
self.assertEqual(len(checks), 1, "已发布卡片必须真的产生核查项")
|
|
step = next(step for step in run["steps"] if step.get("skill", {}).get("id") == CARD_ID)
|
|
self.assertEqual(step["status"] in ("completed", "partial"), True)
|
|
self.assertEqual(step["findings"][0]["basis_ids"], [f"{CARD_ID}@{CARD_VERSION}"],
|
|
"核查项必须能追溯到卡片版本")
|
|
self.assertEqual(step["findings"][0]["fields"], ["financial.operating_cashflow"])
|
|
|
|
# 5) 旧运行仍可读,且没有被新方案改写
|
|
self.assertEqual(self.checks_of(baseline, CARD_CODE), [])
|
|
with self.service.connect() as db:
|
|
self.assertNotEqual(store.get(db, "run", baseline["id"])["status"], "")
|
|
|
|
def test_draft_card_cannot_be_selected_into_a_plan(self):
|
|
"""草稿卡片不得进入新运行(BR-27 / AC-28)——装配阶段就要拒。"""
|
|
self.propose_card() # 只提议,不发布
|
|
plan = self.assemble_plan(["corporate.coverage", CARD_ID])
|
|
self.assertEqual(plan.status_code, 201, plan.text)
|
|
|
|
# 发布时依赖解析拒绝未发布组件
|
|
evaluated = self.call("POST", f"/review-plans/{plan.json()['component_id']}/transitions",
|
|
{"action": "evaluate"}, REVIEWER)
|
|
self.assertEqual(evaluated.status_code, 200, evaluated.text)
|
|
published = self.call("POST", f"/review-plans/{plan.json()['component_id']}/transitions",
|
|
{"action": "publish"}, REVIEWER)
|
|
self.assertEqual(published.status_code, 409, published.text)
|
|
|
|
def test_missing_dependency_fails_evaluation_instead_of_being_dropped(self):
|
|
"""方案引用不存在的组件 → 评测不通过、发布被拒,不静默丢掉该项。"""
|
|
self.propose_card()
|
|
plan = self.assemble_plan(["corporate.coverage", "skill.not.exists"])
|
|
self.assertEqual(plan.status_code, 201, plan.text)
|
|
plan_ref = plan.json()["component_id"]
|
|
|
|
evaluated = self.call("POST", f"/review-plans/{plan_ref}/transitions",
|
|
{"action": "evaluate"}, REVIEWER)
|
|
self.assertEqual(evaluated.status_code, 200, evaluated.text)
|
|
self.assertFalse(evaluated.json()["passed"], "依赖缺失必须让评测不通过")
|
|
|
|
published = self.call("POST", f"/review-plans/{plan_ref}/transitions",
|
|
{"action": "publish"}, REVIEWER)
|
|
self.assertEqual(published.status_code, 409, published.text)
|
|
|
|
def test_plan_rejects_tool_configuration_surface(self):
|
|
"""ADR-11:方案不得自带工具配置面,业务侧不能借方案扩权。"""
|
|
response = self.call("POST", f"/projects/{PROJECT}/review-plans", {
|
|
"name": "越权方案", "skills": ["corporate.coverage"],
|
|
"template": "template.ai-pre-review", "allowed_tools": ["execute_sql"],
|
|
})
|
|
self.assertEqual(response.status_code, 422)
|
|
self.assertIn("allowed_tools", response.text)
|
|
|
|
def test_transitioning_an_unknown_plan_is_404(self):
|
|
"""不存在的方案必须是 404,不是 500(归属查询在 run() 内完成)。"""
|
|
response = self.call("POST", "/review-plans/plan.not.exists/transitions",
|
|
{"action": "evaluate"}, REVIEWER)
|
|
self.assertEqual(response.status_code, 404)
|
|
self.assertEqual(response.json()["error"]["code"], "NOT_FOUND")
|
|
|
|
def test_listed_plans_show_the_selected_cards(self):
|
|
self.propose_card()
|
|
self.publish(f"/knowledge-candidates/{CARD_ID}/transitions")
|
|
plan = self.assemble_plan(["corporate.coverage", CARD_ID])
|
|
listed = self.call("GET", f"/projects/{PROJECT}/review-plans").json()["items"]
|
|
mine = next(item for item in listed if item["id"] == plan.json()["component_id"])
|
|
self.assertIn(CARD_ID, mine["skills"], "方案列表必须显示选入的卡片")
|
|
self.assertEqual(mine["status"], "draft")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|