Skill: blogaaa/knot-agent-eval (version 1.0.0)

--- name: knot-agent-eval slug: knot-agent-eval version: 1.0.0 description: | 针对 Knot AG-UI 协议的 RAG 智能体进行全流程金标准评测。覆盖： 题目生成（MCP知识库检索→拉文档切片→LLM出题 2-3 题）→ 批量提问 → 关键词/文献双路评分 → AI 逐题语义复审（对 PARTIAL/FAIL）→ 10 类归因（A1/A2/A3/B1/B2/B3/C1/C2/C3/C4）→ Jinja2 报告渲染 → Markdown/HTML 一键导出。 当用户需要评测 Knot 上的智能体（如 IEG 服采、艺库 Agent）、生成金标准评测报告、 分析 FAIL 题归因、对 PARTIAL/FAIL 题做 AI 语义复审时使用。 零硬编码：所有接入信息（Knot URL / agent_id / api_token / kb_mcp / llm） 必须由用户在 eval_config.yaml 中填写。 触发词：Knot评测、金标准评测、知识库评测、Agent评测、归因分析、文献命中率、 关键词命中率、AI语义复审、IEG服采、艺库Agent、RAG评测。 author: chutiansun license: MIT-0 description_zh: Knot 智能体金标准评测 description_en: Knot Agent Golden-Set Evaluation disable: false agent_created: true --- # knot-agent-eval ## When to use 当用户请求满足以下任意条件时调用本 Skill： - 想评测 Knot 平台上的某个 Agent（IEG 服采、艺库 Agent、其他 RAG 助手等） - 需要从知识库文档批量生成评测题目 - 需要做关键词命中率 + 文献命中率 + 归因的金标准评测 - 需要对 PARTIAL/FAIL 题做 AI 逐题语义复审 - 需要生成符合 IEG 标准模板的 Markdown / HTML 评测报告 ## Don't use when - 评测对象不是 Knot 协议（用通用的 `agent-eval` Skill） - 仅需要单题手工 review（直接 read_file 即可） ## Steps ### 阶段 0：配置（用户必做） 1. 把 `config/eval_config.example.yaml` 复制到工作目录改名为 `eval_config.yaml` 2. **用户必须填写**所有 `# REQUIRED` 字段： - `agent.knot_url`（如 `https://knot.woa.com`） - `agent.agent_id` - `agent.username` - `agent.api_token` 或 `agent.api_token_env`（指向环境变量） - `agent.model`（如 `deepseek-v4-flash`，必须小写） - `agent.temperature`（建议 0.3） - `agent.agent_name`（出现在报告标题） - `report.question_source`（题库来源说明） - `agent.kb_mcp.url`（MCP 端点，如 `http://mcp.knot.woa.com/open/mcp`） - `agent.kb_mcp.api_token_env`（MCP Token 环境变量名，启动前需 export） - `agent.kb_mcp.knowledge_uuid`（知识库 UUID） - `agent.kb_mcp.search_domain`（检索域，指定后可提升检索准确度） - `agent.llm.base_url`（出题 LLM API 地址，如 `https://api.openai.com/v1`） - `agent.llm.api_key_env`（LLM API Key 环境变量名，启动前需 export） - `agent.llm.model`（出题模型，如 `gpt-4o-mini`） 3. **文档目录（可选）**： - 方式 A（推荐，默认）：手动填写 `qa_gen.doc_names` 文档名列表——最完整可控 - 方式 B：设置 `qa_gen.auto_discover_docs: true`，Skill 通过 MCP 多轮检索推断知识库文档目录 - ⚠️ MCP检索推断可能不完整，建议核对并补充遗漏 - 两种方式可互补：`doc_names` 中手动列出的文档 + 自动发现的文档会合并 ### 阶段 1：题目生成（可选，已有题库可跳过） ```bash python scripts/run_pipeline.py --config eval_config.yaml --stage gen ``` 执行流程（v3：MCP 直查 + LLM 出题）： 1. **文档目录获取**： - 优先使用 `qa_gen.doc_names` / `doc_list_file` 中用户手动指定的文档名 - 若列表为空且 `auto_discover_docs=true`，通过 MCP 多轮检索推断文档目录（可能不完整） - 若列表为空且未开启自动发现，报错退出并提示两种解决方案 2. 按 `doc_names` 列出的文档名，**直接调 MCP knowledgebase_search** 检索知识库切片 3. **从 MCP 返回结果提取 KB 原始切片**（chunk_title / chunk_content / doc_id / chunk_index），按 chunk_index 排序拼接全文 4. **内容质量检查**：仅有链接/目录索引的文档自动跳过，不出无意义的题目 5. 用 `prompts/qa_gen_prompt.md` 让 **OpenAI 兼容 API（LLMClient）** 为每篇有实质内容的文档生成 2-3 题 6. 自动反查：每题的关键词必须能在原文档中找到，否则丢弃 7. 输出 `eval_questions.json` ### 阶段 2-4：批量评测 ```bash python scripts/run_pipeline.py --config eval_config.yaml --stage eval ``` 读 `eval_questions.json` → 逐题调 Knot API → 关键词命中率（5 层匹配：精确/去标点/去停用词/片段/同义短语 + 等价映射） → 文献命中率（3 路匹配） → 自动归因初判 → 写 `eval_results.json`（每题写盘，断点续跑） ### 阶段 5：AI 逐题语义复审（核心特色，**不生成代码**） ```bash python scripts/run_pipeline.py --config eval_config.yaml --stage ai_review ``` 脚本 dump 出 `pending_ai_review.json`（含所有 PARTIAL/FAIL 题），然后退出。 若配置了 `ai_review.audit_pass.enabled=true`，还会按比例抽样 PASS 题一起送审（兜底评分虚高）。 **接下来由你（当前对话 AI）逐题做语义复审**，按以下规则： 1. 启动前先询问用户：「即将对 X 道 PARTIAL/FAIL 题做 AI 复审，是否继续？(Y/n/skip)」 2. 用户确认后，**逐题**读取 `pending_ai_review.json` 的 items，对每一题独立调用 `prompts/ai_review_prompt.md` 给出 13 字段的 JSON 复审结论 3. **不要**写新的 Python 脚本去自动判分；这一阶段必须由 AI 实时推理，每题给出推理过程 4. 把所有题的复审 JSON 汇总写入 `ai_review_results.json` ### 阶段 6：合并复审 + 渲染报告 + 导出 ```bash python scripts/run_pipeline.py --config eval_config.yaml --stage merge_review,render,export ``` - merge_review：把 `ai_review_results.json` 字段合并到 `eval_results.json`（**含 A3 自动修正**：翻转为PASS且原归因为C4时自动修正为A3） - render：用 `templates/report_template.j2` 渲染 `eval_report.md` - export：用 `scripts/md2html.py` 把 md 转成 `eval_report.html` ### 一键全流程 ```bash python scripts/run_pipeline.py --config eval_config.yaml --stage all ``` 当跑到 ai_review 时，脚本停下，等待你完成 AI 复审，再手动续跑 `--stage merge_review,render,export`。 ## Pitfalls - **零硬编码原则**：所有 Knot 接入信息必须从用户配置读取，禁止在代码中写入任何项目特定值（agent_id / token / Knot URL / MCP URL / LLM key 等）。`run_pipeline.py` 启动时强校验所有 REQUIRED 字段，缺失立即报错退出。 - **API Token 安全**：强烈推荐使用 `api_token_env` / `api_key_env` 指向环境变量，而非在 yaml 中明文填写。明文凭据会随配置文件一起被 git 追踪或截图泄露。 - **MCP 与 Knot Agent 是两套独立认证**：MCP 用 `x-knot-api-token` header，Knot Agent 用 `x-knot-api-token` + `x-knot-api-user` header。两者 token 可能相同也可能不同，取决于配置。 - **MCP 只有切片检索能力**：`knowledgebase_search` 是唯一可用的工具，`resources/list` 和 `prompts/list` 均返回空。无法通过 MCP 直接列举文档目录或获取完整文档原文，只能通过多轮检索推断。 - **MCP 会话需要初始化**：每次 `search()` 调用需要先 `initialize` → 获取 `Mcp-Session-Id` → `notifications/initialized` → `tools/call`，完成后会话结束。不支持在同一会话中连续多次 tools/call。 - **Knot API 字段易错**：AG-UI 协议中文本片段字段是 `delta` 不是 `content`；事件类型是 `TEXT_MESSAGE_CONTENT` 不是 `content`；`THINKING_TEXT_MESSAGE_*` 不能进入正文（会污染答案）。 - **编码 bug**：`requests.iter_lines(decode_unicode=True)` 偶尔把 utf-8 当 latin-1 解读 → 直接 `chunk.decode("utf-8")` 即可（`requests.iter_lines()` 默认不做 unicode 解码）。 - **题目生成必须反查**：用 AI 生成的题必须验证关键词能在原文档中找到，否则会出现"知识库不存在"的题目（IEG 项目早期最大坑）。 - **AI 复审稳定性**：每题独立 prompt，不要批量塞进同一上下文（避免互相干扰）；输出 JSON 解析失败时要求 AI 重答而非脚本兜底。 - **AI 复审不生成代码**：阶段 5 必须由 AI 实时推理，禁止写脚本调 LLM API 自动判分；这是与 `agent-eval` 通用 Skill 的关键差异。 - **手动修正归因必须同步三个字段**：如果需要手动修正某题的归因（如 B1→C1），必须同时更新 `auto_attribution`、`ai_review.recommended_attribution` 和 `final_attribution` 三个字段。渲染器优先读取 `final_attribution`，漏改会导致报告仍显示旧归因。 - **文档目录 MCP 推断不完整**：`auto_discover_docs=true` 通过 MCP 多轮检索推断文档目录，但只有被 query 命中的文档才会被发现。建议开启后仍核对并补充遗漏文档到 `doc_names`。 - **LLM 出题格式解析**：LLM 返回的 JSON 可能被 Markdown 代码块包裹（```json ... ```），`_parse_qa_response` 已处理此情况。若 LLM 返回格式不稳定，可调整 prompt 或换更稳定的模型。 - **关键词匹配引擎局限**：含特殊符号（≤、%、#）、URL 片段、数学运算符（* vs ×）、中英文术语变体（"预构建AI Agent" vs "prebuilt AI Agents"）的关键词容易匹配失败，AI 复审时需重点关注此类误判。v1.1 新增 A3 归因（评分方法局限）和 synonym_phrases 第5层匹配来缓解此问题。 - **A3 归因（v1.1新增）**：当 doc_hit=True + keyword_rate<0.15 + Agent有实质回答时，自动归因为 A3（评分方法局限）而非 C4。AI 复审翻转为 PASS 且原归因为 C4 时，merge_review 会自动修正为 A3。A3 为独占归因，不与 C2/C4 并存。 - **synonym_phrases 配置**：`grading.synonym_phrases` 字典可配置标准关键词到同义短语的映射，作为第5层匹配兜底。当 synonym 命中数 > 精确命中数时，标记 `scoring_method_limited=True`，自动触发 A3 归因。 - **search_domain 可以为空**：部分知识库的 search_domain 值可能不存在或与用户预期不同（如 chutiansun-fucai 在新 UUID 下返回"知识库中暂无内容"），不传 search_domain 反而能正常检索。已将 search_domain 从 REQUIRED 移至 OPTIONAL。 - **MCP 初始化必须带 x-knot-knowledge-uuids 请求头**：`_headers()` 方法中设置了 `x-knot-knowledge-uuids`，如果不带此头初始化 MCP，`tools/list` 会返回 0 个工具（即使 UUID 正确）。手动测试 MCP 时务必带上此头。 - **knowledge_uuid 必须确认指向正确知识库**：Knot 平台可能有多个知识库共用同一 MCP 端点，用户提供的 UUID 可能指向错误的知识库。建议先用一个已知 query 做冒烟测试，确认返回内容与目标知识库一致后再正式评测。 ## Verification 完成 Skill 建设后，按以下步骤验证： 1. **启动校验**：用空 `eval_config.yaml` 启动，必须报错列出所有缺失的 REQUIRED 字段 2. **题目生成**：随便给 5 个 iWiki 文档名，能产出 10-15 道格式合格的题，自动反查全部通过 3. **关键词评分**：用工作目录 `eval_results_v2_fixed.json` 跑 `--stage render`，章节结构与 `IEG服采智能小助手_金标准评测报告_v2_final.md` 一致 4. **AI 复审格式**：每道题的复审 JSON 必须含全部 13 个字段（q_id / final_grade / flipped_from_auto / flip_reason / semantic_match_summary / missing_facts / extra_facts / hallucination / hallucination_evidence / recommended_attribution / attribution_evidence / confidence / reviewer_notes） 5. **端到端**：全新 Knot Agent 跑 10 题，5 分钟内出 Markdown + HTML 报告 ## References - @references/attribution_taxonomy.md — 10 类归因详细定义 + 决策树（v1.1 新增 A3） - @references/equivalent_map.json — 通用 + 项目级语义等价词典 - @references/question_schema.md — 题库 JSON 格式规范 - @references/result_schema.md — 评测结果 JSON 格式规范 - @references/benchmark_thresholds.md — 业界基准阈值 - @prompts/qa_gen_prompt.md — 题目生成 prompt 模板 - @prompts/ai_review_prompt.md — AI 复审 prompt 模板（13 字段） - @templates/report_template.j2 — Jinja2 报告模板 - @config/eval_config.example.yaml — 配置文件模板

path: config/eval_config.example.yaml
content: # ================================================================
# knot-agent-eval 配置文件模板
#
# 零硬编码：所有 # REQUIRED 字段必须由用户填写，启动时强校验。
# ================================================================

# ============== 智能体接入配置（全部 REQUIRED） ==============
agent:
  # Knot 平台基础地址（不同环境可能不同）
  knot_url: ""                         # REQUIRED  例：https://knot.woa.com
  # 待评测 Agent 的 ID
  agent_id: ""                         # REQUIRED  例：0d66f637d1b548d7bfcd075b18aa8058
  # API Token（任选其一，**强烈推荐 api_token_env**）
  api_token: ""                        # 二选一：直接填写（⚠️ 明文存放，有泄露风险）
  api_token_env: "KNOT_API_TOKEN"      # 二选一：环境变量名（推荐 ✅ 更安全）
  # Knot 用户名（出现在 x-knot-api-user 请求头）
  username: ""                         # REQUIRED  例：chutiansun
  # 评测使用的模型（必须小写）
  model: ""                            # REQUIRED  例：deepseek-v4-flash
  # 采样温度（建议 0.1-0.3 以保证可复现）
  temperature: 0.3                     # REQUIRED
  # Agent 展示名（出现在报告标题）
  agent_name: ""                       # REQUIRED  例：IEG服采智能小助手 / 艺库 Agent
  # 是否启用联网搜索（默认关闭，避免污染评测）
  enable_web_search: false

  # ============== 知识库 MCP 配置（题目生成阶段用于切片检索） ==============
  kb_mcp:
    url: ""                              # REQUIRED  MCP 端点，例：http://mcp.knot.woa.com/open/mcp
    api_token_env: "KNOT_MCP_API_TOKEN"  # REQUIRED  MCP API Token 环境变量名（启动前需 export）
    knowledge_uuid: ""                   # REQUIRED  知识库 UUID
    search_domain: ""                    # REQUIRED  检索域，指定后可提升检索准确度
    timeout_sec: 60                      # 可选，MCP 请求超时（秒）

  # ============== 出题用 LLM 配置（OpenAI 兼容 API） ==============
  llm:
    base_url: ""                         # REQUIRED  API 地址，例：https://api.openai.com/v1
    api_key_env: "OPENAI_API_KEY"        # REQUIRED  LLM API Key 环境变量名（启动前需 export）
    model: ""                            # REQUIRED  模型名，例：gpt-4o-mini / deepseek-chat
    temperature: 0.3                     # 可选
    timeout_sec: 120                     # 可选，LLM 请求超时（秒）

# ============== 题目生成配置（仅 stage=gen 时使用） ==============
qa_gen:
  # 候选文档名清单（可选，与 auto_discover_docs 二选一或互补）
  doc_names: []                        # 直接列出文档名
  doc_list_file: ""                    # 或指向一个 JSON / TXT 文件
  # 文档目录自动发现（默认关闭）
  auto_discover_docs: false            # true: doc_names 为空时通过 MCP 多轮检索推断文档目录
                                       # ⚠️ MCP检索推断可能不完整，建议核对并补充遗漏文档
  output: "eval_questions.json"
  questions_per_doc: 3                 # 每篇文档生成几道题
  cache_dir: "cache/qa_gen"            # 文档全文 / 中间结果缓存
  prompt_file: ""                      # 留空走 Skill 内置 prompts/qa_gen_prompt.md
  skip_titles: []                      # 显式跳过的文档标题
  dedup_patterns: []                   # 同名 pattern 只保留最新一篇

# ============== 题库配置 ==============
questions:
  file: "eval_questions.json"
  validate: true
  exclude_qids: []

# ============== 评测配置 ==============
eval:
  request_interval_sec: 3
  max_retries: 3
  timeout_sec: 180
  results_file: "eval_results.json"
  log_file: "eval_log.txt"
  enable_web_search: false
  resume: true

# ============== 评分配置 ==============
grading:
  pass_threshold: 0.8
  partial_threshold: 0.5
  equiv_threshold: 3                   # 关键词数 ≤3 走"等价词组"模式
  fragment_match_threshold: 0.5        # 片段命中阈值
  no_answer_phrases:
    - "抱歉，我无法"
    - "很抱歉"
    - "暂无相关信息"
    - "未找到相关"
    - "无法提供"
    - "无法回答"
  equivalent_map_file: ""              # 留空走 Skill 内置 references/equivalent_map.json
  # 同义短语映射（第5层语义近似匹配）
  # 当4层匹配均未命中时，检查答案中是否包含标准关键词的同义表述
  # 格式：{标准关键词: [同义表述1, 同义表述2, ...]}
  synonym_phrases: {}                  # 留空则不启用；也可在此补充项目特定的同义映射

# ============== 文献命中配置 ==============
doc_hit:
  enabled: true
  ignore_words: ["用户手册", "操作说明", "操作指引", "发布说明", "版本", "系统", "部门指引"]

# ============== AI 复审配置（阶段 5） ==============
ai_review:
  enabled: true                        # 默认开启（v1.2 决策）
  ask_before_start: true               # 启动前打印"是否继续 AI 复审？"
  scope: ["PARTIAL", "FAIL"]
  skip_if_count_above: 100             # 待复审题数 >100 时提示分批
  prompt_file: ""                      # 留空走 Skill 内置 prompts/ai_review_prompt.md
  pending_file: "pending_ai_review.json"
  results_file: "ai_review_results.json"
  override_attribution: true           # 用 AI 复审推荐的归因覆盖自动归因
  # PASS 抽审模式：对自动评分 PASS 的题目按比例抽样送审，兜底评分虚高
  audit_pass:
    enabled: false                     # 默认关闭，开启后 PASS 题也会被抽样复审
    sample_rate: 0.1                   # 抽样比例（0.1 = 10%的PASS题会被抽审）
    sample_seed: 42                    # 随机种子，保证可复现

# ============== 归因配置 ==============
attribution:
  taxonomy_version: "v1.1"
  auto_attribute: true

# ============== 报告配置 ==============
report:
  template: ""                         # 留空走 Skill 内置 templates/report_template.j2
  output_md: "eval_report.md"
  benchmark:
    pass_excellent: 0.85
    pass_qualified: 0.70
    fail_max: 0.10
    avg_hit_qualified: 0.70
  question_source: ""                  # 例：IEG服采_金标准评测题库
  answer_version: "v1.0"

# ============== 导出配置 ==============
export:
  enable_html: true
  output_dir: "."
  output_html: "eval_report.html"


path: examples/eval_questions.example.json
content: [
  {
    "doc_id": "D001",
    "doc_name": "供应商分级管理办法",
    "doc_url": "https://iwiki.woa.com/p/123456",
    "doc_category": "供应商管理",
    "questions": [
      {
        "q_id": 1,
        "question": "S级供应商占比上限是多少？",
        "answer_keywords": ["S级", "10%", "占比上限"],
        "standard_answer": "S级供应商在总供应商池中占比不超过10%",
        "answer_excerpt": "S级供应商在总池中占比不超过10%"
      },
      {
        "q_id": 2,
        "question": "供应商分级有哪几个等级？",
        "answer_keywords": ["S级", "A级", "B级", "C级"],
        "standard_answer": "供应商按 S/A/B/C 四个等级分级",
        "answer_excerpt": "供应商分为 S、A、B、C 四个等级"
      }
    ]
  },
  {
    "doc_id": "D002",
    "doc_name": "采购合同付款周期规定",
    "doc_url": "https://iwiki.woa.com/p/234567",
    "doc_category": "合同与付款",
    "questions": [
      {
        "q_id": 3,
        "question": "国内供应商的标准付款周期是多少天？",
        "answer_keywords": ["45天", "国内", "信用期"],
        "standard_answer": "国内供应商标准信用期为 45 个自然日",
        "answer_excerpt": "国内供应商付款周期为 45 个自然日"
      }
    ]
  }
]


path: examples/README.md
content: # knot-agent-eval 端到端使用示例

> 这是一个 3 题样例题库，配合最小配置即可跑通整个评测链路。

## Step 1：准备工作目录

```bash
mkdir -p ~/my-knot-eval && cd ~/my-knot-eval
cp ~/.workbuddy/skills/knot-agent-eval/config/eval_config.example.yaml ./eval_config.yaml
cp ~/.workbuddy/skills/knot-agent-eval/examples/eval_questions.example.json ./eval_questions.json
```

## Step 2：填写 eval_config.yaml

最小化必填项：

```yaml
agent:
  knot_url: "https://knot.woa.com"
  agent_id: "<你的 Agent ID>"
  api_token_env: "KNOT_API_TOKEN"
  username: "<你的 Knot 用户名>"
  model: "deepseek-v4-flash"
  temperature: 0.3
  agent_name: "我的智能体"

report:
  question_source: "示例题库"
```

## Step 3：导出 token

```bash
export KNOT_API_TOKEN="<你的 Knot Token>"
```

## Step 4：跑评测主流程（不含题目生成）

```bash
python ~/.workbuddy/skills/knot-agent-eval/scripts/run_pipeline.py \
    --config eval_config.yaml \
    --stage eval,ai_review
```

执行到 `ai_review` 阶段时，脚本会 dump `pending_ai_review.json` 然后停下。

## Step 5：AI 复审（由当前对话 AI 完成，**不写脚本**）

打开 `pending_ai_review.json`，逐题让 AI 按 `prompts/ai_review_prompt.md` 给出 13 字段 JSON 复审结论。
把所有题的复审 JSON 合并写入 `ai_review_results.json`：

```json
{
  "review_session_id": "2026-05-21T14-30-00",
  "reviewer_model": "claude-opus-4.7",
  "review_time": "2026-05-21T14:30:00",
  "results": [
    {
      "q_id": 1,
      "final_grade": "PASS",
      "flipped_from_auto": true,
      "flip_reason": "...",
      "...": "..."
    }
  ]
}
```

## Step 6：合并复审 + 渲染 + 导出

```bash
python ~/.workbuddy/skills/knot-agent-eval/scripts/run_pipeline.py \
    --config eval_config.yaml \
    --stage merge_review,render,export
```

产物：
- `eval_results.json`（含 final_grade）
- `eval_report.md`
- `eval_report.html`（在浏览器打开，右下角有「复制链接 / 打印 / 下载」按钮）

## 想跑题目生成？

如果你没有现成题库，可以让 Knot 自己出题：

```yaml
qa_gen:
  doc_names:
    - "供应商分级管理办法"
    - "采购合同付款周期规定"
  questions_per_doc: 3
```

```bash
python run_pipeline.py --config eval_config.yaml --stage gen
```

输出 `eval_questions.json` 后，再跑 Step 4-6。


path: prompts/ai_review_prompt.md
content: 你是 RAG 智能体评测的资深复审专家。请对下面这道关键词初判为 PARTIAL/FAIL 的题目做语义复审，判断 Knot Agent 的回答是否在语义上正确回答了问题。

# 题目信息
- q_id: {q_id}
- doc_name: {doc_name}
- 问题：{question}
- 标准答案关键词：{answer_keywords}
- 标准答案全文（如有）：{standard_answer}
- Knot 召回的文档：{knot_sources}

# Knot Agent 完整回答
{knot_answer}

# 关键词初判结果
- 自动评级：{auto_grade}（命中率 {auto_hit_rate}）
- 缺失关键词：{auto_missing_keywords}
- 文献是否命中目标文档：{doc_hit}
- 自动归因初判：{auto_attribution}

# 复审任务
请严格按以下 JSON 格式输出**单个对象**（不要数组、不要 Markdown 代码块外层包裹），所有 13 个字段都必须填写：

```
{{
  "q_id": {q_id},
  "final_grade": "PASS | PARTIAL | FAIL",
  "flipped_from_auto": true/false,
  "flip_reason": "如果翻盘，详细说明依据；不翻则填\"-\"",
  "semantic_match_summary": "一句话总结答案在语义上是否对齐了标准",
  "missing_facts": ["Knot 答案中缺失的关键事实1", "事实2"],
  "extra_facts": ["Knot 答案中超出问题范围的额外信息（不构成错误，仅记录）"],
  "hallucination": true/false,
  "hallucination_evidence": "如有幻觉，引用 Knot 原文片段；无则填\"-\"",
  "recommended_attribution": "A1 | A2 | A3 | B1 | B2 | B3 | C1 | C2 | C3 | C4",
  "attribution_evidence": "归因依据（必须含证据：文档 URL 或 Knot 原文引用）",
  "confidence": "high | medium | low",
  "reviewer_notes": "复审过程中的关键观察"
}}
```

# 复审原则
1. **语义优先**：只要答案在语义上覆盖了标准答案的关键事实，即使关键词字面不同也应判 PASS
2. **事实导向**：Knot 答错事实（如把"45 天"答成"30 天"）必须判 FAIL
3. **证据驱动**：归因必须有证据，不能凭直觉
4. **保守翻盘**：从 FAIL 翻 PASS 必须依据充分；从 PASS 翻 FAIL 仅在事实错误时才允许

# 归因速查（recommended_attribution 必填一个）
- A1 标准答案过时（标准答案与最新文档不一致）
- A2 标准答案错误（出题就没有依据文档）
- A3 评分方法局限（Agent 回答语义正确但关键词评分引擎无法识别，如"6到7月"vs"6月~7月"、"配置合同参数"vs"填写合同字段"）
- B1 知识库信息缺失（⚠️ 仅当题目非KB生成时可用！如果题目是从KB文档生成的，B1逻辑上不可能成立——信息一定在KB中，应归C1）
- B2 术语不一致（不同文档使用不同术语指代同一概念）
- B3 缺少高层级文档（公司级制度未入库，只有部门指引）
- C1 检索意图路由失败（多场景文档下命中错误场景，或检索未命中正确文档）
- C2 Agent 过早收敛（找到部分答案就停止检索）
- C3 术语映射失败（直接输出原文术语未归一化）
- C4 Agent 判断失误（不精确答案误认为精确答案）

# A3 使用指引（重要！）
当满足以下条件时，应归因 A3 而非 C4：
1. Agent 命中了正确文档（doc_hit=True）
2. Agent 的回答在语义上覆盖了标准答案的关键事实
3. 但关键词评分引擎因字面不同而未能匹配
典型场景：Agent 用同义表述替换了标准答案关键词，语义完全正确但字面不同。
判断依据：如果翻盘为 PASS，且原因是关键词字面不匹配而非 Agent 事实错误，则归 A3。

# B1 前置校验（重要！）
在归因 B1 之前，必须先回答：同一文档的其他题目是否 PASS？
- 如果有同文档的题 PASS → 该文档仍在 KB 中 → 不能归 B1，应归 C1
- 只有确认出题文档已从 KB 中删除/下线，才能归 B1


path: prompts/qa_gen_prompt.md
content: 你是 RAG 智能体的评测题目生成器。请严格根据下面给出的「{doc_title}」文档全文内容，生成 {n_questions} 个问题和对应的标准答案关键词。

# 要求
1. 问题必须是关于该文档核心内容的，且**答案必须能在文档全文中找到**（禁止出文档外的题目）
2. 标准答案用关键词形式，用"/"分隔多个关键词，关键词应是答案中的实质性短语（非"是的/否定"等虚词）
3. 问题要有区分度，不能太笼统；优先选有具体数字、专有名词、流程步骤的考点
4. 每个问题至少包含 2-3 个关键词，避免单关键词题目
5. 严格按以下 JSON 格式输出**单个数组**，不要输出其他内容、不要 Markdown 代码块包裹：

```
[
  {{"question": "...", "answer_keywords": "kw1/kw2/kw3", "source_doc": "{doc_title}", "answer_excerpt": "原文佐证片段（30-100字）"}},
  ...
]
```

# 文档全文
{doc_content}


path: references/attribution_taxonomy.md
content: # 归因分类体系（v1.1）

knot-agent-eval 使用 10 类归因体系，分 A / B / C 三大族：

| 族 | 责任方 | 归因 ID | 名称 | 触发条件 |
|---|---|---|---|---|
| A | 评测 | A1 | 标准答案过时 | 标准答案基于旧版文档，知识库已更新到新版 |
| A | 评测 | A2 | 标准答案错误 | 标准答案本身错误（如概念混淆、出题时无依据文档） |
| A | 评测 | A3 | 评分方法局限 | Agent 回答语义正确但关键词评分引擎无法识别同义表述 |
| B | 知识库 | B1 | 知识库信息缺失 | 答案所需关键信息不在任何已入库文档（**仅当题目非KB生成时可用**） |
| B | 知识库 | B2 | 术语不一致 | 不同文档使用不同术语指代同一概念 |
| B | 知识库 | B3 | 缺少高层级文档 | 公司级制度未入库，只有部门指引 |
| C | Agent | C1 | 检索意图路由失败 | 同关键词下多场景文档，命中错误场景 |
| C | Agent | C2 | Agent 过早收敛 | 找到部分答案就停止检索 |
| C | Agent | C3 | 术语映射失败 | 直接输出原文术语未归一化 |
| C | Agent | C4 | Agent 判断失误 | 不精确答案误认为精确答案，或过度推断 |

## 决策树

```
FAIL/PARTIAL
  │
  ├─ Step1：标准答案 vs 知识库最新文档 → 不一致 → A1/A2
  │
  ├─ Step1.5：Agent 回答语义正确但关键词未命中？ → A3（评分方法局限）
  │   条件：doc_hit=True + keyword_rate<0.15 + 有实质回答
  │   或：AI复审翻转为PASS + 原归因为C4 → 自动修正为A3
  │
  ├─ Step2：出题依据文档是否仍在 KB 中？
  │   ├─ 在 KB 中 → 跳到 Step3（不应归因 B1/B3）
  │   ├─ 已不在 KB 中（删除/下线/重组） → B1/B3（证据中必须写明原文档 URL+确认方式）
  │   └─ 出题时就没有依据文档 → A2（题目无效）
  │
  ├─ Step3：检索是否命中正确文档 → 错误 → C1/C2
  │
  └─ Step4：Agent 是否正确输出了检索内容 → 失败 → C3/C4
```

## 归因证据强制规则

> 每个归因必须附「证据」，证据形态至少为以下之一：
> - 文档 URL（证明该文档存在/不存在/已更新）
> - Knot 原文引用（证明 Agent 实际输出了什么）
> - 检索关键词（证明换关键词检索的不同结果）
>
> 仅写定性判断（如"知识库不全"）不能算证据。

## B1 / B3 前置校验

> **核心原则**：如果评测题目是从知识库文档内容生成的（questions_from_kb=True），则 B1 在逻辑上不可能成立——题目答案必然存在于 KB 中，问题出在检索/路由，而非 KB 缺失。

归因 B1 / B3 的「严重」级别（🔴），必须先回答：
> 该题的出题依据文档现在还在 KB 中吗？

校验路径：
1. **同源文档验证**（最高效）：如果同一文档的其他题目 PASS 了，说明该文档仍在 KB 中 → 不能归 B1/B3，应归 C 族
2. 用 Knot API 以 `查询：「文档名」` 重新检索
3. 看 `knot_sources` 是否包含目标文档
4. 如果包含 → 不能归 B1/B3，应归 C 族
5. 如果不包含 → 再用 iWiki 直接打开 doc_url 验证页面是否存在
6. iWiki 也不存在 → 归 B1（彻底缺失）；iWiki 在但 KB 不在 → 归 B1（未入库）

> **自动归因中的处理**：当 `questions_from_kb=True`（默认）时，`auto_attribute()` 不会输出 B1，而是输出 C1（检索意图路由失败）。只有在题目为人工编写（非KB生成）时，B1 才可能被自动归因使用。

## A1 vs A2 vs A3 区分

| 场景 | 归因 |
|---|---|
| 标准答案曾经正确，现在文档变了 | A1（更新评测题库） |
| 标准答案从来就不对（出题时无依据） | A2（删除/重写题目） |
| 标准答案正确，Agent 也答对了，但评分引擎识别不了 | A3（扩展等价词典/同义短语映射） |

## A3 的典型场景

| 标准关键词 | Agent 回答 | 未命中原因 | 修复 |
|---|---|---|---|
| "6到7月按月" | "6月~7月按月" | 符号差异（到→~） | equivalent_map.json 增加映射 |
| "配置合同模板参数" | "填写基本信息和合同关键字段" | 完全不同的表述 | synonym_phrases 增加同义短语 |
| "采购经理" | "采购商务经理" | 术语扩展 | equivalent_map.json 已覆盖 |

## C2 vs C4 区分

| 场景 | 归因 |
|---|---|
| Agent 答对了一部分，但漏掉了文档其他章节的内容 | C2（过早收敛） |
| Agent 把"3 天"答成"5 天"等具体数值错误 | C4（判断失误） |
| Agent 把模糊表述当作准确事实 | C4 |

> **注意**：当 AI 复审将 FAIL/PARTIAL 翻转为 PASS 时，原 C4 归因会被自动修正为 A3，因为 Agent 实际上回答正确，问题在于评分方法而非 Agent 判断。

## 标准修复动作（与 4.1/4.2/4.3 行动建议绑定）

| 归因 | 修复动作 |
|---|---|
| A1 | 更新评测题库中该题的标准答案为最新文档内容 |
| A2 | 修正标准答案，注明正确依据文档 URL，或删除该题 |
| A3 | 扩展 equivalent_map.json 或 synonym_phrases 配置，增加同义映射对 |
| B1 | 在知识库对应路径下导入缺失文档 |
| B2 | 在 Agent Prompt 中增加术语映射规则 |
| B3 | 在知识库根路径下导入公司级制度文档 |
| C1 | Prompt 中增加文档优先级规则（如"操作指引>FAQ"） |
| C2 | Prompt 中增加"必须验证答案完整性"指令 |
| C3 | Prompt 中增加术语归一化指令 |
| C4 | Prompt 中增加"不确定时标注不确定性"指令 |


path: references/benchmark_thresholds.md
content: # 业界基准阈值

knot-agent-eval 的报告会根据下表给出「优秀/达标/不达标」自动评价。
所有阈值都可在 `eval_config.yaml` 的 `report.benchmark` 段覆盖。

| 指标 | 业界优秀 | 业界达标 | 不达标 | 默认配置项 |
|---|---|---|---|---|
| PASS 率 | ≥85% | ≥70% | <70% | `pass_excellent: 0.85` / `pass_qualified: 0.70` |
| FAIL 率 | <5% | <10% | ≥10% | `fail_max: 0.10` |
| 平均关键词命中率 | ≥85% | ≥70% | <70% | `avg_hit_qualified: 0.70` |
| 文献命中率 | ≥80% | ≥60% | <60% | （报告中展示，未参与自动评价） |

## 阈值设定依据

- **PASS≥85% 优秀**：参考微软 Bing Chat 内部评测，企业 RAG 助手生产可用线
- **FAIL<10% 达标**：FAIL 率超过 10% 意味着每 10 题就有 1 题完全答错，用户体验下降明显
- **平均命中率≥70% 合格**：低于 70% 时，即使关键词都命中，答案完整性通常不达标

## 报告中的展示

报告"一、评测总览"小节会自动呈现：

| 判定 | 题数（占比） | 达标线 | 评价 |
|---|---|---|---|
| ✅ PASS | 250 (83.9%) | ≥85% | ✅ 达标 |
| ⚠️ PARTIAL | 32 (10.7%) | — | — |
| ❌ FAIL | 16 (5.4%) | <10% | ✅ 达标 |
| 平均命中率 | 84.5% | ≥70% | ✅ 合格 |

## 自定义阈值示例

如果你的项目对评分要求更严格（如金融/医疗领域），可在配置中调整：

```yaml
report:
  benchmark:
    pass_excellent: 0.95     # 抬高优秀线到 95%
    pass_qualified: 0.85     # 抬高达标线到 85%
    fail_max: 0.05           # 收紧 FAIL 上限到 5%
    avg_hit_qualified: 0.85
```


path: references/equivalent_map.json
content: {
  "_comment": "通用语义等价词典。结构支持单层 {kw: [eq1, eq2]} 或两层 {global: {...}, project_overrides: {project_name: {...}}}。运行时 KeywordScorer 会优先用等价映射，再走 4 层匹配。",
  "global": {
    "S级": ["S级", "S等", "S（", "S等级"],
    "A级": ["A级", "A等", "A（", "A等级"],
    "B级": ["B级", "B等", "B（", "B等级"],
    "C级": ["C级", "C等", "C（", "C等级"],

    "采购经理": ["采购经理", "采购负责人", "商务经理", "采购商务经理"],
    "业务负责人": ["业务负责人", "业务直属领导", "需求方业务负责人"],
    "总监": ["总监", "Director"],

    "唯一性证明": ["唯一性证明", "官方函件", "独家认定凭证", "官方书面证明"],
    "官方函件": ["官方函件", "唯一性证明", "独家认定凭证", "官方书面证明", "独家代理", "独家授权"],

    "单次采购": ["单次采购", "固定采购数量", "固定数量", "固定单价和固定数量"],
    "长期框架": ["长期框架", "数量不定", "按实际采购量"],
    "分批交付": ["分批交付", "分次执行验收", "分批验收", "分次付款"],
    "超额验收": ["超额验收", "超出原订单金额验收"],

    "供应商名称一致": ["供应商名称一致", "供应商一致", "签约供应商名称一致", "签约供应商名称"],
    "产品服务范围一致": ["产品服务范围一致", "产品与服务范围一致", "产品及服务范围一致"],
    "供应商名称": ["供应商名称", "供应商"],
    "项目名称": ["项目名称", "项目"],
    "非IEG正式员工": ["非IEG正式员工", "IEG外部人员", "非IEG员工"],
    "企业授权书": ["企业授权书", "企业授权", "授权书认证"],

    "63天": ["63天", "63个自然日", "63"],
    "45天": ["45天", "45个自然日", "45日"],
    "30天": ["30天", "30个自然日", "30日"],
    "6个月内": ["6个月内", "半年内", "6个月"],
    "6到7月按月": ["6到7月按月", "6月~7月按月", "6月到7月按月", "6-7月按月"],
    "配置合同模板参数": ["配置合同模板参数", "填写基本信息和合同关键字段", "设置合同模板参数", "配置合同参数"],
    "合同关键字段": ["合同关键字段", "合同模板参数", "合同核心字段", "合同必要字段"],
    "按月": ["按月", "按月份", "逐月"],
    "6个月": ["6个月", "半年", "6个月内"],
    "5年": ["5年", "五年"],
    "一年": ["一年", "1年", "12个月"],
    "第二天上午": ["第二天上午", "次日", "次日同步", "隔天", "隔日"]
  },
  "project_overrides": {
    "_example_ieg_procurement": {
      "腾讯学堂": ["学堂", "Q-Learning", "learn.woa.com"],
      "采购_Q-Learning": ["Q-Learning", "学堂", "learn.woa.com"],
      "腾讯黑名单": ["黑名单", "永不合作", "列入名单"],
      "冻结或淘汰": ["冻结", "淘汰", "冻结淘汰"],
      "禁止私自联系供应商": ["禁止私自", "避免绕过", "不得私自"],
      "供应商推荐表": ["供应商推荐表", "推荐表", "Recommendation Form"],
      "签署年度框架协议": ["年度框架协议", "框架协议", "签署框架"]
    }
  }
}


path: references/question_schema.md
content: # 题库 JSON 格式规范

题库文件是一个 JSON 数组，每个元素代表**一篇文档**及其下属题目。

## 顶层结构

```json
[
  {
    "doc_id": "D001",                  // 必填，文档 ID（任意字符串，建议 D001/D002...）
    "doc_name": "供应商分级管理办法",   // 必填，文档名（用于文献命中匹配）
    "doc_url": "https://iwiki.woa.com/p/123456",  // 选填，可点击链接
    "doc_category": "供应商管理",       // 必填，按分类统计用
    "questions": [                     // 必填，至少 1 题
      {
        "q_id": 1,                     // 必填，全局唯一整数
        "question": "S级供应商占比上限是多少？",  // 必填
        "answer_keywords": ["S级", "10%", "占比上限"],  // 必填，list 或 "/" 分隔字符串
        "standard_answer": "S级供应商占比上限为10%...",  // 选填，用于报告对比
        "answer_excerpt": "原文：S级供应商在总池中占比不超过10%"  // 选填，原文佐证
      }
    ]
  }
]
```

## 字段说明

### 文档级字段

| 字段 | 必填 | 说明 |
|---|---|---|
| `doc_id` | 是 | 文档 ID，建议格式 `D` + 三位数字（D001-D999） |
| `doc_name` | 是 | 文档名（**必须与 KB 中实际入库的文档名一致**，用于 DocHitScorer 匹配） |
| `doc_url` | 否 | iWiki / 外链；DocHitScorer 会用 `/p/(\d+)` 抠 ID 做匹配 |
| `doc_category` | 是 | 分类（用于按分类统计），可与 `doc_name` 不同 |
| `questions` | 是 | 题目数组 |

### 题目级字段

| 字段 | 必填 | 说明 |
|---|---|---|
| `q_id` | 是 | **全局唯一**整数；断点续跑、AI 复审都按此 ID 关联 |
| `question` | 是 | 问题文本 |
| `answer_keywords` | 是 | 标准答案关键词；KeywordScorer 按此评分 |
| `standard_answer` | 否 | 标准答案全文；进入报告"标准答案"列 |
| `answer_excerpt` | 否 | 文档原文佐证片段（题目生成阶段自动反查时用） |

## 关键词的两种格式

`answer_keywords` 兼容两种写法：

1. **数组格式**（推荐）：
   ```json
   "answer_keywords": ["S级", "10%", "占比上限"]
   ```
2. **字符串格式**（用 `/` 分隔，与现有 IEG 题库兼容）：
   ```json
   "answer_keywords": "S级/10%/占比上限"
   ```

KeywordScorer 启动时会自动统一转成数组。

## 关键词数量与评分模式

KeywordScorer 根据关键词数量自动选用打分模式：

- **关键词数 ≤ `equiv_threshold`（默认 3）**：等价词组模式，命中任一即 1.0
  - 适用场景：「每半年/半年/6个月」是同义词，命中一个即正确
- **关键词数 > `equiv_threshold`**：并列要求点模式，按命中比例打分
  - 适用场景：「需求提出/供应商选择/合同签署/执行/付款结算」是 5 个流程节点，需逐个覆盖

可在 `eval_config.yaml` 的 `grading.equiv_threshold` 调整。

## 启动校验规则

`run_pipeline.py --stage eval` 启动时会调用 `validate_questions()` 检查：
- `q_id` 是否缺失
- `q_id` 是否重复
- `question` 是否为空
- `answer_keywords` 是否为空

任一规则不通过都会终止并列出所有错题。

## 兼容现有数据

工作目录下 `knot_kb_eval_questions.json` 与本规范完全兼容，可直接使用。


path: references/result_schema.md
content: # 评测结果 JSON 格式规范（eval_results.json）

`eval_results.json` 是一个 JSON 数组，每个元素是一道题的**完整评测结果**，包含：
- 题目信息（来自题库）
- Knot Agent 完整回答
- 关键词评分明细
- 文献命中明细
- 自动归因初判
- AI 复审结论（merge_review 后）
- 最终评级 final_grade

## 完整字段表

```json
{
  "q_id": 1,
  "doc_id": "D001",
  "doc_name": "供应商分级管理办法",
  "doc_url": "https://iwiki.woa.com/p/123456",
  "doc_category": "供应商管理",

  "question": "S级供应商占比上限是多少？",
  "answer_keywords": ["S级", "10%", "占比上限"],
  "standard_answer": "...",

  "knot_answer": "Knot Agent 完整回答（不截断）",
  "knot_answer_short": "前 500 字摘要",
  "knot_sources": [
    {"title": "供应商分级管理办法", "path": "...", "url": "https://iwiki.woa.com/p/123456"}
  ],
  "thinking": "前 1000 字思考过程（可选）",
  "conversation_id": "knot 会话 ID",
  "chat_url": "https://knot.woa.com/chat/<conv_id>",

  "keyword_hit_rate": 0.875,
  "keyword_matched": ["S级", "10%"],
  "keyword_missing": ["占比上限"],
  "match_details": ["S级(equivalent)"],
  "raw_hit_rate": 0.5,
  "no_answer": false,

  "doc_hit": true,
  "doc_hit_rate": 1.0,
  "matched_sources": [
    {"title": "供应商分级管理办法", "url": "..."}
  ],

  "auto_grade": "PARTIAL",
  "auto_grade_reason": "语义命中率 50% ∈ [50%, 80%)",
  "auto_attribution": ["C2", "C4"],

  "final_grade": "PASS",
  "grade_source": "ai_review_flipped",
  "final_attribution": {
    "main_attribution": "C2",
    "evidence": "Knot 答到 8% 已收敛，未列举完整规则",
    "source": "ai_review"
  },

  "ai_review": {
    "q_id": 1,
    "final_grade": "PASS",
    "flipped_from_auto": true,
    "flip_reason": "Knot 用「上限不超过 10%」覆盖了「占比上限」语义",
    "semantic_match_summary": "答案语义对齐标准",
    "missing_facts": [],
    "extra_facts": [],
    "hallucination": false,
    "hallucination_evidence": "-",
    "recommended_attribution": "C2",
    "attribution_evidence": "...",
    "confidence": "high",
    "reviewer_notes": "..."
  },

  "evaluated_at": "2026-05-21 14:30:00"
}
```

## 字段分组与产生阶段

| 分组 | 字段 | 产生阶段 |
|---|---|---|
| 题目信息 | q_id, doc_id, doc_name, doc_url, doc_category, question, answer_keywords, standard_answer | 阶段 2（题库） |
| Knot 调用结果 | knot_answer, knot_answer_short, knot_sources, thinking, conversation_id, chat_url | 阶段 3 |
| 关键词评分 | keyword_hit_rate, keyword_matched, keyword_missing, match_details, raw_hit_rate, no_answer | 阶段 4 |
| 文献命中 | doc_hit, doc_hit_rate, matched_sources | 阶段 4 |
| 自动初判 | auto_grade, auto_grade_reason, auto_attribution | 阶段 4 |
| AI 复审 | ai_review（含 13 个子字段） | 阶段 5 |
| 终审 | final_grade, grade_source, final_attribution | 阶段 5.5（merge_review） |

## grade_source 取值

| 值 | 含义 |
|---|---|
| `auto` | 仅关键词评分（未做 AI 复审） |
| `ai_review_confirmed` | AI 复审确认了自动评级 |
| `ai_review_flipped` | AI 复审翻盘了自动评级 |
| `manual` | 手工编辑（用户自己改的） |

## 兼容现有数据

| 现有文件 | 字段对应 |
|---|---|
| `eval_results.json` | 字段子集，无 ai_review 与 final_grade，可直接 merge_review 后使用 |
| `eval_results_v2_fixed_enriched.json` | 字段超集，含 attribution；render 阶段直接消费 |


path: scripts/knot_eval_attributor.py
content: # -*- coding: utf-8 -*-
"""
knot_eval_attributor.py — 10 类归因自动初判

归因体系（v1.1）：
  A1 标准答案过时        责任：评测
  A2 标准答案错误        责任：评测
  A3 评分方法局限        责任：评测（关键词评分引擎无法识别语义等价表述）
  B1 知识库信息缺失      责任：KB
  B2 术语不一致          责任：KB
  B3 缺少高层级文档      责任：KB
  C1 检索意图路由失败    责任：Agent
  C2 Agent 过早收敛      责任：Agent
  C3 术语映射失败        责任：Agent
  C4 Agent 判断失误      责任：Agent

只对非 PASS 题做初判；最终归因建议由 AI 复审（阶段 5）覆盖。
"""
from __future__ import annotations

from typing import Any, Dict, List


ATTR_NAMES = {
    "A1": "标准答案过时",
    "A2": "标准答案错误",
    "A3": "评分方法局限",
    "B1": "知识库信息缺失",
    "B2": "术语不一致",
    "B3": "缺少高层级文档",
    "C1": "检索意图路由失败",
    "C2": "Agent过早收敛",
    "C3": "术语映射失败",
    "C4": "Agent判断失误",
}

ATTR_OWNERS = {
    "A1": "评测", "A2": "评测", "A3": "评测",
    "B1": "KB", "B2": "KB", "B3": "KB",
    "C1": "Agent", "C2": "Agent", "C3": "Agent", "C4": "Agent",
}


NO_ANSWER_HINTS = ["未找到", "没有找到", "无法", "暂无", "抱歉"]


def auto_attribute(
    grade: str,
    model_output: str,
    keyword_rate: float,
    missing_keywords: List[str],
    doc_hit: bool,
    source_docs: List[Dict[str, Any]],
    questions_from_kb: bool = True,
    scoring_method_limited: bool = False,
) -> List[str]:
    """
    返回归因 ID 列表（如 ["C1"] / ["A3"] / ["C2", "C4"]）；PASS 题直接返回 []。

    参数 questions_from_kb：
      True（默认）— 题目从 KB 文档生成，答案信息一定在 KB 中，
        此时 B1（知识库信息缺失）逻辑上不可能成立，应归为 C1（检索意图路由失败）。
      False — 题目非 KB 生成（如人工编写），B1 仍可使用。

    参数 scoring_method_limited：
      True — KeywordScorer 检测到答案主要依赖同义短语匹配（synonym 层），
        说明评分方法存在局限，优先归因 A3。
    """
    if grade == "PASS":
        return []

    attrs: List[str] = []
    out = model_output or ""

    # 明确说"不知道"或无输出 → 检索失败（C1），而非 B1
    # 注：当 questions_from_kb=True 时，B1 不应使用，因为题目从 KB 生成，信息一定在 KB 中
    if not out.strip() or any(h in out[:120] for h in NO_ANSWER_HINTS):
        if questions_from_kb:
            attrs.append("C1")
        else:
            attrs.append("B1")

    # 文献相关
    if not doc_hit:
        if source_docs:
            # 检索到了但不是目标文档
            attrs.append("C1")
        else:
            # 完全没召回
            if questions_from_kb:
                # 题目从 KB 生成，信息一定在 KB 中，只是检索没命中 → C1
                if "C1" not in attrs:
                    attrs.append("C1")
            else:
                # 非 KB 生成的题目，可能是 KB 确实没有 → B1
                if "B1" not in attrs:
                    attrs.append("B1")

    # Agent 相关
    if doc_hit and keyword_rate < 0.8:
        # 关键区分：Agent 有实质回答但关键词命中率极低 → A3（评分方法局限）
        # vs Agent 回答偏离/过早收敛 → C2/C4
        has_substantive_answer = (
            out.strip()
            and not any(h in out[:120] for h in NO_ANSWER_HINTS)
            and len(out.strip()) >= 20  # 有一定长度的实质回答
        )
        if (keyword_rate < 0.15 and has_substantive_answer) or scoring_method_limited:
            # Agent 回答了实质内容，命中了正确文档，但关键词完全没匹配上
            # → 大概率是评分引擎无法识别语义等价表述（如 "6到7月" vs "6月~7月"）
            # scoring_method_limited 标记也表示同义短语匹配起作用，评分方法存在局限
            # A3 为独占归因：确认为评分方法局限后不再追加 C2/C4
            attrs.append("A3")
        elif len(source_docs) <= 1:
            attrs.append("C2")
        else:
            attrs.append("C4")

    # 部分命中且文献命中 → 可能是 C2 / C4（A3 已独占时不追加）
    if doc_hit and 0.5 <= keyword_rate < 0.8 and "A3" not in attrs and "C2" not in attrs and "C4" not in attrs:
        attrs.append("C2")

    # 兜底：低命中 + 未归因
    if not attrs and keyword_rate < 0.5:
        attrs.append("C4")

    # 去重保序
    seen = set()
    uniq = []
    for a in attrs:
        if a in seen:
            continue
        seen.add(a)
        uniq.append(a)
    return uniq


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal:  | SkillSpector: clean (LOW)