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

145 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 黄金集回归框架(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 行"。
## 怎么跑
```bash
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`(纯逻辑)。
```json
{
"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)
```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` 只是薄壳。