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>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""纠偏第二批 · 历史提炼入口测试(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()
|
||||
Reference in New Issue
Block a user