#!/usr/bin/env python3
"""
Semantic analyzer 效果评估脚本
通过 HTTP 调用原生接口评估:
- /api/analyze-semantic-relevance:相关性门控(full_match_degree vs 阈值)
- /api/analyze-semantic-keywords:关键词归因(token_attention / expect_keywords)
默认按生产 hybrid:两段都跑;无关例上 keywords 假阳不计分(生产不会走到染色)。
提示词语言由服务端模块常量决定(RELEVANCE_PROMPT_LANG / KEYWORDS_PROMPT_LANG),不经 API。
用例字段:
- expect_relevant:relevance 是否应放行
- expect_keywords:keywords 应命中的关键词(仅 expect_relevant=true 时判定)
用法(从项目根目录运行):
python scripts/eval_semantic.py -c scripts/cases/eval_cases_short.json -o eval_result.jsonl
python scripts/eval_semantic.py --url http://localhost:5001
python scripts/eval_semantic.py \\
-c scripts/cases/eval_cases_bilingual_critical.json \\
-o scripts/results/bilingual_critical_1.7b.jsonl \\
--review-md scripts/results/bilingual_critical_1.7b_hybrid_review.md
输出为 JSONL 格式,每完成一例追加一行;中断后可再次运行,从中断处续跑。
旧 JSONL 的 submode=count|fill_blank 在读对照表时仍识别为 relevance|keywords。
"""
import argparse
import json
import os
import sys
import time
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Hugging Face Token(用于Private Space,可通过环境变量HF_TOKEN设置)
HF_TOKEN_ENV = "HF_TOKEN"
try:
import requests
except ImportError:
print("错误: 需要安装 requests 库")
print("请运行: pip install requests")
sys.exit(1)
# 测试用例:(名称, query, text, meta)
# meta:expect_relevant / expect_keywords(hybrid)
TEST_CASES = [
("相关_AI", "人工智能", "人工智能正在改变我们的生活。机器学习、深度学习等技术在医疗、金融等领域广泛应用。",
{"expect_relevant": True, "expect_keywords": ["人工智能", "机器学习"]}),
("相关_天气", "天气", "今天北京天气晴朗,气温适宜,适合户外活动。明天可能有小雨。",
{"expect_relevant": True, "expect_keywords": ["天气", "晴朗", "小雨"]}),
("无关_足球对AI", "足球比赛", "人工智能正在改变我们的生活。机器学习、深度学习等技术在医疗、金融等领域广泛应用。",
{"expect_relevant": False, "expect_keywords": []}),
("无关_烹饪对天气", "红烧肉做法", "今天北京天气晴朗,气温适宜,适合户外活动。明天可能有小雨。",
{"expect_relevant": False, "expect_keywords": []}),
]
DEFAULT_API_BASE = "http://localhost:5001"
# SYNC: client/src/shared/core/constants.ts → SEMANTIC_MATCH_THRESHOLD;extension/config.js
SEMANTIC_MATCH_THRESHOLD = 0.1
# mode → API path(与生产原生接口一致)
MODE_PATH = {
"relevance": "/api/analyze-semantic-relevance",
"keywords": "/api/analyze-semantic-keywords",
}
# 旧 JSONL / 别名 → 规范 mode
_MODE_ALIASES = {
"relevance": "relevance",
"keywords": "keywords",
"count": "relevance",
"fill_blank": "keywords",
}
def _normalize_mode(value: Optional[str]) -> Optional[str]:
if not value:
return None
return _MODE_ALIASES.get(value, value)
def analyze_semantic_http(
api_base: str,
query: str,
text: str,
mode: str,
token: Optional[str] = None,
timeout: int = 300,
) -> dict:
"""通过 HTTP 调用 relevance / keywords 原生接口。"""
mode = _normalize_mode(mode)
if mode not in MODE_PATH:
raise ValueError(f"Unknown mode: {mode}")
url = f"{api_base.rstrip('/')}{MODE_PATH[mode]}"
payload: dict = {"query": query, "text": text, "debug_info": True}
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
resp = requests.post(url, json=payload, 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:
"""加载 JSONL 文件,用于断点续跑"""
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:
"""追加单条记录到 JSONL 文件"""
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _record_mode(record: dict) -> Optional[str]:
return _normalize_mode(record.get("mode") or record.get("submode"))
def _completed_key(mode: str, name: str) -> Tuple[str, str]:
return (_normalize_mode(mode) or mode, name)
def run_eval(
api_base: str,
mode: str,
test_cases: list,
token: Optional[str] = None,
output_path: Optional[Path] = None,
all_results: Optional[list] = None,
completed: Optional[set] = None,
max_retries: int = 3,
timeout: int = 300,
) -> Tuple[list, bool]:
"""返回 (results, aborted),重试后仍失败时 aborted 为 True"""
mode = _normalize_mode(mode) or mode
completed = completed or set()
results = []
for j, (name, query, text, meta) in enumerate(test_cases):
prog = f"[{j+1}/{len(test_cases)}]"
key = _completed_key(mode, name)
if key in completed:
print(f"{prog} ⏭ 跳过: {mode} | {name}", flush=True)
continue
print(f"{prog} 执行: {mode} | {name}", flush=True)
res = None
last_error = None
for attempt in range(max_retries + 1):
try:
res = analyze_semantic_http(
api_base, query, text, mode, token=token, timeout=timeout,
)
break
except Exception as e:
last_error = e
if attempt < max_retries:
wait = 3 * (attempt + 1)
print(f"{prog} 重试 {attempt + 1}/{max_retries},{wait}s 后... - {e}", flush=True)
time.sleep(wait)
if res is None:
print(f"{prog} ✗ 失败(已重试 {max_retries} 次): {mode} | {name} - {last_error}", flush=True)
record = {
"mode": mode,
"case": name,
"case_lang": meta.get("lang"),
"pair": meta.get("pair"),
"query": query,
"error": str(last_error),
}
results.append(record)
if all_results is not None:
all_results.append(record)
completed.add(key)
print(f"\n⚠ 重试后仍失败,中断后续用例", flush=True)
return results, True
di = res.get("debug_info", {})
topk_tokens = di.get("topk_tokens", [])
topk_probs = di.get("topk_probs", [])
token_attention = res.get("token_attention") or []
# 0-max 归一化: score / max ∈ [0, 1],最大值归一为 1
score_max = max(a["score"] for a in token_attention) if token_attention else 0
denom = score_max if score_max > 0 else 1
# 按 score 排序取 top10
sorted_attn = sorted(token_attention, key=lambda x: x["score"], reverse=True)[:10]
top_scored = []
for a in sorted_attn:
score_norm = round(a["score"] / denom, 6)
top_scored.append({
"raw": a["raw"],
"score": round(a["score"], 6),
"score_norm": score_norm,
"offset": a["offset"],
})
record = {
"model": res.get("model", ""),
"mode": mode,
"case": name,
"case_lang": meta.get("lang"),
"pair": meta.get("pair"),
"expect_relevant": meta.get("expect_relevant"),
"expect_keywords": meta.get("expect_keywords") or [],
"query": query,
"text_preview": text[:80] + "..." if len(text) > 80 else text,
"full_match_degree": res.get("full_match_degree", None),
"top10_tokens": topk_tokens,
"top10_probs": [round(p, 6) for p in topk_probs],
"top10_scored_raw": top_scored,
"score_stats": {
"min": round(min(a["score"] for a in token_attention), 6) if token_attention else None,
"max": round(score_max, 6) if token_attention else None,
"mean": round(sum(a["score"] for a in token_attention) / len(token_attention), 6) if token_attention else None,
"mean_norm": round(sum(a["score"] / denom for a in token_attention) / len(token_attention), 6) if token_attention else None,
},
}
results.append(record)
if all_results is not None:
all_results.append(record)
completed.add(key)
if output_path:
_append_record(output_path, record)
print(f"{prog} ✓ 完成: {mode} | {name}", flush=True)
return results, False
def _top5_raw(record: Dict[str, Any]) -> str:
scored = record.get("top10_scored_raw") or []
if not scored:
return "(无)"
return ", ".join(repr(x.get("raw", "")) for x in scored[:5])
def _expect_relevant(record: Dict[str, Any]) -> Optional[bool]:
val = record.get("expect_relevant")
return bool(val) if val is not None else None
def _relevance_passed(record: Dict[str, Any], threshold: float = SEMANTIC_MATCH_THRESHOLD) -> bool:
deg = record.get("full_match_degree")
return deg is not None and deg >= threshold
def _keyword_hits(record: Dict[str, Any]) -> List[str]:
"""粗匹配:expect_keywords 子串是否出现在 top10 scored raw 中(供对照,非严格 gold)。"""
kws = record.get("expect_keywords") or []
if not kws:
return []
tops = [str(x.get("raw") or "") for x in (record.get("top10_scored_raw") or [])[:10]]
blob = " ".join(tops).lower()
hits = []
for kw in kws:
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 _index_by_cell(results: List[dict]) -> Dict[Tuple[str, str, str], dict]:
"""(pair, mode, case_lang) -> record。旧 JSONL 的 count/fill_blank 归一为 relevance/keywords。"""
out: Dict[Tuple[str, str, str], dict] = {}
for r in results:
if r.get("error") or not r.get("pair"):
continue
mode = _record_mode(r)
if not mode:
continue
key = (r["pair"], mode, r.get("case_lang") or "?")
out[key] = r
return out
def write_hybrid_review_markdown(
results: List[dict],
path: Path,
threshold: float = SEMANTIC_MATCH_THRESHOLD,
) -> None:
"""
Hybrid 人工对照表:只评 relevance 门控 +(门控放行后的)keywords。
无关例被 relevance 拦住时,不展示/不计 keywords 假阳。
"""
idx = _index_by_cell(results)
pairs = sorted({r["pair"] for r in results if r.get("pair") and not r.get("error")})
case_langs = sorted({r.get("case_lang") for r in results if r.get("case_lang") and not r.get("error")})
if not case_langs:
case_langs = ["?"]
lines: List[str] = [
"# Hybrid 对照表(relevance 门控 + keywords 关键词)",
"",
f"对齐生产 hybrid:`relevance` 的 `full_match_degree >= {threshold}` 才进入 `keywords` 染色。",
"无关例只看 relevance 是否拦住;keywords 假阳在门控失败时**不计**。",
"相关例:relevance 应放行,且 top5 宜覆盖 `expect_keywords`。",
"提示词语言由服务端常量决定(默认 en)。",
"",
]
stats: Dict[str, int] = {
"rel_ok": 0, "rel_miss_gate": 0, "rel_miss_kw": 0,
"irrel_ok": 0, "irrel_fp": 0,
}
for pair in pairs:
sample = next(
(r for r in results if r.get("pair") == pair and not r.get("error")),
None,
)
if not sample:
continue
want_pass = _expect_relevant(sample)
lines.append(f"## {pair} · expect_relevant={want_pass}")
lines.append("")
if set(case_langs) >= {"zh", "en"}:
lines.append("| case_lang | result |")
lines.append("|---|---|")
col_langs = ["zh", "en"]
else:
lines.append("| case_lang | result |")
lines.append("|---|---|")
col_langs = case_langs
for cl in col_langs:
rc = idx.get((pair, "relevance", cl))
rf = idx.get((pair, "keywords", cl))
if not rc:
lines.append(f"| **{cl}** | _missing relevance_ |")
continue
passed = _relevance_passed(rc, threshold)
deg = rc.get("full_match_degree")
gate = "PASS" if passed else "fail"
expect_kw = (rf or rc).get("expect_keywords") or []
if want_pass is False:
ok = not passed
verdict = "拒识OK" if ok else "误放行"
cell = f"relevance={gate} ({deg}) → **{verdict}**"
if ok:
stats["irrel_ok"] += 1
else:
stats["irrel_fp"] += 1
if rf:
cell += f"
误入后 top5: {_top5_raw(rf)}"
elif want_pass is True:
if not passed:
cell = f"relevance={gate} ({deg}) → **门控漏检**"
stats["rel_miss_gate"] += 1
else:
hits = _keyword_hits(rf) if rf else []
top = _top5_raw(rf) if rf else "(无 keywords)"
if expect_kw and not hits:
cell = (
f"relevance={gate} ({deg}) → **词未命中**
"
f"expect_kw={expect_kw}
top5: {top}"
)
stats["rel_miss_kw"] += 1
else:
cell = (
f"relevance={gate} ({deg}) → **OK**
"
f"hits={hits or '(无 expect_kw)'}
top5: {top}"
)
stats["rel_ok"] += 1
else:
cell = f"relevance={gate} ({deg})(无 expect)"
lines.append(f"| **{cl}** | {cell} |")
lines.append("")
lines.append("人工判定笔记:(留空)")
lines.append("")
lines.append("# Hybrid 汇总")
lines.append("")
lines.append("| 相关OK | 门控漏 | 门过词差 | 无关拒识OK | 无关误放行 |")
lines.append("|---:|---:|---:|---:|---:|")
lines.append(
f"| {stats['rel_ok']} | {stats['rel_miss_gate']} | {stats['rel_miss_kw']} | "
f"{stats['irrel_ok']} | {stats['irrel_fp']} |"
)
lines.append("")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines), encoding="utf-8")
print(f"✅ Hybrid 对照表已写入 {path}")
def write_review_markdown(results: List[dict], path: Path, hybrid: bool = True) -> None:
"""默认写 hybrid 对照表;hybrid=False 时按 mode 展开(旧调试用)。"""
if hybrid:
write_hybrid_review_markdown(results, path)
return
by_pair_mode: Dict[Tuple[str, str], List[dict]] = defaultdict(list)
for r in results:
if r.get("error") or not r.get("pair"):
continue
mode = _record_mode(r)
if not mode:
continue
by_pair_mode[(r["pair"], mode)].append(r)
lines: List[str] = [
"# 对照表(分 mode,非 hybrid)",
"",
"调试用:分别看 relevance / keywords。生产请用默认 hybrid 对照表。",
"",
]
for (pair, mode) in sorted(by_pair_mode.keys()):
rows = by_pair_mode[(pair, mode)]
lines.append(f"## {pair} · `{mode}`")
lines.append("")
lines.append("| case_lang | result |")
lines.append("|---|---|")
for case_lang in ("zh", "en"):
hit = next((r for r in rows if r.get("case_lang") == case_lang), None)
if not hit:
lines.append(f"| **{case_lang}** | _missing_ |")
continue
deg = hit.get("full_match_degree")
kw = hit.get("expect_keywords") or []
rel = _expect_relevant(hit)
cell = (
f"degree={deg}
"
f"expect_relevant={rel}
"
f"expect_kw={kw}
"
f"top5: {_top5_raw(hit)}"
)
lines.append(f"| **{case_lang}** | {cell} |")
lines.append("")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines), encoding="utf-8")
print(f"✅ 对照表已写入 {path}")
def _load_cases(paths: Optional[List[Path]]) -> list:
if not paths:
return TEST_CASES
test_cases = []
for path in paths:
raw = json.loads(path.read_text(encoding="utf-8"))
for c in raw:
if "name" not in c or "query" not in c:
continue
expect_relevant = c.get("expect_relevant")
meta = {
"lang": c.get("lang"),
"pair": c.get("pair"),
"expect_keywords": c.get("expect_keywords") if expect_relevant else [],
"expect_relevant": expect_relevant,
}
# strip() 与浏览器语义分析时的 trim() 保持一致,避免 token 数差异
test_cases.append((c["name"], c["query"], (c["text"] or "").strip(), meta))
print(f"已加载 {len(test_cases)} 个用例,来自 {len(paths)} 个文件")
return test_cases
def main():
parser = argparse.ArgumentParser(
description="评估 semantic analyzer(默认 hybrid:relevance 门控 + keywords 关键词)"
)
parser.add_argument(
"--mode",
choices=["relevance", "keywords"],
nargs="+",
default=None,
help="不指定则评估 relevance + keywords(hybrid 两段)",
)
parser.add_argument(
"--output", "-o",
type=Path,
default=None,
help="结果输出 JSONL 路径(支持断点续跑)",
)
parser.add_argument(
"--review-md",
type=Path,
default=None,
help="人工阅读对照表 Markdown(默认 hybrid 视角)",
)
parser.add_argument(
"--review-only",
action="store_true",
help="不跑评测,仅从 --output JSONL 生成 --review-md",
)
parser.add_argument(
"--no-hybrid-review",
action="store_true",
help="对照表按 mode 分开展示(旧调试格式)",
)
parser.add_argument(
"--threshold",
type=float,
default=SEMANTIC_MATCH_THRESHOLD,
help=f"relevance 门控阈值,默认 {SEMANTIC_MATCH_THRESHOLD}",
)
parser.add_argument(
"--url",
default=DEFAULT_API_BASE,
help=f"API 地址,默认 {DEFAULT_API_BASE}",
)
parser.add_argument(
"--hf-token",
type=str,
default=None,
help=f"Hugging Face Token(用于Private Space,也可通过环境变量{HF_TOKEN_ENV}设置)",
)
parser.add_argument(
"--cases", "-c",
type=Path,
nargs="+",
default=None,
help="测试用例 JSON:[{name, query, text, expect_relevant, expect_keywords, ...}]",
)
parser.add_argument(
"--retries",
type=int,
default=3,
help="失败时自动重试次数,默认 3",
)
parser.add_argument(
"--timeout",
type=int,
default=300,
help="单次请求超时秒数,默认 300",
)
args = parser.parse_args()
hybrid_review = not args.no_hybrid_review
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 hybrid_review:
write_hybrid_review_markdown(results, args.review_md, threshold=args.threshold)
else:
write_review_markdown(results, args.review_md, hybrid=False)
return
api_base = args.url.rstrip("/")
hf_token = args.hf_token or os.environ.get(HF_TOKEN_ENV)
test_cases = _load_cases(args.cases)
modes = args.mode if args.mode else ["relevance", "keywords"]
all_results: list = []
completed: set = set()
if args.output and args.output.exists():
all_results = _load_jsonl(args.output)
completed = {
_completed_key(_record_mode(r) or "", r["case"])
for r in all_results
if _record_mode(r) and "case" in r
}
print(f"已加载 {len(all_results)} 条历史结果,从中断处续跑")
aborted = False
for mode in modes:
_, aborted = run_eval(
api_base, mode, test_cases, token=hf_token,
output_path=args.output, all_results=all_results,
completed=completed, max_retries=args.retries, timeout=args.timeout,
)
if aborted:
break
if args.output:
print(f"\n✅ 结果已写入 {args.output}(共 {len(all_results)} 条)")
if args.review_md and all_results:
if hybrid_review:
write_hybrid_review_markdown(all_results, args.review_md, threshold=args.threshold)
else:
write_review_markdown(all_results, args.review_md, hybrid=False)
if __name__ == "__main__":
main()