# SGME 提炼质量评测框架设计 v0.1 > 版本:v0.1 > 日期:2026-08-06 > 依据:SGME-评测基线-PRD-v0.1.md、SGME-架构设计-0.4.md §14 #32、SGME-提示词版本管理-v0.1.md(#33) > 范围:评测引擎架构 + 评测集格式 + L1/L2/RRF 度量计算 + A/B 裁判 + 任务分解 > 约束:评测不破坏提炼链路(参数/模式开关隔离);`eval/` 顶层目录与 `sgme/` 并列;最小侵入 --- ## 0. 现状核实(已读文件确认) | 项 | 现状 | 对评测设计的影响 | |---|---|---| | L1 引擎 | `sgme/engine/l1.py`:`extract_l1(conversation, dimensions, llm_cfg, ...)` 返回 `(memories, provider, prompt_meta)` | 评测直接调用 `extract_l1()`,传入 eval case 的 conversation 文本——天然可复用 | | L1.5 引擎 | `sgme/engine/l15.py`:`resolve_conflicts(new_memories, mem_conn, cfg, ...)` → `L15Result` | 评测需提供真实 `mem_conn`(可指向临时 DB 或隔离 namespace) | | L2 引擎 | `sgme/engine/l2.py`:`aggregate(memories, mem_conn, wiki_conn, cfg, ...)` → `L2Result` | 评测需 wiki_conn 执行模板查询,验证 section 命中 | | 提炼调度 | `sgme/engine/refine.py`:`refine_file(file_id)` 串联 L0→L1→归一化 | 评测不走 `refine_file`(它依赖 L0 文件),直接调各引擎函数 | | PromptStore | `sgme/prompts/manager.py`:`get(stage, ctx)` 支持版本/A-B 钉版 | 评测通过 `BucketCtx(overrides=...)` 钉版,`--prompt-version` 映射为 overrides | | refine_runs | `sgme/storage/db.py` v3:已含 `(run_id, file_id, stage, version, variant, provider, ...)` | 评测复用 refine_runs 记录每次 eval run,`file_id` 填 `case_id` | | 维度注册表 | `registry/dimensions.yaml`(15 维)+ `registry/aliases.yaml` | L1 F1 计算时,ground truth 维度与预测维度均已归一化为注册表 id | | 模板引擎 | `templates/daily.yaml` `coding.yaml` `work.yaml` `full.yaml` | L2 section 命中率:执行模板查询 → 比对 section 归属 | | 归一化 | `sgme/engine/normalize.py`:`normalize_batch()` | L1 F1 计算前对预测维度做归一化 | --- ## 1. 实现方案 ### 1.1 评测如何对接现有提炼链路 **核心原则:评测套件 = 提炼链路的消费者,不做改造者。** ``` ┌─────────────────────────────────────────────────────────────────┐ │ eval/engine/runner.py │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ │ │ 评测集加载 │ → │ L1 提取 │ → │ L1.5 冲突 │ → │ L2 场景 │ │ │ │ loader.py │ │ (调用 │ │ 提炼 │ │ 聚合 │ │ │ │ │ │ sgme. │ │ (调用 │ │ (调用 │ │ │ │ │ │ engine. │ │ sgme. │ │ sgme. │ │ │ │ │ │ l1) │ │ engine. │ │ engine. │ │ │ │ │ │ │ │ l15) │ │ l2) │ │ │ └──────────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 度量计算器 metrics.py │ │ │ │ L1 F1 / Strict Match / memory_type Acc / time_velocity │ │ │ │ L2 Section 命中率 / 画像质量 │ │ │ │ RRF NDCG@10(条件:/search 实现后) │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 报告生成器 reporter.py │ │ │ │ report.json + report.md + per_case.csv + 退出码 │ │ │ └──────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` **对接方式(最小侵入)**: 1. **L1**:评测直接调用 `sgme.engine.l1.extract_l1(conversation, dimensions, llm_cfg, bucket_ctx=...)` ——与 `refine.py` 调用方式完全一致,只是 `conversation` 来自 eval case 而非 L0 文件 2. **L1.5**:评测调用 `sgme.engine.l15.resolve_conflicts(normalized_memories, eval_mem_conn, cfg, ...)` ——需要独立的 eval SQLite 数据库(`eval/tmp/eval_memory.db`),避免污染生产数据 3. **L2**:评测调用 `sgme.engine.l2.aggregate(memories_with_ids, eval_mem_conn, eval_wiki_conn, cfg, ...)` ——同理需独立 eval wiki.db 4. **模板查询**:评测调用 `sgme.profile.query_engine`(或直接 SQL)对 eval DB 执行模板查询,获取各 section 的记忆归属 5. **归一化**:评测复用 `sgme.engine.normalize.normalize_batch()`,确保预测维度与 ground truth 维度使用同一套注册表 id 体系 **隔离机制**: - eval 使用独立的数据目录 `eval/tmp/`(含 `eval_memory.db` + `eval_wiki.db`),启动时从 `init_databases()` 创建 - eval 完成后可选清理临时 DB(`--keep-db` 保留用于调试) - eval 不走 `refine_file()` / `refine_batch()` 等生产调度路径 - 通过 `BucketCtx(overrides={stage: version_ref})` 钉版,不修改 manifest 的 active ### 1.2 评测引擎架构 ``` eval/ __init__.py run.py # CLI 入口(python -m eval.run) engine/ __init__.py models.py # 数据结构:EvalCase, EvalResult, L1Metrics, L2Metrics 等 loader.py # 评测集加载(YAML → EvalCase 列表) runner.py # 评测流水线(L1 → L1.5 → L2 → 模板查询 → 度量) metrics.py # 度量计算(L1 F1, L2 命中率, RRF NDCG, 画像质量) reporter.py # 报告生成(JSON + MD + CSV) cases/ v001.yaml # 首版评测集(80 条用例) v001_sample.yaml # 精简样本(开发调试用 5 条) results/ # 评测结果输出(不入 git,但 .gitkeep 保留目录) .gitkeep tmp/ # 评测运行时的临时 DB(不入 git) .gitkeep ``` ### 1.3 L1 维度标注准确率计算链路 这是评测框架最核心的度量链路。从头到尾的计算过程如下: ``` ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 1:提取 L1 输出 │ │ │ │ case.conversation ──→ l1.extract_l1() ──→ raw_memories[] │ │ (评测用例的会话文本) │ 每条含: │ │ │ content, dimensions │ │ │ (LLM 原始标签), │ │ │ memory_type, priority, │ │ │ time_velocity │ └──────────────────────────────┼───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 2:归一化预测维度(复用 normalize.py) │ │ │ │ raw_memories[].dimensions ──→ normalize.normalize_batch() │ │ (如 ["技术栈", "风格"]) │ 别名表 + fuzzy 兜底 │ │ ──→ dimension_ids │ │ (如 ["tech_stack", "style"]) │ │ 归一化失败的标签 → 丢弃(不计入 FP/FN,仅记 anomaly_log) │ └──────────────────────────────┼───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 3:记忆匹配(预测 ↔ ground truth) │ │ │ │ 预测记忆列表: [M_pred_1, M_pred_2, ...] │ │ GT 记忆列表: [M_gt_1, M_gt_2, ...] │ │ │ │ 匹配策略(内容相似度 + 最大权匹配): │ │ - 对每对 (pred_i, gt_j),计算 content 的归一化编辑距离相似度 │ │ - sim = 1 - Levenshtein(pred.content, gt.content) │ │ / max(len(pred), len(gt)) │ │ - 构建二分图,使用贪心最大匹配(Hungarian 可选,首版贪心) │ │ - sim ≥ 0.5 → 匹配成功;< 0.5 → 不匹配 │ │ - 每个预测最多匹配一个 GT,每个 GT 最多匹配一个预测 │ │ │ │ 匹配结果: │ │ - matched_pairs: [(pred_i, gt_j), ...] │ │ - unmatched_preds: [pred_i, ...] → 视为 FP 记忆(多提取) │ │ - unmatched_gts: [gt_j, ...] → 视为 FN 记忆(漏提取) │ └──────────────────────────────┼───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 4:维度级 TP/FP/FN 计算 │ │ │ │ 对每对匹配 (pred, gt): │ │ pred_dims = set(pred.dimension_ids) # 归一化后的维度 id 集合 │ │ gt_dims = set(gt.dimensions) # ground truth 维度集合 │ │ │ │ TP += |pred_dims ∩ gt_dims| # 标对的维度 │ │ FP += |pred_dims - gt_dims| # 多标的维度 │ │ FN += |gt_dims - pred_dims| # 漏标的维度 │ │ │ │ 对 unmatched_preds(多提取的记忆): │ │ FP += Σ |pred.dimension_ids| # 整条记忆的所有维度都算 FP │ │ │ │ 对 unmatched_gts(漏提取的记忆): │ │ FN += Σ |gt.dimensions| # 整条记忆的所有维度都算 FN │ └──────────────────────────────┼───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 5:汇总计算 L1 指标 │ │ │ │ 微平均 Precision = TP / (TP + FP) │ │ 微平均 Recall = TP / (TP + FN) │ │ 微平均 F1 = 2 * P * R / (P + R) │ │ │ │ 逐维度 F1:对每个维度 id 单独统计 TP_d / FP_d / FN_d → F1_d │ │ │ │ Strict Match Rate = count(case 全部维度完全匹配) / total_cases │ │ (某 case 的 pred_dims == gt_dims 对所有匹配 memory 成立 │ │ 且无 unmatched_preds 和 unmatched_gts → 该 case strict=1) │ │ │ │ 对匹配记忆对计算: │ │ memory_type Acc = count(pred.type == gt.type) / matched_pairs │ │ time_velocity Acc = count(pred.tv == gt.tv) / matched_pairs │ │ priority MAE = Σ|pred.priority - gt.priority| / matched_pairs │ └──────────────────────────────────────────────────────────────────┘ ``` **关键设计决策**: - 维度注册表的作用:提供 ground truth 的维度 id 体系(`expected_l1.memories[].dimensions` 已是注册表 id),归一化层确保预测维度也映射到同一 id 体系 - 记忆匹配使用内容相似度而非顺序匹配——LLM 输出顺序不稳定,内容匹配更可靠 - 相似度阈值 0.5 为初始值,可在评测迭代中根据 IAA(标注者间一致率)校准 ### 1.4 L2 Section 命中率计算 L2 评测需要完整走完 L1 → L1.5 落库 → L2 聚合 → 模板查询的全链路。 ``` ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 1:全链路执行 │ │ │ │ case.conversation │ │ → l1.extract_l1() → raw_memories (含维度标签) │ │ → normalize → dimension_ids │ │ → l15.resolve_conflicts → 落库到 eval_memory.db │ │ → l2.aggregate → 场景聚合到 eval_wiki.db │ │ → 模板查询(case 指定的 mode)→ section 归属 │ └──────────────────────────────┼───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 2:模板查询执行 │ │ │ │ 对 case.expected_l2.template_section 指定的 mode: │ │ - 加载模板 YAML(如 templates/daily.yaml) │ │ - 逐个 section 执行结构化 SQL 查询(维度过滤 + 排序 + limit) │ │ - 收集每条记忆落入的 section 标题 │ │ │ │ section 匹配规则: │ │ - 模板 section.title 是 LLM 友好的展示文本(如 "👤 基本信息") │ │ - 评测时比较记忆落入的 section.title 与 GT 中的 │ │ expected_l2.template_section[memory_index] 是否一致 │ │ - 匹配以记忆的 memory_id(通过 content 关联到 GT memory) │ └──────────────────────────────┼───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 步骤 3:计算 L2 指标 │ │ │ │ Section 命中率 = 命中预期 section 的记忆数 / GT 应有记忆总数 │ │ - 分子:template_section 匹配正确的记忆条数 │ │ - 分母:expected_l2.template_section 中列出的记忆总数 │ │ │ │ Section 误入率 = 进入错误 section 的记忆数 / 查询返回记忆总数 │ │ Section 漏出率 = 未出现在任何 section 的记忆数 / GT 应有记忆总数 │ │ │ │ 画像质量 = L1_Dimension_F1 × L2_Section_HitRate │ └──────────────────────────────────────────────────────────────────┘ ``` **模板 section 匹配细节**: - 模板 section 的 `query.dimensions`(AND 语义)决定了哪些记忆会落入该 section - 评测脚本执行模板查询时,记录每个 section 返回的 memory_id 列表 - 通过 `scene_memories` 表或直接查询 `memory_tags`,反查每条 GT 记忆落入的 section - GT 的 `template_section` 字段标注格式:`{memory_index: section_title}` ### 1.5 RRF 评估接口 RRF 评估依赖 `/search` 端点(当前未实现),但评测框架需要预留接口。 ``` ┌──────────────────────────────────────────────────────────────────┐ │ RRF 评估架构(当前:接口预留,/search 实现后接入) │ │ │ │ eval/engine/rrf.py │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ GridSearch │ │ │ │ param_space: {rrf_k, bm25_weight, top_k, bm25_k1, bm25_b}│ │ │ │ search() → list[(params, ndcg_score)] │ │ │ │ best_params() → dict │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ 接入方式(/search 实现后): │ │ 1. 构建 RRF 评测子集(~30 条,含 expected_search_results) │ │ 2. 对每个参数组合: │ │ - 调用 sgme.search.engine.search(query, params) │ │ - 或用 HTTP 客户端调 MemoryHub /v1/search │ │ - 获取排序后的 memory_id 列表 │ │ 3. 与 expected_search_results 比对,计算 NDCG@10 │ │ 4. 网格搜索结束 → 输出最优参数 + 敏感度曲线数据 │ │ │ │ 当前阶段(/search 未实现): │ │ - rrf.py 定义完整的接口签名与数据结构 │ │ - GridSearch.search() 抛 NotImplementedError,附提示信息 │ │ - NDCG 计算函数可独立使用(接受任意排序列表 + GT 列表) │ └──────────────────────────────────────────────────────────────────┘ ``` **NDCG@10 计算公式**: ``` DCG@10 = Σ_{i=1}^{10} rel_i / log₂(i+1) 其中 rel_i = 1(第 i 位记忆在 GT 相关列表中) / 0(不在) IDCG@10 = 理想排序下的 DCG@10(GT 直接按相关性排列) NDCG@10 = DCG@10 / IDCG@10 ``` ### 1.6 #33 A/B 裁判 评测框架是 #33 提示词版本管理的 A/B 裁判消费者: ``` ┌──────────────────────────────────────────────────────────────────┐ │ A/B 评测流程 │ │ │ │ python -m eval.run \ │ │ --cases eval/cases/v001.yaml \ │ │ --prompt-version v001 \ ← A 版 │ │ --output eval/results/ab_v001/ │ │ │ │ python -m eval.run \ │ │ --cases eval/cases/v001.yaml \ │ │ --prompt-version v002 \ ← B 版 │ │ --output eval/results/ab_v002/ │ │ │ │ python -m eval.run --compare \ │ │ eval/results/ab_v001/report.json \ │ │ eval/results/ab_v002/report.json │ │ → 输出 A/B 差分报告: │ │ Δ L1 F1 = +0.03 (v002 > v001) │ │ Δ Strict Match = +0.05 │ │ 逐维度 F1 变化热力图 │ │ │ │ 实现方式: │ │ - --prompt-version 映射为 BucketCtx(overrides={stage: version}) │ │ - PromptStore 的 overrides 优先级高于 manifest active │ │ - 每个版本跑完整的全链路评测 │ │ - --compare 模式纯离线:读两份 report.json → diff → 输出 │ │ │ │ 不做自动裁决:结论留人工 + 评测集,差分报告只呈现数据 │ └──────────────────────────────────────────────────────────────────┘ ``` --- ### 1.7 注入效果评测(T-20) 评测框架在 L1/L2/RRF 之外新增注入效果阶段,度量定义见 PRD §5.4。 #### 1.7.1 数据形态 评测用例在既有 L1/L2 ground truth 之外附加可选字段 `expected_inject`: ```yaml expected_inject: mode: coding # 注入模式(daily/coding/work/full) subsequent_conversation: | # 用户后续对话(注入画像之后的对话) [msg#2] 2026-01-02T10:00:00Z user: 那个 Rust 项目 CI 挂了,帮我看看怎么办 referenced_memory_indices: [1] # 后续对话引用的 GT 记忆索引(按 expected_l1.memories) ``` `referenced_memory_indices` 是人工标注的「后续对话引用了哪些 GT 记忆」, 与 §1.3 的 ground truth 标注同一套人工流程,零 LLM 判定。 #### 1.7.2 执行链路 ``` ┌──────────────────────────────────────────────────────────────────────┐ │ 注入评测阶段(eval/runner.py 的 inject stage) │ │ │ │ case.expected_inject │ │ │ │ │ ▼ │ │ GT 记忆落库(复用 retrieval_gt 的确定性 memory_id 规则) │ │ memory_id = {case_id}#{idx} │ │ updated_at 取当前 UTC(模板含 time_window,必须保证记忆在窗口内) │ │ ▼ │ │ 模板加载 + 注入(sgme.profile.inject / build_inject_blocks) │ │ 零 LLM 纯 SQL:query_section 逐 section 查询 │ │ ▼ │ │ 画像块 blocks[](title / items[memory_id] / present) │ │ ▼ │ │ compute_inject_metrics(eval/metrics.py) │ │ 注入命中率 = 相关块数 / 注入块总数 │ │ 引用覆盖率 = 命中且引用数 / 引用记忆数 │ └──────────────────────────────────────────────────────────────────────┘ ``` #### 1.7.3 度量计算(metrics.py 新增) ``` compute_inject_metrics(blocks, expected_inject) -> InjectMetrics # blocks: build_inject_blocks 输出(含 memory_id,present 标记) # 相关块:块内含 ≥1 条 memory_id 索引 ∈ referenced_memory_indices 的记忆 # 注入命中率 = 相关块数 / present=true 的块数 # 引用覆盖率 = (被引用且注入命中的记忆数) / len(referenced_memory_indices) ``` #### 1.7.4 文件改动 | 文件 | 改动 | |---|---| | `eval/models.py` | 新增 `InjectGroundTruth` / `InjectMetrics` / `CaseResult` 注入字段 | | `eval/loader.py` | 解析并校验 `expected_inject` | | `eval/metrics.py` | 新增 `compute_inject_metrics` / `aggregate_inject_metrics` | | `eval/runner.py` | 新增 inject stage(复用 retrieval_gt 落库 + profile.inject) | | `eval/reporter.py` | report.json / report.md 注入段 | | `eval/run.py` | `--stages` 支持 inject | | `tests/test_eval.py` | 注入度量 + runner inject stage 测试 | --- ## 2. 文件列表 ### 新增文件 | 文件 | 说明 | |---|---| | `docs/design/SGME-评测框架设计-v0.1.md` | 本文档 | | `docs/design/eval-class-diagram.mermaid` | 类图(§3) | | `docs/design/eval-sequence-diagram.mermaid` | 时序图(§4) | | `eval/__init__.py` | 评测包入口 | | `eval/run.py` | CLI 入口(python -m eval.run) | | `eval/engine/__init__.py` | 引擎包入口 | | `eval/engine/models.py` | 数据结构定义(EvalCase, EvalResult, L1Metrics, L2Metrics, ...) | | `eval/engine/loader.py` | 评测集加载器(YAML → EvalCase 列表,校验 ground truth schema) | | `eval/engine/runner.py` | 评测流水线(串联 L1/L1.5/L2/模板查询 + 调用 metrics) | | `eval/engine/metrics.py` | 度量计算器(L1 F1 + Strict Match + L2 命中率 + 画像质量 + NDCG) | | `eval/engine/reporter.py` | 报告生成器(report.json + report.md + per_case.csv) | | `eval/engine/rrf.py` | RRF 网格搜索 + NDCG 计算(接口预留,/search 实现后接入) | | `eval/engine/ab.py` | A/B 差分报告(读两份 report.json → diff 输出) | | `eval/cases/.gitkeep` | 保持 cases 目录存在 | | `eval/cases/v001_sample.yaml` | 精简样本评测集(5 条,开发调试用) | | `eval/results/.gitkeep` | 保持 results 目录存在 | | `eval/tmp/.gitkeep` | 保持 tmp 目录存在 | | `tests/test_eval_loader.py` | loader 单测(用例加载 + schema 校验) | | `tests/test_eval_metrics.py` | metrics 单测(L1 F1 + L2 命中率计算) | | `tests/test_eval_runner.py` | runner 集成测试(用 mock LLM + v001_sample) | | `tests/test_eval_reporter.py` | reporter 单测(JSON/MD/CSV 输出) | ### 修改文件 | 文件 | 改动 | |---|---| | `AGENTS.md` | 文档索引增加本文档路径 | **无需修改的文件**(评测通过现有 public API 对接,不动核心提炼逻辑): - `sgme/engine/l1.py` — 不动 - `sgme/engine/l15.py` — 不动 - `sgme/engine/l2.py` — 不动 - `sgme/engine/refine.py` — 不动 - `sgme/storage/db.py` — 不动(复用 refine_runs 表,评测 `file_id` 填 `case_id`) - `sgme/prompts/manager.py` — 不动(通过 BucketCtx.overrides 钉版) --- ## 3. 数据结构与接口(类图) 见 `docs/design/eval-class-diagram.mermaid`(合并版见下): ```mermaid classDiagram direction TB %% ── 评测用例数据结构 ── class EvalCase { +case_id: str +source: str +difficulty: str +conversation: str +expected_l1: L1GroundTruth +expected_l15: L15GroundTruth | None +expected_l2: L2GroundTruth | None +notes: str } class L1GroundTruth { +memories: list[GtMemory] } class GtMemory { +content: str +dimensions: list[str] +memory_type: str +priority: int +time_velocity: str +source_message_ids: list[str] } class L15GroundTruth { +actions: list[GtConflictAction] } class GtConflictAction { +new_memory_index: int +candidate_ids: list[str] +action: str +merged_content: str | None +reason: str } class L2GroundTruth { +scene_labels: list[str] +template_section: dict[str, str] } %% ── 评测结果数据结构 ── class EvalResult { +run_id: str +timestamp: str +prompt_versions: dict +l1: L1Metrics +l2: L2Metrics +l15: L15Metrics | None +rrf: RRFMetrics | None +per_case: list[CaseResult] +summary: EvalSummary } class L1Metrics { +dimension_micro_f1: float +dimension_micro_precision: float +dimension_micro_recall: float +per_dimension_f1: dict[str, float] +strict_match_rate: float +memory_type_accuracy: float +time_velocity_accuracy: float +priority_mae: float +total_tp: int +total_fp: int +total_fn: int } class L2Metrics { +section_hit_rate: float +section_misentry_rate: float +section_miss_rate: float +profile_quality: float +total_evaluated: int } class L15Metrics { +action_accuracy: float +per_action_counts: dict[str, int] } class RRFMetrics { +best_ndcg10: float +best_params: dict +all_results: list[dict] +param_sensitivity: dict } class CaseResult { +case_id: str +difficulty: str +l1_f1: float +strict_match: bool +matched_memories: int +unmatched_pred: int +unmatched_gt: int +dimension_details: list[dict] +error: str | None } class EvalSummary { +total_cases: int +passed_p0: bool +p0_status: dict[str, str] +duration_seconds: float } %% ── 评测引擎组件 ── class EvalLoader { +PROJECT_ROOT: Path +load_yaml(path: str | Path) list[EvalCase] +validate_case(case: EvalCase) list[str] +get_dimension_names() list[str] } class EvalRunner { +cfg: dict +mem_conn: sqlite3.Connection +wiki_conn: sqlite3.Connection +run_all(cases: list[EvalCase], stages: list[str], prompt_version: str | None) EvalResult +run_one(case: EvalCase, stages: list[str], prompt_version: str | None) CaseResult +_run_l1(case: EvalCase) tuple[list[dict], dict] +_run_l15(memories: list[dict], case: EvalCase) L15Result +_run_l2(memories: list[dict]) L2Result +_run_template_query(mode: str, expected: dict) dict +_setup_eval_db() None +_teardown_eval_db() None } class MetricsCalculator { +compute_l1(predictions: list[dict], ground_truth: L1GroundTruth) L1Metrics +compute_l2(template_results: dict, ground_truth: L2GroundTruth, l1_f1: float) L2Metrics +compute_l15(predictions: L15Result, ground_truth: L15GroundTruth) L15Metrics +_match_memories(pred: list[dict], gt: list[GtMemory]) list[tuple[int, int]] +_content_similarity(a: str, b: str) float +_compute_dimension_tp_fp_fn(matched: list, unmatched_pred: list, unmatched_gt: list) tuple } class ReportGenerator { +generate(result: EvalResult, output_dir: Path) None +_generate_json(result: EvalResult) dict +_generate_markdown(result: EvalResult) str +_generate_csv(result: EvalResult) str +_generate_heatmap_text(l1: L1Metrics) str } class RRFGridSearch { +param_space: dict[str, list] +search(query_fn: Callable, ground_truth: dict) RRFMetrics +_compute_ndcg(predicted: list[str], relevant: list[str], k: int) float +best_params() dict +status() str } class ABComparer { +compare(report_a: dict, report_b: dict) dict +_diff_l1(a: L1Metrics, b: L1Metrics) dict +_diff_l2(a: L2Metrics, b: L2Metrics) dict +_format_diff_markdown(diff: dict) str } %% ── 关系 ── EvalCase *-- L1GroundTruth EvalCase *-- L15GroundTruth EvalCase *-- L2GroundTruth L1GroundTruth *-- GtMemory L15GroundTruth *-- GtConflictAction L2GroundTruth -- GtMemory : references by index EvalResult *-- L1Metrics EvalResult *-- L2Metrics EvalResult *-- L15Metrics EvalResult *-- RRFMetrics EvalResult *-- CaseResult EvalResult *-- EvalSummary EvalLoader ..> EvalCase : produces EvalRunner ..> EvalLoader : uses EvalRunner ..> MetricsCalculator : uses EvalRunner ..> EvalResult : produces MetricsCalculator ..> L1Metrics : produces MetricsCalculator ..> L2Metrics : produces ReportGenerator ..> EvalResult : consumes RRFGridSearch ..> RRFMetrics : produces ABComparer ..> EvalResult : consumes ``` ### 评测用例 YAML 格式 ```yaml # eval/cases/v001.yaml — SGME 评测基线 v001 # 格式约定:字段英文,注释中文;cases 数组每条为一个评测用例 meta: version: v001 created_at: "2026-08-06T00:00:00Z" total_cases: 80 description: "SGME L1/L2 提炼质量评测基线首版" cases: - case_id: eval-001 source: synthetic difficulty: easy conversation: | [msg#1] 2026-01-01T10:00:00Z user: 我叫张明,在深圳做后端开发,用 Python 和 Go expected_l1: memories: - content: "张明,深圳,后端开发" dimensions: [identity, skills, tech_stack] memory_type: persona priority: 85 time_velocity: static source_message_ids: ["msg_1"] expected_l2: scene_labels: ["个人信息"] template_section: daily: "0": "👤 基本信息" # memory_index → section_title notes: "基础单维度标注 + 多维度拆分" - case_id: eval-002 source: synthetic difficulty: medium conversation: | [msg#1] 2026-01-01T11:00:00Z user: SGME 这个项目架构从 Fork 改自研了,用 Python 重写,参考了 TencentDB-Agent-Memory 的设计 expected_l1: memories: - content: "SGME 项目从 Fork 改为 Python 自研" dimensions: [projects, tech_stack] memory_type: episodic priority: 80 time_velocity: static source_message_ids: ["msg_1"] - content: "参考了 TencentDB-Agent-Memory 的设计思想" dimensions: [tech_stack, values] memory_type: episodic priority: 75 time_velocity: static source_message_ids: ["msg_1"] expected_l2: scene_labels: ["SGME 架构演进"] template_section: coding: "0": "📦 项目进展" "1": "🔧 技术决策" notes: "维度边界:项目 vs 技术栈 vs 价值观" # ... 其余 78 条用例 ``` --- ## 4. 程序调用流程(时序图) 见 `docs/design/eval-sequence-diagram.mermaid`(合并版见下): ```mermaid sequenceDiagram autonumber actor User participant CLI as eval/run.py participant EL as EvalLoader participant ER as EvalRunner participant L1 as sgme/engine/l1.py participant L15 as sgme/engine/l15.py participant L2 as sgme/engine/l2.py participant PS as sgme/prompts/manager.py participant NORM as sgme/engine/normalize.py participant MC as MetricsCalculator participant RG as ReportGenerator participant MD as sgme/storage/memory_dao.py participant DB as eval/tmp/ (eval DB) Note over User,DB: 一次完整 baseline run User->>CLI: python -m eval.run --cases eval/cases/v001.yaml --stages all --prompt-version v001 CLI->>EL: load_yaml("eval/cases/v001.yaml") EL-->>CLI: list[EvalCase] (80 cases) CLI->>ER: EvalRunner(cfg, prompt_version="v001") ER->>ER: _setup_eval_db() Note over ER: 创建 eval/tmp/eval_memory.db + eval_wiki.db
(独立于生产 DB,无污染) loop 每个 eval case ER->>ER: run_one(case, stages=["l1","l2"]) Note over ER,L1: L1 提取 ER->>PS: get("l1_extraction", BucketCtx(overrides={"l1_extraction":"v001"})) PS-->>ER: PromptVersion(text, "v001", None) ER->>L1: extract_l1(case.conversation, dimensions, llm_cfg, bucket_ctx=...) L1->>L1: render + LLM call_with_fallback + parse L1-->>ER: (raw_memories, provider, prompt_meta) Note over ER,NORM: 维度归一化 ER->>NORM: normalize_batch(raw_memories[].dimensions, alias_map, registry_names) NORM-->>ER: (dimension_ids, stats) Note over ER,L15: L1.5 冲突提炼(可选,L2 评测需要) ER->>MD: build_alias_map(mem_conn) ER->>L15: resolve_conflicts(normalized_memories, eval_mem_conn, cfg, bucket_ctx=...) L15->>L15: 候选池 → 分批 LLM → 四动作落库 L15-->>ER: L15Result (stored/updated/merged memory_ids) Note over ER,L2: L2 场景聚合 ER->>L2: aggregate(memories_with_ids, eval_mem_conn, eval_wiki_conn, cfg, bucket_ctx=...) L2->>L2: 分批 LLM → update/merge/create 落库 L2-->>ER: L2Result (created/updated/merged scene_ids) Note over ER,DB: 模板查询验证 ER->>DB: 执行模板查询(按 case.expected_l2.template_section 指定的 mode) DB-->>ER: section 归属结果 {memory_index: section_title} Note over ER,MC: 度量计算 ER->>MC: compute_l1(raw_memories, case.expected_l1) MC->>MC: _match_memories() + _compute_dimension_tp_fp_fn() MC-->>ER: L1Metrics (per-case) ER->>MC: compute_l2(template_results, case.expected_l2, l1_f1) MC-->>ER: L2Metrics (per-case) ER-->>ER: 累积 CaseResult end Note over ER,MC: 汇总度量 ER->>MC: aggregate all per-case metrics MC-->>ER: L1Metrics + L2Metrics (aggregated) ER->>ER: _teardown_eval_db() (除非 --keep-db) Note over ER,RG: 报告生成 ER->>RG: generate(result, output_dir) RG->>RG: _generate_json() → report.json RG->>RG: _generate_markdown() → report.md RG->>RG: _generate_csv() → per_case.csv CLI-->>User: 退出码 0(全部 P0 达标)/ 1(有指标不达标) ``` ### A/B 对比时序 ```mermaid sequenceDiagram autonumber actor User participant CLI as eval/run.py participant ER_A as EvalRunner (v001) participant ER_B as EvalRunner (v002) participant AB as ABComparer participant RG as ReportGenerator User->>CLI: python -m eval.run --cases v001.yaml --prompt-version v001 --output results/ab_v001/ CLI->>ER_A: run_all(cases, prompt_version="v001") ER_A-->>CLI: EvalResult A → results/ab_v001/report.json User->>CLI: python -m eval.run --cases v001.yaml --prompt-version v002 --output results/ab_v002/ CLI->>ER_B: run_all(cases, prompt_version="v002") ER_B-->>CLI: EvalResult B → results/ab_v002/report.json User->>CLI: python -m eval.run --compare results/ab_v001/report.json results/ab_v002/report.json CLI->>AB: compare(report_a, report_b) AB->>AB: _diff_l1() → Δ F1, Δ Strict Match AB->>AB: _diff_l2() → Δ Section Hit Rate AB-->>CLI: diff dict CLI->>RG: generate_diff_report(diff) RG-->>CLI: A/B 差分报告 (MD) CLI-->>User: A/B 差分报告:Δ L1 F1 = +0.03 (v002 > v001) ``` --- ## 5. 依赖包列表 无新增第三方依赖。所有依赖已在 `pyproject.toml` 中: ``` - PyYAML>=6.0 # YAML 解析(eval cases + report) - pytest>=7.0 # 测试框架 - rapidfuzz (可选) # 高精度内容相似度匹配(stdlib difflib 兜底) ``` `rapidfuzz` 为可选依赖——首版使用 stdlib `difflib.SequenceMatcher` 做内容相似度匹配,若后续发现匹配准确率不足,再引入 rapidfuzz 替代。`difflib` 在评测规模(80 条用例 × ~5 条记忆)下性能可接受。 --- ## 6. 共享知识(跨文件约定) - **评测用例 YAML schema**:`case_id` 格式 `eval-{NNN}`(三位数字);`source ∈ {real, synthetic, edge}`;`difficulty ∈ {easy, medium, hard}`;`expected_l1.memories[].dimensions` 必须是注册表 id(英文 snake_case);`expected_l2.template_section` 的 key 是 mode 名(daily/coding/work/full),value 是 `{memory_index: section_title}` 映射 - **评测 DB 隔离**:eval 使用 `eval/tmp/eval_memory.db` + `eval/tmp/eval_wiki.db`(独立数据目录),每次 `run_all()` 启动时 `init_databases(eval_tmp_dir)` 创建干净 DB,run 结束后可选清理(`--keep-db` 保留);绝不碰生产 `data/memory.db` 或 `data/wiki.db` - **提示词版本钉版**:`--prompt-version vNNN` 映射为 `BucketCtx(overrides={"l1_extraction": "versions/l1_extraction/vNNN.txt", "l1_conflict": ..., "l2_scene": ...})`;不指定时走 manifest active(默认 @working) - **refine_runs 复用**:评测每次 L1/L1.5/L2 调用自动记录 refine_run(engine 层已实现),`file_id` 填 `case_id`(形如 `eval-001`);A/B 评测时同一 case 会产生两条 refine_run(version 不同),便于追溯 - **记忆匹配相似度阈值**:`_content_similarity()` 阈值 0.5(stdilb `SequenceMatcher.ratio()`),低于阈值视为不匹配;可在 `eval/engine/metrics.py` 中通过 `MATCH_THRESHOLD` 常量调整 - **维度归一化一致性**:评测使用与生产相同的 `normalize.normalize_batch()` + `alias_map` + `registry_names`,确保预测维度 id 与 GT 维度 id 在同一命名空间 - **报告格式**:`report.json` 字段英文(可机器消费);`report.md` 中文(人类可读);`per_case.csv` 表头英文 - **CLI 退出码**:全部 P0 指标达标 → `exit(0)`;任一不达标 → `exit(1)`(用于 CI 集成) - **TDD 铁律**:测试文件先写(单测 + mock LLM),后写实现;mock 全绿后跑真实 LLM 冒烟 --- ## 7. 任务分解(有序,含依赖) > 实现顺序 T01 → (T02 ∥ T03) → T04 → T05;每个任务包含 >=3 个文件,总计 5 个任务(硬性上限)。 | ID | 任务 | 源文件 | 依赖 | 优先级 | |---|---|---|---|---| | **T01** | 项目基础设施:设计文档 + 评测包骨架 + 数据模型 | `docs/design/SGME-评测框架设计-v0.1.md`、`docs/design/eval-class-diagram.mermaid`、`docs/design/eval-sequence-diagram.mermaid`、`eval/__init__.py`、`eval/engine/__init__.py`、`eval/engine/models.py`、`eval/cases/.gitkeep`、`eval/results/.gitkeep`、`eval/tmp/.gitkeep`、`AGENTS.md`(文档索引修订) | 无 | P0 | | **T02** | 评测数据层:用例加载器 + 度量计算器 | `eval/engine/loader.py`、`eval/engine/metrics.py`、`eval/cases/v001_sample.yaml`(5 条样本)、`tests/test_eval_loader.py`、`tests/test_eval_metrics.py` | T01 | P0 | | **T03** | 评测流水线:Runner + 报告生成器 | `eval/engine/runner.py`、`eval/engine/reporter.py`、`eval/run.py`、`tests/test_eval_runner.py`、`tests/test_eval_reporter.py` | T02 | P0 | | **T04** | RRF 调优 + A/B 比对 | `eval/engine/rrf.py`(NDCG + 网格搜索接口)、`eval/engine/ab.py`(A/B 差分)、`tests/test_eval_rrf.py`、`tests/test_eval_ab.py` | T01 | P1 | | **T05** | 集成验证:全链路冒烟 + 首版评测集生成 | `eval/cases/v001.yaml`(首版 50+ 条标注用例)、`scripts/eval_ci.py`(CI 集成脚本)、`tests/test_eval_integration.py`(真实 LLM 冒烟) | T03, T04 | P1 | --- ## 8. 任务依赖图 ```mermaid graph LR T01[T01 项目基础设施
设计文档+包骨架+数据模型] --> T02[T02 评测数据层
用例加载器+度量计算器] T01 --> T04[T04 RRF调优+A/B比对
NDCG+差分报告] T02 --> T03[T03 评测流水线
Runner+报告生成器+CLI] T03 --> T05[T05 集成验证
全链路冒烟+首版评测集] T04 --> T05 ``` --- ## 9. 待明确事项 1. **内容相似度匹配阈值**:当前设为 0.5(difflib.SequenceMatcher.ratio),是否需要在首版评测集建成后用人工评估校准?建议在 T05 集成验证阶段跑 5 条样本,人工检查匹配对质量后调整。 2. **记忆匹配的 Hungarian 算法 vs 贪心匹配**:首版使用贪心最大匹配(简单、可解释);若发现匹配冲突率高(同一 GT 记忆被多个预测竞抢),后续迭代可引入 Hungarian 算法。当前评测规模下贪心匹配质量可接受。 3. **L2 模板查询 section 匹配粒度**:当前设计按 section.title 精确匹配。若模板改了 title(如 "基本信息" → "👤 基本信息"),GT 中的 `template_section` 需同步更新。是否考虑用 section 的 `query.dimensions` 作为更稳定的匹配锚点?建议首版用 title,若变更频繁再改用 dimensions。 4. **eval/tmp/ 目录的 .gitignore**:`eval/tmp/` 下的 SQLite 文件不入 git,但需确认项目 `.gitignore` 已包含 `*.db` 规则(当前 `AGENTS.md` 约束 #2 已声明 `*.db` 不入 git)。 5. **RRF 评测子集的 ground truth 格式**:`expected_search_results` 字段尚未在用例结构中定义(因 /search 未实现)。建议在 `/search` 实现时同步补充该字段,标注格式为 `{query_text: [relevant_memory_ids_in_order]}`。 --- ## 10. 关键设计决策(5 条) 1. **独立 eval DB,零生产污染**:评测使用 `eval/tmp/` 下的独立 SQLite 数据库(`init_databases(eval_tmp_dir)`),与生产 `data/` 目录物理隔离。每次 run 从干净 DB 开始,绝不碰生产数据。 2. **内容相似度记忆匹配**:L1 F1 计算依赖将 LLM 输出的记忆与 ground truth 记忆配对。采用 difflib 内容相似度 + 贪心最大匹配,而非顺序匹配——因为 LLM 输出顺序不稳定。 3. **复用 normalize.py 保证维度体系一致**:评测预测维度通过与生产相同的 `normalize.normalize_batch()` 归一化到注册表 id,确保与 ground truth(已在标注时使用注册表 id)在同一命名空间下比较。 4. **RRF 评估接口预留、NDCG 计算先独立**:RRF 评估的 `GridSearch.search()` 在 `/search` 实现前抛 `NotImplementedError`,但 `_compute_ndcg()` 作为独立函数可被测试和验证。评测框架不阻塞 `/search` 的开发节奏。 5. **A/B 裁判不自动裁决**:与 #33 设计一致——评测框架只产出 A/B 差分数据(Δ F1、Δ 命中率),版本优劣结论留人工判断。`--compare` 模式读两份 report.json 纯离线对比,不重新跑提炼。 --- *文档完。v0.1 初版,待团队评审后修订。*