"""纠偏第二批 · 历史提炼入口测试(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()