tests/golden:JSON 用例 + 阈值配置 + runner(规则 pass_rate=1.0、检索 0.8 起步)+ pytest 壳;规则/检索两子集种子,Agent/报告子集占位(T-P2-11)。 实现: - runner.py 为普通模块(不依赖 pytest):加载/执行/指标(pass_rate、 first_hit_in_top3)/逐用例差异明细;test_golden.py 只是薄壳(marker=golden, 无 MEIDA_RUN_INTEGRATION 时整体 skip)。用例与 expect 的未知键一律判 fail(封闭词汇表,防断言静默失效)。 - 规则子集 13 例(rules_cases.json,走 packages.rules DSL/evaluate): 含 3 个硬禁忌场景(无条件合规硬拦、正式场合硬拦、复合优先级链 100硬>95禁忌>90体型>50趋势)+ 禁忌压制/维度隔离/多规则叠加/温度边界/ 缺字段静默/include 子集语义等。 - 检索子集 13 例(retrieval_cases.json,DeterministicEmbedder+hybrid_search): 含 7 个 must_miss 红线场景(禁忌体型/draft/archived/场景外/类型外/组合红线)。 种子实体以 golden: 前缀命名,跑前先删(幂等重跑);跑后也删——CI 防线 5c 与集成测试共用长活库,留下假实体会破坏其它用例对结果列表的精确断言。 确定性策略:query=目标 chunk 原文(余弦距离 0 必排第 1)+ 每例独立 marker token(gqNN)隔离关键词路;检索参数(1024 维/top_k 50/默认 RRF 权重)钉死在 runner,不读 env。 - thresholds.json:规则 pass_rate_min=1.0(硬结论子集一条不许退); 检索 pass_rate_min=0.8 + first_hit_in_top3_min=0.8 起步。 - README.md:四子集 330 例规划、两格式 spec、加用例流程、阈值口径与 “为什么规则必须 1.0”。 测试: - MEIDA_RUN_INTEGRATION=1 pytest -m golden --no-cov:2 passed(规则 13/13=1.0,检索 13/13=1.0、first_hit_in_top3=13/13);连续重跑幂等, 跑后库内 golden:% 残留为 0;不设 env 时 2 skipped;基线单测 473 passed、 集成 38 passed 不受影响;ruff check/format、lint-imports 全绿。 - 阻断语义自证(已改回,提交里只有正确版本):临时把规则用例 user-taboo-overrides-trend-recommendation 的 must_avoid 改成不存在的 取值并把 hard_blocks_nonempty 翻转为 true → 规则子集 12/13=0.9231 <1.0 fail,明细输出“must_avoid 未命中:期望 top[length=九分裤脚],实际 avoid=[top[length=[短款, 露脐]]]”与“hard_blocks_nonempty 期望 True, 实际 False(硬拦截规则:无)”;再临时破坏 3 条检索用例(2 条 must_hit 换成不存在的实体名、1 条 must_miss 加入本应命中的实体)→ 检索子集 10/13=0.7692<0.8 fail,明细含“must_miss 红线违规:golden:r12-good 出现在第 1 位——硬过滤零漏被破坏”。验证完三条全部改回,复跑全绿。 (注:检索阈值 0.8 起步,单条退化 0.92 尚不拦截、≥3 条即拦截,符合 种子期口径;规则阈值 1.0 单条即拦截。) 偏差说明:无
534 lines
23 KiB
Python
534 lines
23 KiB
Python
"""黄金集回归 runner:JSON 用例加载 → 执行 → 指标计算 → 逐用例明细(T-P2-11).
|
||
|
||
黄金集是"指标退化直接阻断合并"的防线(FR-KB-11 / NFR-MAINT-04)。本模块把
|
||
用例(数据)、阈值(配置)与判定(代码)彻底分开——加用例 = 加 JSON 行,
|
||
规模扩充(知识库文档 §7 的四子集 330 例口径,由内容轨 T-P2-16 达成)不需要
|
||
动框架。
|
||
|
||
设计约束:
|
||
|
||
1. **不依赖 pytest**:``test_golden.py`` 只是薄壳。将来本地脚本或 admin
|
||
后台跑同一套黄金集,直接 import 本模块即可。
|
||
2. **失败必须可解释**:每条用例的判定携带"期望 vs 实际"的差异摘要,
|
||
CI 日志里要能直接看出哪条用例在哪个断言上退化,而不是只有一个通过率。
|
||
3. **种子实体自管理**:检索用例的实体以 ``golden:`` 前缀命名;跑前先删
|
||
(上次运行可能中断在判定之前,保证幂等重跑)、跑后必删(集成库长活,
|
||
留下假实体会破坏其它用例对结果列表的精确断言)。
|
||
4. **参数钉死**:黄金集断言确定性结果,检索的维度/top_k 固定在框架里而
|
||
不读 env——env 漂移会让同一份用例集在不同机器上口径不一。
|
||
5. **封闭词汇表**:用例/expect 的未知键一律判为用例失败而非静默忽略——
|
||
``must_recomend`` 这类 typo 若被忽略,意味着断言悄悄失效(与规则 DSL
|
||
同一哲学,FR-KB-02)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from collections.abc import Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from sqlalchemy import delete, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from packages.domain.ids import next_id
|
||
from packages.retrieval.embedding import DeterministicEmbedder
|
||
from packages.retrieval.repo import ChunkInput, hybrid_search, upsert_chunks
|
||
from packages.retrieval.rerank import ScoredChunk
|
||
from packages.rules import dsl, engine
|
||
from packages.shared.db import session_scope
|
||
from packages.shared.models import KbChunk, KbEntity
|
||
|
||
GOLDEN_DIR = Path(__file__).resolve().parent
|
||
|
||
#: 种子实体命名前缀:runner 只清理带此前缀的实体——既不误伤业务数据,
|
||
#: 也让"哪些行是黄金集造的"在库内一眼可辨(幂等重跑契约,任务文档 §3.3)。
|
||
SEED_NAME_PREFIX = "golden:"
|
||
|
||
#: 检索执行的固定参数(与 Settings 默认值一致但刻意不读 Settings)。
|
||
_VECTOR_DIMENSIONS = 1024 # 与 kb_chunk.embedding 的 Vector(1024) 列宽一致
|
||
_RETRIEVAL_TOP_K = 50 # 与 Settings.retrieval_top_k 默认值一致
|
||
|
||
_RULES_CASE_KEYS = frozenset({"name", "rules", "context", "expect"})
|
||
_RULES_EXPECT_KEYS = frozenset(
|
||
{"must_recommend", "must_avoid", "forbidden_recommend", "hard_blocks_nonempty"}
|
||
)
|
||
_RETRIEVAL_CASE_KEYS = frozenset({"name", "seed_entities", "query", "user_ctx", "expect"})
|
||
_RETRIEVAL_EXPECT_KEYS = frozenset({"must_hit_names", "must_miss_names", "first_hit_in"})
|
||
_USER_CTX_KEYS = frozenset({"body_shape", "occasion", "entity_types"})
|
||
_FIRST_HIT_IN_CHOICES = ("top1", "top3")
|
||
|
||
|
||
# ── 结果模型 ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class CaseResult:
|
||
"""一条用例的判定结论;problems 是"期望 vs 实际"的差异摘要列表."""
|
||
|
||
name: str
|
||
passed: bool
|
||
problems: tuple[str, ...] = ()
|
||
#: 检索子集专用:首个 must_hit 落位是否在 top3(未声明 first_hit_in 为 None)。
|
||
first_hit_in_top3: bool | None = None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class SubsetReport:
|
||
"""一个子集的指标汇总。阈值随报告携带,调用方只需对齐指标与阈值."""
|
||
|
||
subset: str
|
||
cases: tuple[CaseResult, ...]
|
||
pass_rate_min: float
|
||
first_hit_in_top3_min: float | None = None
|
||
first_hit_in_top3_total: int = 0
|
||
first_hit_in_top3_passed: int = 0
|
||
|
||
@property
|
||
def total(self) -> int:
|
||
return len(self.cases)
|
||
|
||
@property
|
||
def passed(self) -> int:
|
||
return sum(1 for case in self.cases if case.passed)
|
||
|
||
@property
|
||
def pass_rate(self) -> float:
|
||
return self.passed / self.total if self.total else 0.0
|
||
|
||
@property
|
||
def first_hit_in_top3_rate(self) -> float | None:
|
||
"""分母 = 声明了 first_hit_in 的用例数(规则子集恒为 None)."""
|
||
if not self.first_hit_in_top3_total:
|
||
return None
|
||
return self.first_hit_in_top3_passed / self.first_hit_in_top3_total
|
||
|
||
def summary(self) -> str:
|
||
"""CI 日志直读的明细:头部指标行 + 每条失败用例的差异摘要."""
|
||
head = (
|
||
f"[golden:{self.subset}] 通过 {self.passed}/{self.total},"
|
||
f"pass_rate={self.pass_rate:.4f}(阈值 {self.pass_rate_min:.2f})"
|
||
)
|
||
if self.first_hit_in_top3_min is not None:
|
||
rate = self.first_hit_in_top3_rate
|
||
rate_text = "n/a(无用例声明该指标)" if rate is None else f"{rate:.4f}"
|
||
head += (
|
||
f";first_hit_in_top3={rate_text}"
|
||
f"(阈值 {self.first_hit_in_top3_min:.2f},"
|
||
f"{self.first_hit_in_top3_passed}/{self.first_hit_in_top3_total} 例参与)"
|
||
)
|
||
lines = [head]
|
||
for case in self.cases:
|
||
if case.passed:
|
||
continue
|
||
lines.append(f" [FAIL] {case.name}")
|
||
lines.extend(f" - {problem}" for problem in case.problems)
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ── 配置与用例加载 ──────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class SubsetThresholds:
|
||
"""thresholds.json 里一个子集的阈值."""
|
||
|
||
pass_rate_min: float
|
||
first_hit_in_top3_min: float | None = None
|
||
|
||
|
||
def load_thresholds() -> dict[str, SubsetThresholds]:
|
||
raw = json.loads((GOLDEN_DIR / "thresholds.json").read_text(encoding="utf-8"))
|
||
thresholds: dict[str, SubsetThresholds] = {}
|
||
for subset, cfg in raw.items():
|
||
if not isinstance(cfg, dict) or "pass_rate_min" not in cfg:
|
||
raise ValueError(f"thresholds.json:子集 {subset} 缺少 pass_rate_min")
|
||
thresholds[subset] = SubsetThresholds(
|
||
pass_rate_min=float(cfg["pass_rate_min"]),
|
||
first_hit_in_top3_min=(
|
||
float(cfg["first_hit_in_top3_min"]) if "first_hit_in_top3_min" in cfg else None
|
||
),
|
||
)
|
||
return thresholds
|
||
|
||
|
||
def _thresholds_for(subset: str) -> SubsetThresholds:
|
||
thresholds = load_thresholds()
|
||
if subset not in thresholds:
|
||
raise ValueError(f"thresholds.json 缺少子集 {subset} 的阈值")
|
||
return thresholds[subset]
|
||
|
||
|
||
def _load_cases(filename: str) -> list[dict[str, Any]]:
|
||
path = GOLDEN_DIR / "cases" / filename
|
||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||
if not isinstance(raw, list):
|
||
raise ValueError(f"{path} 应为 JSON 数组(每个元素一个用例对象)")
|
||
if not all(isinstance(case, dict) for case in raw):
|
||
raise ValueError(f"{path} 存在非对象的数组元素(用例必须是 JSON 对象)")
|
||
return raw
|
||
|
||
|
||
def _unknown_keys(actual: Mapping[str, Any], allowed: frozenset[str]) -> list[str]:
|
||
return sorted(set(actual) - allowed)
|
||
|
||
|
||
def _empty_marker(subset: str) -> tuple[CaseResult, ...]:
|
||
"""空子集的哨兵失败用例:vacuous green 是回归防线最危险的静默失效."""
|
||
return (
|
||
CaseResult(
|
||
f"<{subset}-empty>",
|
||
False,
|
||
(f"子集 {subset} 没有任何用例:请检查 cases/*.json 是否被清空",),
|
||
),
|
||
)
|
||
|
||
|
||
# ── 规则子集(packages.rules DSL + 引擎,纯逻辑无 I/O)─────────────────────
|
||
|
||
|
||
def _build_context(raw: Mapping[str, Any]) -> dsl.RuleContext:
|
||
"""JSON context → RuleContext。集合字段(数组)收成 tuple;未知字段 TypeError."""
|
||
kwargs: dict[str, Any] = {}
|
||
for key, value in raw.items():
|
||
if key in ("features", "goals", "user_taboos"):
|
||
kwargs[key] = tuple(value)
|
||
else:
|
||
kwargs[key] = value
|
||
return dsl.RuleContext(**kwargs)
|
||
|
||
|
||
def _expected_item(item: Any) -> tuple[str, dict[str, tuple[str, ...]]]:
|
||
"""expect 条目 {slot, attrs_include} → (slot, {维度: 取值元组});attrs 缺省=整 slot."""
|
||
if not isinstance(item, dict) or not item.get("slot"):
|
||
raise ValueError(f"expect 条目缺少 slot:{item!r}")
|
||
attrs = item.get("attrs_include") or {}
|
||
return str(item["slot"]), {str(dim): tuple(values) for dim, values in attrs.items()}
|
||
|
||
|
||
def _covers(actual: dsl.SlotAttrs, slot: str, attrs: dict[str, tuple[str, ...]]) -> bool:
|
||
"""子集语义(must_*):同 slot,且期望每个维度的取值都被 actual 包含.
|
||
|
||
attrs 为空 = 对整个 slot 的期望(与 DSL"空 attrs 表示对品类推荐/回避"对齐)。
|
||
"""
|
||
if actual.slot != slot:
|
||
return False
|
||
return all(
|
||
dim in actual.attrs and set(values) <= set(actual.attrs[dim])
|
||
for dim, values in attrs.items()
|
||
)
|
||
|
||
|
||
def _overlaps(actual: dsl.SlotAttrs, slot: str, attrs: dict[str, tuple[str, ...]]) -> bool:
|
||
"""红线语义(forbidden_*):同 slot 且任一期望维度与 actual 取值相交即命中."""
|
||
if actual.slot != slot:
|
||
return False
|
||
return any(
|
||
dim in actual.attrs and set(values) & set(actual.attrs[dim])
|
||
for dim, values in attrs.items()
|
||
)
|
||
|
||
|
||
def _fmt_slot_attrs(item: dsl.SlotAttrs) -> str:
|
||
if not item.attrs:
|
||
return item.slot
|
||
dims = ", ".join(f"{dim}={list(values)}" for dim, values in item.attrs.items())
|
||
return f"{item.slot}[{dims}]"
|
||
|
||
|
||
def _fmt_items(items: Sequence[dsl.SlotAttrs]) -> str:
|
||
if not items:
|
||
return "(空)"
|
||
return "[" + ", ".join(_fmt_slot_attrs(item) for item in items) + "]"
|
||
|
||
|
||
def _judge_rules_case(case: Mapping[str, Any]) -> CaseResult:
|
||
name = str(case.get("name", "<缺少 name>"))
|
||
problems: list[str] = []
|
||
unknown = _unknown_keys(case, _RULES_CASE_KEYS)
|
||
if unknown:
|
||
problems.append(f"用例含未识别字段 {unknown}(可用:{sorted(_RULES_CASE_KEYS)})")
|
||
if "rules" not in case or "expect" not in case:
|
||
problems.append("用例缺少必填字段 rules / expect")
|
||
return CaseResult(name, False, tuple(problems))
|
||
expect: Mapping[str, Any] = case["expect"]
|
||
if not isinstance(expect, Mapping):
|
||
problems.append("expect 应为 JSON 对象")
|
||
return CaseResult(name, False, tuple(problems))
|
||
|
||
try:
|
||
rules = [dsl.Rule.model_validate(rule) for rule in case["rules"]]
|
||
ctx = _build_context(case.get("context") or {})
|
||
must_recommend = [_expected_item(item) for item in expect.get("must_recommend", [])]
|
||
must_avoid = [_expected_item(item) for item in expect.get("must_avoid", [])]
|
||
forbidden = [_expected_item(item) for item in expect.get("forbidden_recommend", [])]
|
||
except Exception as exc:
|
||
problems.append(f"用例无法解析:{exc}")
|
||
return CaseResult(name, False, tuple(problems))
|
||
|
||
unknown_expect = _unknown_keys(expect, _RULES_EXPECT_KEYS)
|
||
if unknown_expect:
|
||
problems.append(
|
||
f"expect 含未识别字段 {unknown_expect}(可用:{sorted(_RULES_EXPECT_KEYS)})——"
|
||
"静默忽略会让断言悄悄失效"
|
||
)
|
||
|
||
verdict = engine.evaluate(rules, ctx)
|
||
|
||
for slot, attrs in must_recommend:
|
||
if not any(_covers(rec, slot, attrs) for rec in verdict.recommend):
|
||
problems.append(
|
||
f"must_recommend 未命中:期望 {slot}[{attrs}],"
|
||
f"实际 recommend={_fmt_items(verdict.recommend)}"
|
||
)
|
||
for slot, attrs in must_avoid:
|
||
if not any(_covers(av, slot, attrs) for av in verdict.avoid):
|
||
problems.append(
|
||
f"must_avoid 未命中:期望 {slot}[{attrs}],实际 avoid={_fmt_items(verdict.avoid)}"
|
||
)
|
||
for slot, attrs in forbidden:
|
||
violation = next((rec for rec in verdict.recommend if _overlaps(rec, slot, attrs)), None)
|
||
if violation is not None:
|
||
problems.append(
|
||
f"forbidden_recommend 违规:{_fmt_slot_attrs(violation)} "
|
||
f"与红线 {slot}[{attrs}] 相交"
|
||
)
|
||
if "hard_blocks_nonempty" in expect:
|
||
want = bool(expect["hard_blocks_nonempty"])
|
||
if want != verdict.blocked:
|
||
hit_keys = [block.rule_key for block in verdict.hard_blocks]
|
||
problems.append(
|
||
f"hard_blocks_nonempty 期望 {want},实际 {verdict.blocked}"
|
||
f"(硬拦截规则:{hit_keys or '无'})"
|
||
)
|
||
return CaseResult(name, not problems, tuple(problems))
|
||
|
||
|
||
def run_rules_subset() -> SubsetReport:
|
||
"""规则子集:纯逻辑求值,同步执行即可(无 DB / 网络)."""
|
||
cases = _load_cases("rules_cases.json")
|
||
results = tuple(_judge_rules_case(case) for case in cases) or _empty_marker("rules")
|
||
thresholds = _thresholds_for("rules")
|
||
return SubsetReport(subset="rules", cases=results, pass_rate_min=thresholds.pass_rate_min)
|
||
|
||
|
||
# ── 检索子集(真实 PG + DeterministicEmbedder + hybrid_search)─────────────
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class SeedEntity:
|
||
"""一条检索用例自带的种子实体(跑前清理、跑后删除,见模块 docstring)."""
|
||
|
||
entity_type: str
|
||
name: str
|
||
attrs: dict[str, Any]
|
||
status: str
|
||
chunks: tuple[str, ...]
|
||
|
||
|
||
def _parse_seeds(raw_seeds: Any) -> list[SeedEntity]:
|
||
if not isinstance(raw_seeds, list) or not raw_seeds:
|
||
raise ValueError("seed_entities 必须是非空数组")
|
||
seeds: list[SeedEntity] = []
|
||
seen: set[str] = set()
|
||
for raw in raw_seeds:
|
||
if not isinstance(raw, dict):
|
||
raise ValueError(f"seed 实体应为 JSON 对象:{raw!r}")
|
||
name = str(raw.get("name", ""))
|
||
if not name.startswith(SEED_NAME_PREFIX):
|
||
raise ValueError(f"种子实体 name 必须以 {SEED_NAME_PREFIX!r} 开头:{name!r}")
|
||
if name in seen:
|
||
raise ValueError(f"种子实体 name 在用例内重复:{name!r}")
|
||
seen.add(name)
|
||
chunks = tuple(str(text) for text in raw.get("chunks", []))
|
||
if not chunks or not all(text.strip() for text in chunks):
|
||
raise ValueError(f"种子实体 {name!r} 至少要有一个非空 chunk 文本")
|
||
seeds.append(
|
||
SeedEntity(
|
||
entity_type=str(raw.get("entity_type", "item")),
|
||
name=name,
|
||
attrs=dict(raw.get("attrs") or {}),
|
||
status=str(raw.get("status", "published")),
|
||
chunks=chunks,
|
||
)
|
||
)
|
||
return seeds
|
||
|
||
|
||
def _build_user_ctx(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||
unknown = _unknown_keys(raw, _USER_CTX_KEYS)
|
||
if unknown:
|
||
raise ValueError(f"user_ctx 含未识别字段 {unknown}(可用:{sorted(_USER_CTX_KEYS)})")
|
||
ctx: dict[str, Any] = dict(raw)
|
||
if ctx.get("entity_types"):
|
||
ctx["entity_types"] = tuple(ctx["entity_types"])
|
||
else:
|
||
ctx.pop("entity_types", None) # 空数组视为不限类型(与 hybrid_search 语义一致)
|
||
return ctx
|
||
|
||
|
||
async def _delete_seeded(session: AsyncSession, seeds: Sequence[SeedEntity]) -> int:
|
||
"""按 golden: 前缀 name 删除本用例 seed 过的实体及其全部向量(幂等)."""
|
||
names = [seed.name for seed in seeds]
|
||
entity_ids = list(
|
||
(await session.execute(select(KbEntity.entity_id).where(KbEntity.name.in_(names))))
|
||
.scalars()
|
||
.all()
|
||
)
|
||
if not entity_ids:
|
||
return 0
|
||
await session.execute(delete(KbChunk).where(KbChunk.entity_id.in_(entity_ids)))
|
||
await session.execute(delete(KbEntity).where(KbEntity.entity_id.in_(entity_ids)))
|
||
return len(entity_ids)
|
||
|
||
|
||
def _judge_retrieval_expect(
|
||
expect: Mapping[str, Any],
|
||
results: Sequence[ScoredChunk],
|
||
name_by_entity: Mapping[int, str],
|
||
) -> tuple[list[str], bool | None]:
|
||
"""检索用例判定。返回 (差异摘要列表, 首命中是否在 top3 或 None)."""
|
||
problems: list[str] = []
|
||
unknown = _unknown_keys(expect, _RETRIEVAL_EXPECT_KEYS)
|
||
if unknown:
|
||
problems.append(f"expect 含未识别字段 {unknown}(可用:{sorted(_RETRIEVAL_EXPECT_KEYS)})")
|
||
|
||
position_by_name: dict[str, int] = {}
|
||
ranked_names: list[str] = []
|
||
for index, chunk in enumerate(results, start=1):
|
||
name = name_by_entity.get(chunk.entity_id, f"#实体{chunk.entity_id}")
|
||
ranked_names.append(name)
|
||
if name not in position_by_name:
|
||
position_by_name[name] = index
|
||
|
||
must_hit = [str(item) for item in expect.get("must_hit_names", [])]
|
||
must_miss = [str(item) for item in expect.get("must_miss_names", [])]
|
||
for name in must_hit:
|
||
if name not in position_by_name:
|
||
problems.append(f"must_hit 未出现:{name!r} 不在结果中(结果实体序列:{ranked_names})")
|
||
for name in must_miss:
|
||
position = position_by_name.get(name)
|
||
if position is not None:
|
||
problems.append(
|
||
f"must_miss 红线违规:{name!r} 出现在第 {position} 位——"
|
||
"硬过滤零漏(draft/禁忌/场景外实体绝不出现)被破坏"
|
||
)
|
||
|
||
first_hit_in_top3: bool | None = None
|
||
if "first_hit_in" in expect:
|
||
mode = expect["first_hit_in"]
|
||
if mode not in _FIRST_HIT_IN_CHOICES:
|
||
problems.append(f"first_hit_in 只能是 {_FIRST_HIT_IN_CHOICES},得到 {mode!r}")
|
||
else:
|
||
positions = [position_by_name[name] for name in must_hit if name in position_by_name]
|
||
if not positions:
|
||
problems.append("无法评价 first_hit_in:没有任何 must_hit 实体出现在结果中")
|
||
first_hit_in_top3 = False
|
||
else:
|
||
first = min(positions)
|
||
limit = 1 if mode == "top1" else 3
|
||
if first > limit:
|
||
problems.append(
|
||
f"first_hit_in={mode} 未满足:首个命中在第 {first} 位,"
|
||
f"前 {limit} 位序列:{ranked_names[:limit]}"
|
||
)
|
||
first_hit_in_top3 = first <= 3
|
||
return problems, first_hit_in_top3
|
||
|
||
|
||
async def run_retrieval_case(
|
||
case: Mapping[str, Any], embedder: DeterministicEmbedder
|
||
) -> CaseResult:
|
||
name = str(case.get("name", "<缺少 name>"))
|
||
problems: list[str] = []
|
||
unknown = _unknown_keys(case, _RETRIEVAL_CASE_KEYS)
|
||
if unknown:
|
||
problems.append(f"用例含未识别字段 {unknown}(可用:{sorted(_RETRIEVAL_CASE_KEYS)})")
|
||
try:
|
||
for required in ("seed_entities", "query", "expect"):
|
||
if required not in case:
|
||
raise ValueError(f"用例缺少必填字段 {required}")
|
||
seeds = _parse_seeds(case["seed_entities"])
|
||
query = str(case["query"])
|
||
user_ctx = _build_user_ctx(case.get("user_ctx") or {})
|
||
expect = case["expect"]
|
||
if not isinstance(expect, Mapping):
|
||
raise ValueError("expect 应为 JSON 对象")
|
||
except Exception as exc:
|
||
problems.append(f"用例无法解析:{exc}")
|
||
return CaseResult(name, False, tuple(problems))
|
||
|
||
first_hit_in_top3: bool | None = None
|
||
try:
|
||
# 1) 跑前先清自己 seed 的行:上一次运行可能中断在判定之前(幂等重跑)。
|
||
async with session_scope() as session:
|
||
await _delete_seeded(session, seeds)
|
||
# 2) 重建种子实体并写入向量(DeterministicEmbedder:同文本同向量)。
|
||
entity_ids: dict[str, int] = {}
|
||
async with session_scope() as session:
|
||
for seed in seeds:
|
||
entity_id = next_id()
|
||
session.add(
|
||
KbEntity(
|
||
entity_id=entity_id,
|
||
entity_type=seed.entity_type,
|
||
name=seed.name,
|
||
attrs=seed.attrs,
|
||
status=seed.status,
|
||
)
|
||
)
|
||
# 先落实体行再写 chunk:kb_chunk 有 entity 外键,而两个模型之间
|
||
# 没有 relationship(),session 又是 autoflush=False——不显式
|
||
# flush 的话 UOW 不保证实体先于 chunk 插入(实测会 FK 违约)。
|
||
await session.flush()
|
||
await upsert_chunks(
|
||
session,
|
||
entity_id,
|
||
[ChunkInput(text=text) for text in seed.chunks],
|
||
embedder,
|
||
)
|
||
entity_ids[seed.name] = entity_id
|
||
# 3) 混合检索:硬过滤在 SQL WHERE 里,"零漏"是结构保证而非概率保证。
|
||
query_vec = (await embedder.embed([query]))[0]
|
||
async with session_scope() as session:
|
||
results = await hybrid_search(
|
||
session,
|
||
query_vec,
|
||
query,
|
||
entity_types=user_ctx.get("entity_types"),
|
||
occasion=user_ctx.get("occasion"),
|
||
body_shape=user_ctx.get("body_shape"),
|
||
top_k=_RETRIEVAL_TOP_K,
|
||
)
|
||
name_by_entity = {entity_id: seed_name for seed_name, entity_id in entity_ids.items()}
|
||
case_problems, first_hit_in_top3 = _judge_retrieval_expect(expect, results, name_by_entity)
|
||
problems.extend(case_problems)
|
||
finally:
|
||
# 4) 跑后必删:集成库长活,留下的假实体会破坏其它用例对结果列表的精确断言。
|
||
try:
|
||
async with session_scope() as session:
|
||
await _delete_seeded(session, seeds)
|
||
except Exception as exc:
|
||
problems.append(f"种子清理失败(会污染共享集成库):{exc}")
|
||
return CaseResult(name, not problems, tuple(problems), first_hit_in_top3)
|
||
|
||
|
||
async def run_retrieval_subset() -> SubsetReport:
|
||
"""检索子集:真实 PG + pgvector 上执行(种子自管理,幂等可重跑)."""
|
||
cases = _load_cases("retrieval_cases.json")
|
||
embedder = DeterministicEmbedder(dimensions=_VECTOR_DIMENSIONS)
|
||
results = tuple([await run_retrieval_case(case, embedder) for case in cases])
|
||
if not results:
|
||
results = _empty_marker("retrieval")
|
||
thresholds = _thresholds_for("retrieval")
|
||
declaring = [case for case in results if case.first_hit_in_top3 is not None]
|
||
return SubsetReport(
|
||
subset="retrieval",
|
||
cases=results,
|
||
pass_rate_min=thresholds.pass_rate_min,
|
||
first_hit_in_top3_min=thresholds.first_hit_in_top3_min,
|
||
first_hit_in_top3_total=len(declaring),
|
||
first_hit_in_top3_passed=sum(1 for case in declaring if case.first_hit_in_top3),
|
||
)
|