Spaces:
Paused
Paused
File size: 22,293 Bytes
494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 1f66b27 494c9e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 | #!/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"<br>误入后 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}) → **词未命中**<br>"
f"expect_kw={expect_kw}<br>top5: {top}"
)
stats["rel_miss_kw"] += 1
else:
cell = (
f"relevance={gate} ({deg}) → **OK**<br>"
f"hits={hits or '(无 expect_kw)'}<br>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}<br>"
f"expect_relevant={rel}<br>"
f"expect_kw={kw}<br>"
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()
|