# ============================================================ # M3 知识与偏好黄金测试(moduleId: golden-m3, 发版门禁 §14.3) # 固化不变式: # K1 检索必带出处(assetId/title/version)且未命中返回空(诚实原则 §8.1) # K2 报告数字全部可溯源到快照字段(§9.10 硬规则 1) # K3 偏好个性化:采用权重 > 试排权重;无信号回退默认(§8.3) # K4 知识资产同名重复入库版本递增(§8.1 版本化) # ============================================================ from __future__ import annotations # 前向类型引用 import re # 报告数字抽取 from server.knowledge.assets import KnowledgeStore # 资产库(临时路径实例化) from server.knowledge.preferences import PreferenceStore # 偏好仓 from server.knowledge.retrieval import search # 混合检索 from server.aps_domain.reports import build_daily_report, build_version_diff_report # 报告 from server.state.seed import seed_world # 种子世界 from server.engines import get_engine # 引擎(造版本用) from server.engines.base import EngineParams # 引擎入参 from server.timeutil import add_minutes, fmt_date, today0 # 日期工具 def _make_version(world, strategy="DELIVERY_FIRST"): """在世界里真实跑一版排产(报告测试的前置数据)。""" counters = {} # 独立发号器 def next_id(kind): counters[kind] = counters.get(kind, 0) + 1 return counters[kind] + 1000 params = EngineParams(orderIds=[], engineType="RULE", strategyTemplate=strategy, planningHorizonDays=14, startDate=fmt_date(add_minutes(today0(), 24 * 60))) return get_engine("RULE").solve(world, params, next_id) # ---------------- K1:检索带出处 + 诚实未命中 ---------------- def test_search_returns_provenance(tmp_path): """命中必带 assetId/title/version;无关问题返回空列表(不编造)。""" kb = KnowledgeStore(path=str(tmp_path / "kn.json")) # 临时库(自动播种) hits = search(kb.assets, "换线有什么规定") # SOP 问题 assert hits, "种子 SOP 应能命中" # 有命中 top = hits[0] # 最相关 assert top["title"] == "换线标准SOP" # 命中正确资产 for key in ("assetId", "title", "version", "snippet"): # 出处字段齐全 assert top[key], f"命中缺出处字段 {key}" miss = search(kb.assets, "quantum blockchain xyzzy") # 无关问题(无重叠词) assert miss == [], "无关问题必须诚实返回空" # 未命中为空 # ---------------- K2:报告数字可溯源 ---------------- def test_daily_report_numbers_from_snapshot(): """日报正文中的核心数字必须与冻结快照字段一致。""" world = seed_world() # 种子世界 _make_version(world) # 真实排一版 report = build_daily_report(world) # 生成日报 snap = report["snapshot"] # 冻结快照 assert snap is not None # 有快照 md = report["markdown"] # 报告正文 assert f"**{snap['poCount']}** 个" in md # 订单数可溯源 assert f"**{snap['woCount']}** 个" in md # 工单数可溯源 assert f"**{snap['conflictCount']}** 项" in md # 冲突数可溯源 assert snap["versionNo"] in md # 版本号出现在出处行 def test_version_diff_report_needs_two_versions(): """不足两版时诚实拒绝;两版后 KPI 表出现新旧版本号。""" world = seed_world() # 种子世界 r0 = build_version_diff_report(world) # 无版本 assert r0["reportId"] is None # 拒绝生成 _make_version(world, "DELIVERY_FIRST") # 第一版 _make_version(world, "CAPACITY_BALANCE") # 第二版 r = build_version_diff_report(world) # 生成对比 assert r["reportId"] # 成功 assert r["snapshot"]["new"]["versionNo"] in r["markdown"] # 新版号在正文 assert r["snapshot"]["old"]["versionNo"] in r["markdown"] # 旧版号在正文 # ---------------- K3:偏好个性化 ---------------- def test_preference_weighting(tmp_path): """采用(2分)> 试排(1分);无信号回退系统默认。""" ps = PreferenceStore(path=str(tmp_path / "pref.json")) # 临时仓 default, scores = ps.preferred_strategy() # 无样本 assert default == "COMPREHENSIVE" and scores == {} # 回退默认 ps.record("DELIVERY_FIRST", source="schedule.run") # 试排交期 ×2 = 2分 ps.record("DELIVERY_FIRST", source="schedule.run") ps.record("CAPACITY_BALANCE", source="scenario.apply") # 采用均衡 ×1 = 2分… ps.record("CAPACITY_BALANCE", source="scenario.apply") # ×2 = 4分(采用权重更高) best, scores = ps.preferred_strategy() # 求偏好 assert best == "CAPACITY_BALANCE", f"采用权重应更高,got {scores}" # 采用胜出 assert scores["DELIVERY_FIRST"] == 2 and scores["CAPACITY_BALANCE"] == 4 # 得分可解释 # ---------------- K4:资产版本化 ---------------- def test_asset_versioning(tmp_path): """同名资产重复入库版本号递增(v1 → v2)。""" kb = KnowledgeStore(path=str(tmp_path / "kn.json")) # 临时库 a1 = kb.add("sop", "测试SOP", "第一版内容") # 首次入库 assert a1["version"] == "v1" # v1 a2 = kb.add("sop", "测试SOP", "第二版内容") # 同名再入 assert a2["version"] == "v2" # 版本递增 assert kb.find_by_title("测试SOP") is not None # 标题可检索 def test_hybrid_search_units_with_chunks(tmp_path): """带 chunks 的资产按单元检索,仍强制出处。""" kb = KnowledgeStore(path=str(tmp_path / "kn.json")) kb.assets = [] kb._write() kb.add_with_chunks( kind="sop", title="夜班换线禁令", content="摘要", chunks=[{"chunkId": "c0", "text": "每日十六点后禁止跨产品族换线作业。", "seq": 0}], tags=["换线", "夜班"], approved=True, ) hits = search(kb.assets, "夜班换线") # search() 走整篇;再用 hybrid on units from server.knowledge.retrieval import hybrid_search units = kb.iter_search_units() hits2 = hybrid_search(units, "禁止跨产品族换线", top_k=2) assert hits2 for key in ("assetId", "title", "version", "snippet"): assert hits2[0][key] assert hits2[0].get("chunkId") == "c0"