aps-agent/tests/golden/test_m3_knowledge.py

121 lines
6.8 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 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"