覆盖 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>
320 lines
16 KiB
Python
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()
|