Files
meida_backend/tests/golden/README.md
T
xsl 67d9754823 test(golden): data-driven golden set framework wired into CI
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 单条即拦截。)

偏差说明:无
2026-08-14 21:19:57 +08:00

7.4 KiB
Raw Blame History

黄金集回归框架(Golden Set)

任务 T-P2-11 | 需求 FR-KB-11(检索质量可回归)、NFR-MAINT-04(指标退化阻断合并) 架构 §17「黄金集是最重要的一道防线」| CI 防线 5c(.github/workflows/ci.yml)

黄金集 = 数据驱动的回归防线:用例存 JSON、阈值存配置、指标低于阈值直接 fail。 与"散在单测里的固定样本"的本质区别是——加用例 = 加 JSON 行,零代码改动; 顾问/运营扩充规模不需要工程师改断言。

目录

tests/golden/
  README.md              # 本文档:用例格式 + 加用例流程 + 阈值口径
  thresholds.json        # 各子集指标阈值(pass_rate 等)
  cases/
    rules_cases.json     # 规则黄金集(当前 13 例种子)
    retrieval_cases.json # 检索黄金集(当前 13 例种子)
  runner.py              # 用例加载 + 执行 + 指标计算 + 逐用例明细(不依赖 pytest)
  test_golden.py         # pytest 薄壳(marker=golden,无 MEIDA_RUN_INTEGRATION 时 skip)

四子集规划(知识库文档 §7,唯一事实源)

子集 目标规模 当前 状态
检索 100 13 ✅ 种子已就绪(内容轨 T-P2-16 扩充)
规则 —(决策表回归,随单测+本子集维护) 13 ✅ 种子已就绪
Agent 问答 100 0 ⏳ Sprint 3 占位
诊断报告结构 30 0 ⏳ Sprint 4 占位

规模达标是内容轨 T-P2-16 的事;本框架的职责是让"加用例 = 加 JSON 行"。

怎么跑

make golden                                    # = MEIDA_RUN_INTEGRATION=1 uv run pytest -m golden --no-cov
# 或直接:
MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m golden --no-cov
  • 默认 pytest(make test)不跑 golden(pyproject addopts 已排除),本地无 PG 不受影响; 未设 MEIDA_RUN_INTEGRATION 时本目录用例整体 skip,不拖累本地开发。
  • 需要 PostgreSQL(pgvector) 已迁移到最新 schema(make up && make migrate)。
  • CI 在集成测试之后强制跑(防线 5c),指标低于阈值 = job 失败 = 阻断合并。

用例格式

规则子集(cases/rules_cases.json)

每条用例是自包含的:规则数组 + 画像 + 期望。执行走 packages.rules 的 dsl.Rule.model_validate → dsl.RuleContext → engine.evaluate(纯逻辑)。

{
  "name": "user-taboo-overrides-trend-recommendation",
  "rules": [ { "rule_key": "...", "category": "...", "priority": 95,
               "conditions": {"user_taboos": {"include": ["露脐"]}},
               "effects": {"avoid": [{"slot": "top", "attrs": {"length": ["短款"]}}]} } ],
  "context": {"style": "甜酷", "user_taboos": ["露脐"]},
  "expect": {
    "must_recommend":      [{"slot": "top", "attrs_include": {"shoulder": ["垫肩"]}}],
    "must_avoid":          [{"slot": "top", "attrs_include": {"length": ["短款"]}}],
    "forbidden_recommend": [{"slot": "top", "attrs_include": {"length": ["短款"]}}],
    "hard_blocks_nonempty": false
  }
}

expect 判定语义(一条不符 = 该用例 fail):

键 语义
must_recommend 子集语义:实际 recommend 中存在同 slot 条目,且期望每个维度的取值都被其包含。attrs_include 为空 {} = 对整个 slot 的期望
must_avoid 同上,但核对对象是实际 avoid 列表
forbidden_recommend 红线语义:实际 recommend 中任何与期望维度取值相交的条目 = 违规(用于验证禁忌/合规压制:被压掉的推荐绝不允许复活)
hard_blocks_nonempty engine.evaluate 的 hard_blocks 是否非空(is_hard 规则的 avoid 命中)

检索子集(cases/retrieval_cases.json)

{
  "name": "taboo-body-shape-entity-never-leaks",
  "seed_entities": [
    {"entity_type": "item", "name": "golden:r02-taboo",
     "attrs": {"occasions": ["wedding"], "avoid_body_shapes": ["apple"]},
     "status": "published", "chunks": ["gq02 pear body dress styling guide"]}
  ],
  "query": "gq02 pear body dress styling guide for apple shapes",
  "user_ctx": {"body_shape": "apple", "occasion": "wedding", "entity_types": ["item"]},
  "expect": {
    "must_hit_names":  ["golden:r02-good"],
    "must_miss_names": ["golden:r02-taboo"],
    "first_hit_in": "top1"
  }
}
  • 种子实体契约:name 必须以 golden: 开头(runner 强校验)。执行时 跑前先删自己 seed 的行(上次中断也能幂等重跑)、跑后必删(集成库长活, 留下假实体会破坏其它用例对结果列表的精确断言)。
  • 判定语义:
    • must_hit_names:这些实体的 chunk 必须出现在结果里;
    • must_miss_names:硬过滤红线——draft / archived / 禁忌体型 / 场景外 / 类型外实体绝不允许出现在任何位置(零漏是 SQL WHERE 的结构保证,不是排序权重);
    • first_hit_in:must_hit_names 中最早出现的落位须满足 top1(第 1 位)或 top3(前 3 位)。
  • 确定性策略(校准新用例时遵守):
    1. 嵌入用 DeterministicEmbedder(同文本同向量),query 写成目标 chunk 的原文 → 余弦距离 0 → 向量路必排第 1,结果与库中其它数据无关;
    2. chunk/query 带各自独立的 marker token(gq01、gq02…)→ 关键词路只命中本用例的行;
    3. 需要非 top1 的落位时,用"两实体同 chunk 原文"构造并列(引擎按 chunk_id 稳定打破平局)。
  • 检索参数钉死在 runner(维度 1024、top_k 50、默认 RRF 权重),不读 env—— 同一份用例集在任何机器上口径一致。

加用例流程(零代码改动)

  1. 在 cases/*.json 数组里追加一个用例对象(格式见上);
  2. make golden 跑一遍校准 expect(种子用例必须 100% 通过——不过就是 expect 写错 或真有退化,二选一,搞清楚再提交);
  3. 提交。CI 防线 5c 自动把它纳入回归口径。

注意:runner 对用例/expect 的未知键一律判 fail(封闭词汇表)——must_recomend 这类 typo 不会被静默忽略,这是防"断言悄悄失效"的刻意设计(与规则 DSL 同一哲学)。

阈值口径(thresholds.json)

子集 指标 阈值 说明
rules pass_rate_min 1.0 硬结论子集,一条不许退(原因见下)
retrieval pass_rate_min 0.8 顾问扩充用例后可逐步上调
retrieval first_hit_in_top3_min 0.8 首个 must_hit 落进前 3 的比例

为什么规则子集阈值必须 1.0:产品根基本上是"结论由规则引擎产出,LLM 只负责表达" (防幻觉红线)。规则黄金集里的每一条都是人工校准过的硬结论——体型该推荐什么、 禁忌必须压过什么、合规必须拦什么。这些结论退化了任何一条,就意味着给用户的建议 直接变味,不存在"可以容忍 20% 出错"的余地;而检索是排序质量问题,允许有起步阈值、 随用例规模成熟逐步收紧。所以:规则 pass_rate < 1.0 = fail,没有商量。

runner 复用(不依赖 pytest)

runner.py 是普通模块:run_rules_subset()(同步)与 run_retrieval_subset()(async) 各自返回 SubsetReport(含 pass_rate、first_hit_in_top3_rate、逐用例差异明细 summary())。将来本地脚本、admin 后台"跑一遍黄金集"直接 import 即可, test_golden.py 只是薄壳。