Files
mini-openclaw-v2/backend/test_experience_extraction.py
T
qiaoqichaoandClaude Code 2e7e19471c feat(v2): 纠偏第二批——接通历史经验加工闭环
覆盖 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>
2026-10-01 00:31:42 +08:00

320 lines
16 KiB
Python

"""纠偏第二批 · 历史提炼入口测试(BR-48 / AC-50 / E-28)。
两条必须机械成立的约束,不靠文档约定:
1. **模型不可用 ⇒ 不产生候选**。断言"候选数为 0"必须在提炼**之后**再查一次库;
只断言接口返回 failed 是不够的——真正的偏差模式正是"报失败但草稿照样落库"。
2. **来源不可洗**。产出的草稿必须是 origin=history_derived 且带来源事件 id。
模型调用用假传输层(`FakeModel`)走真实 `agent_manager.invoke_scoped` 路径,
因此构造请求、解析输出、越界校验都被真实执行;不打桩 `_invoke` 本身。
用临时库;不触碰真实数据根。
"""
import json
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 langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from api import prescreen as prescreen_api
from api import project_assistant as api
from api import risk_agent_config as catalog
from contracts.prescreen import ErrorCode, KnowledgeExtraction
from services import assistant_store as store
from services import experience_extraction, history_cases, knowledge_cards
from services.assistant_materials import material
from services.project_assistant import ProjectAssistantService
HEADER = "date,event_type,question,judgment_before,judgment_after\n"
EVENTS = (
HEADER
+ "2024-03-11,问询,回款是否依赖单一客户,按期回款,存在集中依赖\n"
+ "2024-04-02,补件,补充经营现金流与负债材料,材料缺失,已补充\n"
)
PROJECT = "DEMO-B"
# 字段名必须逐字取自标准字段表(services/assistant_materials.FIELDS 的值),
# 写成中文标签会被字段守卫拒绝——这正是本批要防的"自造字段"。
VALID_PROPOSAL = {
"title": "核查回款集中依赖与现金流缺口",
"trigger": "项目存在单一客户回款依赖时适用",
"action": "补充经营现金流材料并核对回款来源集中度",
"exceptions": "已提供到账凭证时不重复核查",
"conditions": {"collection.dependency": True},
"required_field": "financial.operating_cashflow",
"exception_field": "collection.evidence",
}
class FakeModel(BaseChatModel):
"""假传输层:返回值即模型"输出",不触网。"""
payload: str = ""
@property
def _llm_type(self) -> str:
return "fake"
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
return ChatResult(generations=[ChatGeneration(message=AIMessage(content=self.payload))])
class ExtractionTestCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db_path = Path(self._tmp.name) / "extraction.sqlite3"
store.initialize(self.db_path)
with store.transaction(self.db_path) as db:
store.put(db, "project", {
"id": PROJECT, "data_domain": "demo", "revision": 1,
"facts": {}, "values": {}, "sources": {}, "selected_sources": {},
"document_ids": [], "pending_candidates": [], "data_revision": 1,
})
def tearDown(self):
self._tmp.cleanup()
# ------------------------------------------------------------ 夹具
def add_and_build_timeline(self):
with store.transaction(self.db_path) as db:
document = material(PROJECT, "历史问询.csv", EVENTS.encode("utf-8-sig"), "tester")
store.put(db, "document", document, immutable=True)
timeline = history_cases.build(db, PROJECT, "demo")
self.assertGreaterEqual(len(timeline["events"]), 2, "夹具必须真的产出历史事件")
return timeline
def extract(self, payload, model_config=None):
with store.transaction(self.db_path) as db:
timeline = history_cases.load(db, PROJECT)
return experience_extraction.extract(db, PROJECT, timeline, payload, "demo-manager",
model_config=model_config)
def drafts(self) -> list[dict]:
with store.transaction(self.db_path) as db:
return store.listing(db, "component_draft")
def events(self) -> list[dict]:
with store.transaction(self.db_path) as db:
return history_cases.load(db, PROJECT)["events"]
class UnavailableModelTests(ExtractionTestCase):
"""AC-50 后半句:模型不可用时返回 E-28,且**不产生**候选。"""
def test_unconfigured_model_yields_e28_and_no_candidate(self):
self.add_and_build_timeline()
record = self.extract({}, model_config={"available": False, "reason": "未配置受信模型,语义核查未执行"})
self.assertEqual(record["status"], "failed")
self.assertIsNone(record["candidate_id"], "失败记录不得引用候选")
codes = [item["code"] for item in record["degradations"]]
self.assertIn(ErrorCode.EXTRACTION_UNAVAILABLE.value, codes, "必须记 E-28")
self.assertIn("本次未能完成经验提炼", record["degradations"][0]["message"])
# 关键反例:提炼跑完之后,库里仍然一张草稿都没有
self.assertEqual(self.drafts(), [], "模型不可用不得产出任何候选草稿")
def test_transport_failure_yields_e28_and_no_candidate(self):
self.add_and_build_timeline()
def boom(*args, **kwargs):
raise TimeoutError("内网模型超时")
with patch.object(experience_extraction.agent_manager, "invoke_scoped", boom):
record = self.extract({}, model_config={"available": True, "endpoint": "http://127.0.0.1:11434/v1", "name": "fake"})
self.assertEqual(record["status"], "failed")
self.assertIn("内网模型超时", record["degradations"][0]["message"])
self.assertEqual(self.drafts(), [])
def test_malformed_output_yields_e28_and_no_candidate(self):
self.add_and_build_timeline()
with patch.object(experience_extraction.agent_manager, "invoke_scoped",
lambda *a, **k: {"text": "不是JSON", "engine": "fake"}):
record = self.extract({}, model_config={"available": True, "endpoint": "http://127.0.0.1:11434/v1", "name": "fake"})
self.assertEqual(record["status"], "failed")
self.assertEqual(self.drafts(), [], "输出畸形时不得落半成品候选")
def test_field_outside_standard_table_is_rejected(self):
"""模型自造字段名必须被拒——否则候选会引用不存在的字段。"""
self.add_and_build_timeline()
payload = dict(VALID_PROPOSAL, conditions={"自造指标": True})
with patch.object(experience_extraction.agent_manager, "invoke_scoped",
lambda *a, **k: {"text": json.dumps(payload, ensure_ascii=False), "engine": "fake"}):
record = self.extract({}, model_config={"available": True, "endpoint": "http://127.0.0.1:11434/v1", "name": "fake"})
self.assertEqual(record["status"], "failed")
self.assertIn("触发条件必须来自标准字段", record["degradations"][0]["message"])
self.assertEqual(self.drafts(), [])
class SuccessfulExtractionTests(ExtractionTestCase):
"""AC-50 前半句:模型可用时真实调用并产出带来源的候选草稿。"""
def invoke_with(self, proposal):
self.add_and_build_timeline()
env = {"XINGBANG_LOCAL_MODEL_URL": "http://127.0.0.1:11434/v1",
"XINGBANG_LOCAL_MODEL_NAME": "fake-local"}
with patch.dict(os.environ, env), \
patch("services.experience_extraction.ChatOpenAI",
lambda **kwargs: FakeModel(payload=json.dumps(proposal, ensure_ascii=False))):
return self.extract({"requirement": "重点整理回款与现金流不一致的核查方法"})
def test_candidate_carries_history_origin_and_sources(self):
record = self.invoke_with(VALID_PROPOSAL)
self.assertEqual(record["status"], "completed")
self.assertEqual(len(self.drafts()), 1, "成功提炼必须正好产出一张候选草稿")
with store.transaction(self.db_path) as db:
card = knowledge_cards.draft_card(db, record["candidate_id"])
source_ids = [event["id"] for event in self.events()]
self.assertEqual(card["origin"], "history_derived")
self.assertEqual(sorted(card["source_event_ids"]), sorted(source_ids))
self.assertEqual(card["kind"], "skill")
self.assertEqual(card["title"], VALID_PROPOSAL["title"])
self.assertEqual(card["conditions"][0]["field"], "collection.dependency")
def test_record_keeps_prompt_version_and_model(self):
"""记录必须能证明"确实发起了调用",而不是固定返回值包装成成功。"""
record = self.invoke_with(VALID_PROPOSAL)
self.assertEqual(record["prompt_version"], experience_extraction.PROMPT_VERSION)
self.assertEqual(record["model"], "fake-local")
self.assertEqual(record["degradations"], [])
def test_selected_events_limit_the_sources(self):
self.add_and_build_timeline()
chosen = self.events()[0]["id"]
with patch.object(experience_extraction.agent_manager, "invoke_scoped",
lambda *a, **k: {"text": json.dumps(VALID_PROPOSAL, ensure_ascii=False),
"engine": "fake"}):
record = self.extract({"event_ids": [chosen]},
model_config={"available": True, "endpoint": "http://127.0.0.1:11434/v1",
"name": "fake"})
self.assertEqual(record["source_event_ids"], [chosen])
def test_event_outside_timeline_is_rejected(self):
self.add_and_build_timeline()
with self.assertRaises(ValueError) as ctx:
self.extract({"event_ids": ["event-不属于本项目"]},
model_config={"available": False, "reason": "x"})
self.assertIn("不属于该项目时间线", str(ctx.exception))
def test_empty_timeline_without_requirement_is_rejected(self):
"""没有事件也没有提炼要求时无可依据,必须拒绝而不是凭空生成。"""
with store.transaction(self.db_path) as db:
document = material(PROJECT, "最终报告.txt", "尽调结论:建议通过。\n".encode("utf-8-sig"),
"tester")
store.put(db, "document", document, immutable=True)
timeline = history_cases.build(db, PROJECT, "demo")
self.assertEqual(timeline["events"], [], "夹具必须是「过程不完整」的空时间线")
with self.assertRaises(ValueError) as ctx:
self.extract({}, model_config={"available": False, "reason": "x"})
self.assertIn("至少给出一个来源事件或一条提炼要求", str(ctx.exception))
class ContractTests(unittest.TestCase):
"""契约层的机械约束:失败的提炼记录不可能带候选。"""
def _record(self, **overrides):
body = {"id": "extraction-1", "project_id": PROJECT, "data_domain": "demo",
"revision": 1, "status": "failed", "timeline_id": "timeline-DEMO-B",
"prompt_version": "history-extraction.v1", "created_at": store.now(),
"created_by": "demo-manager",
"degradations": [{"code": ErrorCode.EXTRACTION_UNAVAILABLE.value, "message": "未完成"}]}
return dict(body, **overrides)
def test_failed_record_cannot_reference_a_candidate(self):
with self.assertRaises(ValueError) as ctx:
KnowledgeExtraction(**self._record(candidate_id="skill-1"))
self.assertIn("不得产生候选草稿", str(ctx.exception))
def test_failed_record_must_explain_itself(self):
with self.assertRaises(ValueError):
KnowledgeExtraction(**self._record(degradations=[]))
def test_completed_record_must_reference_a_candidate(self):
with self.assertRaises(ValueError):
KnowledgeExtraction(**self._record(status="completed", degradations=[], candidate_id=None))
class ExtractionApiTests(unittest.TestCase):
"""HTTP 边界:模型不可用仍返回 201 + failed 记录(E-28 不是 HTTP 错误)。"""
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()
app = FastAPI()
app.include_router(api.router)
app.include_router(prescreen_api.router)
self.client = TestClient(app)
with self.service.connect() as db:
document = material(PROJECT, "历史问询.csv", EVENTS.encode("utf-8-sig"), "tester")
store.put(db, "document", document, immutable=True)
project = store.get(db, "project", PROJECT)
project["document_ids"].append(document["id"])
store.put(db, "project", project)
def tearDown(self):
self.client.close()
for item in reversed(self.patches):
item.stop()
self.temp.cleanup()
def call(self, method, path, body=None):
return self.client.request(method, prescreen_api.router.prefix + path, json=body,
headers={"X-Xingbang-Demo-Role": "manager"})
def test_timeline_must_be_built_before_extraction(self):
# 尚未提炼时间线 → 404,不返回空壳时间线
self.assertEqual(self.call("GET", f"/projects/{PROJECT}/case-timeline").status_code, 404)
def test_unavailable_model_returns_record_not_http_error(self):
built = self.call("POST", f"/projects/{PROJECT}/case-timeline")
self.assertEqual(built.status_code, 201)
self.assertGreaterEqual(len(built.json()["events"]), 2)
response = self.call("POST", f"/projects/{PROJECT}/knowledge-extractions",
{"requirement": "整理回款核查方法"})
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["status"], "failed")
self.assertIsNone(body["candidate_id"])
self.assertEqual(body["degradations"][0]["code"], ErrorCode.EXTRACTION_UNAVAILABLE.value)
listed = self.call("GET", f"/projects/{PROJECT}/knowledge-extractions").json()
self.assertEqual(len(listed["items"]), 1, "失败的提炼记录必须可见,不隐藏")
self.assertEqual(self.call("GET", f"/projects/{PROJECT}/knowledge-extractions/"
+ body["id"]).json()["id"], body["id"])
def test_extraction_rejects_unknown_payload_fields(self):
"""调用方不能借请求体指定引擎或工具——只允许选事件与给要求(E-07)。"""
self.call("POST", f"/projects/{PROJECT}/case-timeline")
response = self.call("POST", f"/projects/{PROJECT}/knowledge-extractions",
{"requirement": "x", "engine": "model"})
self.assertEqual(response.status_code, 422)
# 本测试的 app 未注册 app.py 的校验处理器,因此是 FastAPI 默认的 detail 形状;
# 断言字段名被点名,而不是假设信封形状。
self.assertIn("engine", response.text)
if __name__ == "__main__":
unittest.main()