InfoLens / scripts /eval_semantic_keywords.py
dqy08's picture
插件优化文本节点可见性检查;评测相关改进
9b4263f
Raw
History Blame Contribute Delete
11.7 kB
#!/usr/bin/env python3
"""
关键词归因评测:只打 /api/analyze-semantic-keywords(默认本地)。
真值约定:
- 磁盘上每 chunk 的 query 为数组,每项自带 expect_relevant / expect_keywords
- 加载后展平;仅 expect_relevant=true 的用例参与本脚本;无关例跳过(expect_keywords 无意义)
- 相关例对照 expect_keywords 与 top scored raw(粗匹配)
相关性门控请用 scripts/eval_semantic_relevance_remote.py(云端 Chat)。
用法(项目根目录):
python scripts/eval_semantic_keywords.py \\
-c scripts/cases/红楼-第3回.json \\
-o scripts/results/红楼-第3回_kw.jsonl \\
--review-md scripts/results/红楼-第3回_kw_review.md
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from semantic_case_load import load_relevant_cases
HF_TOKEN_ENV = "HF_TOKEN"
try:
import requests
except ImportError:
print("错误: 需要安装 requests 库")
print("请运行: pip install requests")
sys.exit(1)
DEFAULT_API_BASE = "http://localhost:5001"
PATH_KEYWORDS = "/api/analyze-semantic-keywords"
def analyze_keywords_http(
api_base: str,
query: str,
text: str,
token: Optional[str] = None,
timeout: int = 300,
) -> dict:
url = f"{api_base.rstrip('/')}{PATH_KEYWORDS}"
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
resp = requests.post(
url,
json={"query": query, "text": text, "debug_info": True},
headers=headers,
timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
if not data.get("success"):
raise RuntimeError(data.get("message", "分析失败"))
return data
def _load_jsonl(path: Path) -> list:
if not path.exists():
return []
results = []
for line in path.read_text(encoding="utf-8").strip().split("\n"):
if not line:
continue
try:
results.append(json.loads(line))
except json.JSONDecodeError:
pass
return results
def _append_record(path: Path, record: dict) -> None:
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _top_scored_from_attention(token_attention: list, k: int = 10) -> List[dict]:
if not token_attention:
return []
score_max = max(a["score"] for a in token_attention)
denom = score_max if score_max > 0 else 1
sorted_attn = sorted(token_attention, key=lambda x: x["score"], reverse=True)[:k]
out = []
for a in sorted_attn:
out.append({
"raw": a.get("raw"),
"score": round(a["score"], 6),
"score_norm": round(a["score"] / denom, 6),
"offset": a.get("offset"),
})
return out
def _keyword_hits(expect_keywords: List[str], top_scored: List[dict]) -> List[str]:
if not expect_keywords:
return []
tops = [str(x.get("raw") or "") for x in (top_scored or [])[:10]]
blob = " ".join(tops).lower()
hits = []
for kw in expect_keywords:
kl = kw.lower()
if kl in blob or any(kl in t.lower() or t.lower() in kl for t in tops if t.strip()):
hits.append(kw)
continue
parts = [
p for p in kw.replace("(", " ").replace(")", " ").replace("(", " ").replace(")", " ").split()
if len(p) >= 2
]
if parts and any(p.lower() in blob for p in parts):
hits.append(kw)
return hits
def _top5_raw(top_scored: List[dict]) -> str:
if not top_scored:
return "(无)"
return ", ".join(repr(x.get("raw", "")) for x in top_scored[:5])
def load_cases(path: Path) -> tuple[List[dict], int]:
"""返回 (相关用例, 跳过的无关条数)。无关例的 expect_keywords 忽略。"""
return load_relevant_cases(path)
def run_one(
api_base: str,
case: dict,
token: Optional[str],
timeout: int,
max_retries: int,
) -> dict:
name = case["name"]
query = case["query"]
text = case["text"]
expect_kw = case.get("expect_keywords") or []
disputed = bool(case.get("disputed"))
dispute_note = case.get("dispute_note") or ""
def _base(**extra: Any) -> dict:
rec: Dict[str, Any] = {
"case": name,
"chunk_index": case.get("chunk_index"),
"query": query,
"expect_relevant": True,
"expect_keywords": expect_kw,
"source": case.get("source"),
**extra,
}
if disputed:
rec["disputed"] = True
if dispute_note:
rec["dispute_note"] = dispute_note
return rec
last_error: Optional[BaseException] = None
r2 = None
for attempt in range(max_retries + 1):
try:
r2 = analyze_keywords_http(api_base, query, text, token=token, timeout=timeout)
break
except Exception as e:
last_error = e
if attempt < max_retries:
wait = 3 * (attempt + 1)
print(f" keywords 重试 {attempt + 1}/{max_retries}{wait}s… {e}", flush=True)
time.sleep(wait)
if r2 is None:
return _base(error=f"keywords: {last_error}")
top = _top_scored_from_attention(r2.get("token_attention") or [])
hits = _keyword_hits(expect_kw, top)
return _base(
model=r2.get("model", ""),
top10_scored_raw=top,
keyword_hits=hits,
keywords_ok=(not expect_kw) or bool(hits),
)
def enrich_results_from_cases(results: List[dict], cases: List[dict]) -> None:
by_name = {c["name"]: c for c in cases}
for r in results:
c = by_name.get(r.get("case") or "")
if not c:
continue
if c.get("disputed"):
r["disputed"] = True
if c.get("dispute_note"):
r["dispute_note"] = c["dispute_note"]
else:
r.pop("disputed", None)
r.pop("dispute_note", None)
def write_review_markdown(results: List[dict], path: Path) -> None:
lines = [
"# 关键词归因对照表(仅相关例)",
"",
"只评 `/api/analyze-semantic-keywords`;无关例不跑本主题。",
"`disputed=true`:边界争议,汇总仍按 expect 计;报告里点评实测。",
"",
"| case | chunk | disputed | hits | verdict |",
"|---|---:|---|---|---|",
]
ok = miss = err = 0
disputed_rows: List[dict] = []
for r in results:
name = r.get("case", "?")
ci = r.get("chunk_index", "")
disp = "yes" if r.get("disputed") else ""
if r.get("disputed"):
disputed_rows.append(r)
if r.get("error"):
lines.append(f"| {name} | {ci} | {disp} | — | **error**: {r['error']} |")
err += 1
continue
expect_kw = r.get("expect_keywords") or []
hits = r.get("keyword_hits") or []
top = _top5_raw(r.get("top10_scored_raw") or [])
if expect_kw and not hits:
lines.append(
f"| {name} | {ci} | {disp} | [] top5={top} | **词未命中** expect={expect_kw} |"
)
miss += 1
else:
lines.append(
f"| {name} | {ci} | {disp} | "
f"hits={hits or '(无 expect_kw)'} top5={top} | **OK** |"
)
ok += 1
lines.extend([
"",
"# 汇总",
"",
"| 词命中OK | 词未命中 | error |",
"|---:|---:|---:|",
f"| {ok} | {miss} | {err} |",
"",
])
if disputed_rows:
lines.extend([
"# 争议 case(disputed)",
"",
"| case | note | hits |",
"|---|---|---|",
])
for r in disputed_rows:
note = (r.get("dispute_note") or "").replace("|", "\\|")
hits = r.get("keyword_hits", "—")
lines.append(f"| {r.get('case')} | {note} | {hits} |")
lines.append("")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines), encoding="utf-8")
print(f"✅ 对照表已写入 {path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="关键词归因评测(仅 expect_relevant=true;打本地 keywords API)"
)
parser.add_argument("-c", "--cases", type=Path, required=True, help="用例 JSON 数组")
parser.add_argument("-o", "--output", type=Path, default=None, help="结果 JSONL(可续跑)")
parser.add_argument("--review-md", type=Path, default=None, help="对照表 Markdown")
parser.add_argument("--review-only", action="store_true", help="仅从 JSONL 生成对照表")
parser.add_argument("--url", default=DEFAULT_API_BASE, help=f"keywords API 根,默认 {DEFAULT_API_BASE}")
parser.add_argument("--hf-token", default=None)
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--timeout", type=int, default=300)
args = parser.parse_args()
if args.review_only:
if not args.output or not args.review_md:
print("错误: --review-only 需要 -o 与 --review-md")
sys.exit(1)
results = _load_jsonl(args.output)
if args.cases:
cases, _ = load_cases(args.cases)
enrich_results_from_cases(results, cases)
write_review_markdown(results, args.review_md)
return
cases, skipped = load_cases(args.cases)
print(f"已加载 {len(cases)} 个相关用例(跳过无关 {skipped})")
if not cases:
print("无相关用例可跑")
sys.exit(0)
api_base = args.url.rstrip("/")
token = args.hf_token or os.environ.get(HF_TOKEN_ENV)
completed = set()
all_results: list = []
if args.output and args.output.exists():
all_results = _load_jsonl(args.output)
completed = {r["case"] for r in all_results if "case" in r}
print(f"已加载 {len(all_results)} 条历史,跳过 {len(completed)} 个 case")
for i, case in enumerate(cases):
name = case["name"]
prog = f"[{i + 1}/{len(cases)}]"
if name in completed:
print(f"{prog}{name}", flush=True)
continue
print(f"{prog} 执行 {name}", flush=True)
record = run_one(api_base, case, token, args.timeout, args.retries)
if record.get("error"):
print(f"{prog}{name}: {record['error']}", flush=True)
all_results.append(record)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
_append_record(args.output, record)
print("⚠ 失败中断后续", flush=True)
break
print(
f"{prog}{name} hits={record['keyword_hits']} "
f"ok={record['keywords_ok']}",
flush=True,
)
all_results.append(record)
completed.add(name)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
_append_record(args.output, record)
if args.output:
print(f"\n✅ 结果已写入 {args.output}(共 {len(all_results)} 条)")
if args.review_md and all_results:
enrich_results_from_cases(all_results, cases)
write_review_markdown(all_results, args.review_md)
if __name__ == "__main__":
main()