File size: 27,613 Bytes
0a98b0b
 
 
 
 
 
 
 
22f2f5a
 
 
0a98b0b
 
22f2f5a
 
 
 
0a98b0b
 
 
 
 
 
 
 
 
 
22f2f5a
 
 
 
 
 
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
 
 
22f2f5a
0a98b0b
 
 
22f2f5a
 
 
 
 
 
 
 
 
 
 
 
 
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
 
 
 
0a98b0b
22f2f5a
 
 
 
0a98b0b
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
22f2f5a
0a98b0b
22f2f5a
 
 
0a98b0b
 
 
 
 
22f2f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
22f2f5a
0a98b0b
22f2f5a
 
 
0a98b0b
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
 
 
 
22f2f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a98b0b
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
 
0a98b0b
 
 
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
 
 
 
 
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
 
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22f2f5a
0a98b0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
#!/usr/bin/env python3
"""
远程 Chat API 关键词匹配评测(默认 OpenRouter;与门面 keywords v2 同契约)。

定位:评测模型「抽词 + 排序」能力(对照 expect_keywords 的集合/顺序指标)。
不跑线上渲染路径:不定位、不 uniquifyHighScores、不 REPEAT_DIM 压分——那些是
keywords_remote_v2.js 里为上色观感做的 Workaround,与模型能力无关。

默认口头(SYNC: cf/facade/src/keywords_remote_v2.js):
  - Task:抽取与 query 相关、且出现在正文中的关键词,按重要性排序
  - Output Format:逐行 [keyword]<score>,score 1–5;头尾同序 Task(Reminder) → Query → Format
对照 expect_keywords 打分时先忽略 score,只用关键词列表。仅跑 expect_relevant=true。

--tool 保留旧 submit_keywords 对照(门面已不用)。Hy3 全集 108 条相关例(2026-08-14):
口头相对 tool:F1(综合) 66.0%→67.7%,精确略升、召回几乎不动;完成 token 约少 65%
(词条数/词长几乎不变,差在 JSON 骨架);两边解析失败都是 0。故默认与门面都走口头。

打分(子串互含):
  - 集合:召回 / 精确 / F1 —— 对外展示必须带括号释义,见下方 METRIC_*
  - 成对顺序正确率(两边都命中的 expect 词对,相对先后是否与 gold 一致)

用法(项目根目录):
  python scripts/eval_semantic_keywords_remote.py \\
    -c scripts/cases/subsets/keywords.typical10.json \\
    -o scripts/results/keywords_typical10_remote_hy3.jsonl \\
    --review-md scripts/results/keywords_typical10_remote_hy3_review.md \\
    -j 8

  # 旧 tool 对照(submit_keywords JSON)
  python scripts/eval_semantic_keywords_remote.py \\
    -c scripts/cases/subsets/keywords.typical10.json \\
    -o scripts/results/keywords_typical10_remote_hy3_tool.jsonl \\
    --tool
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

_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

try:
    import requests
except ImportError:
    print("错误: 需要安装 requests 库")
    print("请运行: pip install requests")
    sys.exit(1)

OPENROUTER_TOKEN_ENV = "OPENROUTER_API_KEY"
HF_TOKEN_ENV = "HF_TOKEN"
DEFAULT_API_BASE = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = "tencent/hy3"
DEFAULT_MAX_TOKENS = 256

# ---------------------------------------------------------------------------
# 口头默认(SYNC 门面);--tool 走旧 submit_keywords,仅评测对照。
# ---------------------------------------------------------------------------
TOOL_NAME = "submit_keywords"

KEYWORDS_TASK_CORE = (
    "Extract all keywords related to the query topic. "
    "A keyword can be a word or short phrase. "
    "Make sure to only extract keywords that appear in the text. "
    "Order them from most important to least important."
)
KEYWORDS_TASK = KEYWORDS_TASK_CORE + f" Submit with {TOOL_NAME}."

# 口头:与相关度 multi-chunk 同构——Task 只说做什么;
# 头尾同序 Task(Reminder) → Query → Output Format,格式各一次。
# Hy3 对这段顺序敏感(相关度实测:Query 在最后会抬错检);跟相关度同一序。
KEYWORDS_OUTPUT_FORMAT = (
    "Output Format: each keyword on its own line "
    "as [keyword]<score, 1 slightly related to 5 strongly related>. "
    "Nothing else.\n"
    "Example reply:\n"
    "[foo]5\n[bar]3"
)

SUBMIT_KEYWORDS_TOOL: Dict[str, Any] = {
    "type": "function",
    "function": {
        "name": TOOL_NAME,
        "description": "Submit extracted keywords with scores.",
        "parameters": {
            "type": "object",
            "required": ["keywords"],
            "properties": {
                "keywords": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "required": ["keyword", "score"],
                        "properties": {
                            "keyword": {"type": "string"},
                            "score": {
                                "type": "integer",
                                "description": "1 (slightly related) to 5 (strongly related).",
                            },
                        },
                    },
                }
            },
        },
    },
}

# ---------------------------------------------------------------------------
# 对外展示约定(强制生效):对照表表头、汇总行、日志打印里的 R/P/F1/pair
# 一律引用下列常量,禁止裸写 "R"/"P"/"F1"/"pair"。
# ---------------------------------------------------------------------------
METRIC_R = "R(召回:该出的出了多少)"
METRIC_P = "P(精确:抽出的有多少对)"
METRIC_F1 = "F1(综合)"
METRIC_PAIR = "pair(成对顺序:命中词相对先后是否一致)"
METRIC_LEGEND = f"{METRIC_R}{METRIC_P}{METRIC_F1}{METRIC_PAIR}"


def format_rpf1(recall: float, precision: float, f1: float, *, pct: bool = True) -> str:
    """格式化 R/P/F1,强制带括号释义(与表头/汇总同一套 METRIC_*)。"""
    if pct:
        return (
            f"{METRIC_R}={recall:.0%} {METRIC_P}={precision:.0%} {METRIC_F1}={f1:.0%}"
        )
    return f"{METRIC_R}={recall} {METRIC_P}={precision} {METRIC_F1}={f1}"


def build_keywords_user_content(query: str, text: str, *, verbal: bool = True) -> str:
    """三明治。默认口头(SYNC 门面):Task(Reminder) → Query → Output Format。
    Hy3 对这段顺序敏感(相关度实测:Query 在最后会抬错检);跟相关度同一序。
    verbal=False(--tool):旧 Task 含 Submit with,无 Output Format 段。"""
    query_line = f"Query: {query}"
    task = KEYWORDS_TASK_CORE if verbal else KEYWORDS_TASK
    mid = f"{query_line}\n{KEYWORDS_OUTPUT_FORMAT}" if verbal else query_line
    head = f"Task: {task}\n{mid}"
    reminder = f"Task Reminder: {task}\n{mid}"
    return f"{head}\nText:\n\n{text}\n\n{reminder}"


def build_keywords_chat_body(
    query: str,
    text: str,
    *,
    model: str = DEFAULT_MODEL,
    max_tokens: int = DEFAULT_MAX_TOKENS,
    verbal: bool = True,
) -> Dict[str, Any]:
    body: Dict[str, Any] = {
        "model": model,
        "messages": [
            {"role": "user", "content": build_keywords_user_content(query, text, verbal=verbal)}
        ],
        "temperature": 0,
        "max_tokens": max_tokens,
        "stream": False,
        "reasoning": {"effort": "none"},
    }
    if not verbal:
        body["tools"] = [SUBMIT_KEYWORDS_TOOL]
        body["tool_choice"] = {
            "type": "function",
            "function": {"name": TOOL_NAME},
        }
    return body


_RE_VERBAL_KEYWORD = re.compile(r"^\[(.+)\]\s*(\d+)\s*$")


def parse_verbal_keywords(content: Optional[str]) -> Optional[List[Tuple[str, int]]]:
    """口头逐行 `[keyword]<score>` → [(kw, score), ...];失败返回 None。
    空/空白字符串 = 合法零词;None / 非 str / 任一非空行对不上 = 契约外。score 定档 1–5。"""
    if not isinstance(content, str):
        return None
    if not content.strip():
        return []
    out: List[Tuple[str, int]] = []
    for line in content.splitlines():
        line = line.strip()
        if not line:
            continue
        m = _RE_VERBAL_KEYWORD.match(line)
        if not m:
            return None
        kw = m.group(1).strip()
        if not kw:
            return None
        try:
            score = float(m.group(2))
        except ValueError:
            return None
        s = int(round(score))
        s = max(1, min(5, s))
        out.append((kw, s))
    return out


def parse_submit_keywords_arguments(arguments: str) -> Optional[List[Tuple[str, int]]]:
    """从 tool_calls[].function.arguments 得到 [(kw, score), ...];失败返回 None。"""
    try:
        data = json.loads(arguments)
    except json.JSONDecodeError:
        return None
    if not isinstance(data, dict):
        return None
    items = data.get("keywords")
    if not isinstance(items, list):
        return None
    out: List[Tuple[str, int]] = []
    for x in items:
        if not isinstance(x, dict):
            continue
        kw = x.get("keyword")
        score = x.get("score")
        if not isinstance(kw, str) or not kw.strip():
            continue
        if not isinstance(score, (int, float)) or score != score:  # NaN
            continue
        s = int(round(float(score)))
        s = max(1, min(5, s))
        out.append((kw.strip(), s))
    # 空数组 = 合法零词;非空却 0 条有效 = 契约外,当解析失败
    if len(items) > 0 and len(out) == 0:
        return None
    return out


def is_keywords_length_stop(choice: Optional[dict]) -> bool:
    """OpenRouter 常把 finish_reason 归一成 tool_calls;超长截断看 native_finish_reason=length。"""
    if not isinstance(choice, dict):
        return False
    return choice.get("finish_reason") == "length" or choice.get("native_finish_reason") == "length"


def salvage_partial_submit_keywords_arguments(
    arguments: str,
) -> Optional[List[Tuple[str, int]]]:
    """从 length 截断的残缺 arguments 捞已写完的 {keyword,score};一个都没有 → None。
    --tool 对照用(门面口头路径不再 salvage JSON)。
    """
    if not isinstance(arguments, str) or not arguments:
        return None
    found: List[Tuple[int, str, int]] = []

    def push(i: int, kw_raw: str, score_raw: str) -> None:
        try:
            kw = json.loads(f'"{kw_raw}"')
        except json.JSONDecodeError:
            return
        if not isinstance(kw, str) or not kw.strip():
            return
        try:
            score = float(score_raw)
        except ValueError:
            return
        if score != score:  # NaN
            return
        s = int(round(score))
        s = max(1, min(5, s))
        found.append((i, kw.strip(), s))

    for m in re.finditer(
        r'\{\s*"keyword"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"score"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}',
        arguments,
    ):
        push(m.start(), m.group(1), m.group(2))
    for m in re.finditer(
        r'\{\s*"score"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"keyword"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}',
        arguments,
    ):
        push(m.start(), m.group(2), m.group(1))
    if not found:
        return None
    found.sort(key=lambda x: x[0])
    return [(kw, score) for _i, kw, score in found]


def resolve_submit_keywords_arguments(
    arguments: str,
    choice: Optional[dict] = None,
) -> Optional[List[Tuple[str, int]]]:
    """严格解析;失败且为 length 截断时再 salvage。"""
    scored = parse_submit_keywords_arguments(arguments)
    if scored is not None:
        return scored
    if is_keywords_length_stop(choice):
        return salvage_partial_submit_keywords_arguments(arguments)
    return None


def _load_env_file(path: Path) -> None:
    if not path.is_file():
        return
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        os.environ.setdefault(k.strip(), v.strip())


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 _norm(s: str) -> str:
    return s.strip().casefold()


def _soft_match(a: str, b: str) -> bool:
    """子串互含(已 norm 后)。"""
    return bool(a and b and (a in b or b in a))


def match_keywords(expect: List[str], got: List[str]) -> Dict[str, Any]:
    """集合 + 成对顺序打分。

    - recall / precision / f1:子串互含(集合,允许多 expect 命中同一 got)
      对外打印用 format_rpf1 / METRIC_*,勿只写裸 R/P/F1
    - pairwise_acc:一对一对齐后,命中词对的相对先后是否与 gold 一致
    """
    expect_n = [_norm(e) for e in expect]
    got_n = [_norm(g) for g in got]

    hit: List[str] = []
    miss: List[str] = []
    for ei, en in enumerate(expect_n):
        ok = any(_soft_match(en, gn) for gn in got_n if gn)
        (hit if ok else miss).append(expect[ei])

    got_hit: List[str] = []
    got_extra: List[str] = []
    for gi, gn in enumerate(got_n):
        ok = any(_soft_match(gn, en) for en in expect_n if en)
        (got_hit if ok else got_extra).append(got[gi])

    n_exp = len(expect)
    n_got = len(got)
    n_hit_exp = len(hit)
    n_hit_got = len(got_hit)
    recall = n_hit_exp / n_exp if n_exp else 0.0
    precision = n_hit_got / n_got if n_got else 0.0
    if precision + recall > 0:
        f1 = 2 * precision * recall / (precision + recall)
    else:
        f1 = 0.0

    used_got: set = set()
    expect_to_got: Dict[int, int] = {}
    for ei, en in enumerate(expect_n):
        for gi, gn in enumerate(got_n):
            if gi in used_got or not gn:
                continue
            if _soft_match(en, gn):
                used_got.add(gi)
                expect_to_got[ei] = gi
                break

    aligned = sorted(expect_to_got.items())
    pair_ok = 0
    pair_n = 0
    for a in range(len(aligned)):
        for b in range(a + 1, len(aligned)):
            _ei, gi = aligned[a]
            _ej, gj = aligned[b]
            pair_n += 1
            if gi < gj:
                pair_ok += 1
    pairwise_acc = pair_ok / pair_n if pair_n else None

    return {
        "hit": hit,
        "miss": miss,
        "got_hit": got_hit,
        "got_extra": got_extra,
        "hit_n": n_hit_exp,
        "miss_n": len(miss),
        "expect_n": n_exp,
        "got_n": n_got,
        "recall": round(recall, 4),
        "precision": round(precision, 4),
        "f1": round(f1, 4),
        "pairwise_ok": pair_ok,
        "pairwise_n": pair_n,
        "pairwise_acc": None if pairwise_acc is None else round(pairwise_acc, 4),
        "ok": len(miss) == 0 and n_exp > 0,
    }


def _chat_headers(api_base: str, token: str) -> Dict[str, str]:
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }
    if "openrouter.ai" in api_base:
        headers["HTTP-Referer"] = "https://info-radar.local"
        headers["X-Title"] = "info-radar-keywords-eval"
    return headers


def chat_keywords(
    api_base: str,
    model: str,
    query: str,
    text: str,
    *,
    token: str,
    timeout: int,
    max_tokens: int = DEFAULT_MAX_TOKENS,
    verbal: bool = True,
) -> dict:
    """默认口头 [keyword]<score>;verbal=False 则 submit_keywords 虚拟提交。"""
    url = f"{api_base.rstrip('/')}/chat/completions"
    body = build_keywords_chat_body(
        query, text, model=model, max_tokens=max_tokens, verbal=verbal
    )
    if "openrouter.ai" not in api_base:
        body.pop("reasoning", None)
        body["thinking"] = {"type": "disabled"}
    prompt = build_keywords_user_content(query, text, verbal=verbal)

    resp = requests.post(url, headers=_chat_headers(api_base, token), json=body, timeout=timeout)
    data = resp.json()
    if resp.status_code >= 400:
        err = data.get("error") or data
        raise RuntimeError(f"HTTP {resp.status_code}: {err}")
    if data.get("error"):
        raise RuntimeError(str(data["error"]))
    choice = (data.get("choices") or [{}])[0]
    msg = choice.get("message") or {}

    if verbal:
        raw = msg.get("content")
        scored = parse_verbal_keywords(raw)
        if scored is None:
            raise RuntimeError(f"unparseable verbal output: {raw!r}")
        content_out = raw if isinstance(raw, str) else ""
    else:
        tool_calls = msg.get("tool_calls") or []
        if not tool_calls:
            raise RuntimeError(
                f"no tool_calls: content={msg.get('content')!r} finish={choice.get('finish_reason')!r}"
            )
        content_out = (tool_calls[0].get("function") or {}).get("arguments") or ""
        scored = resolve_submit_keywords_arguments(content_out, choice)
        if scored is None:
            raise RuntimeError(f"unparseable tool arguments: {content_out!r}")

    keywords = [k for k, _s in scored]
    return {
        "prompt": prompt,
        "content": content_out,
        "keywords": keywords,
        "scored": [{"keyword": k, "score": s} for k, s in scored],
        "finish_reason": choice.get("finish_reason"),
        "native_finish_reason": choice.get("native_finish_reason"),
        "usage": data.get("usage"),
        "raw_model": data.get("model") or model,
        "verbal": verbal,
    }


def run_one(
    api_base: str,
    model: str,
    case: dict,
    *,
    token: str,
    timeout: int,
    max_retries: int,
    verbal: bool = True,
) -> dict:
    name = case["name"]
    query = case["query"]
    text = case["text"]
    expect = list(case.get("expect_keywords") or [])

    def _base(**extra: Any) -> dict:
        return {
            "case": name,
            "chunk_index": case.get("chunk_index"),
            "query": query,
            "expect_keywords": expect,
            "model": model,
            "source": case.get("source"),
            "verbal": verbal,
            **extra,
        }

    last_error: Optional[BaseException] = None
    for attempt in range(max_retries + 1):
        try:
            r = chat_keywords(
                api_base, model, query, text,
                token=token, timeout=timeout, verbal=verbal,
            )
            m = match_keywords(expect, r["keywords"])
            return _base(
                prompt=r["prompt"],
                content=r["content"],
                keywords=r["keywords"],
                scored=r.get("scored"),
                match=m,
                finish_reason=r.get("finish_reason"),
                usage=r.get("usage"),
            )
        except Exception as e:
            last_error = e
            if attempt < max_retries:
                wait = 3 * (attempt + 1)
                print(f"  重试 {attempt + 1}/{max_retries}{wait}s… {e}", flush=True)
                time.sleep(wait)
    return _base(error=f"keywords: {last_error}")


def write_review_markdown(results: List[dict], path: Path) -> None:
    err_n = 0
    sum_r = sum_p = sum_f1 = 0.0
    scored_n = 0
    pair_ok_all = pair_n_all = 0
    lines = [
        "# 远程 keywords 对照表",
        "",
        f"打分:子串互含。{METRIC_LEGEND}。",
        "",
        f"| case | query | expect | got | {METRIC_R} | {METRIC_P} | {METRIC_F1} | {METRIC_PAIR} | miss | extra |",
        "|---|---|---|---|---:|---:|---:|---:|---|---|",
    ]
    for r in results:
        if r.get("error"):
            err_n += 1
            lines.append(
                f"| {r.get('case')} | {r.get('query')} | {r.get('expect_keywords')} "
                f"| — | — | — | — | — | — | error |"
            )
            continue
        # 用 expect/got 重算,便于旧 JSONL 用新指标 --review-only
        expect = list(r.get("expect_keywords") or [])
        got = list(r.get("keywords") or [])
        m = match_keywords(expect, got)
        r["match"] = m
        scored_n += 1
        sum_r += float(m["recall"])
        sum_p += float(m["precision"])
        sum_f1 += float(m["f1"])
        pair_ok_all += int(m["pairwise_ok"])
        pair_n_all += int(m["pairwise_n"])
        expect_s = ", ".join(expect)
        got_s = ", ".join(got)
        miss_s = ", ".join(m["miss"]) or "—"
        extra_s = ", ".join(m["got_extra"]) or "—"
        pair = m["pairwise_acc"]
        pair_s = "—" if pair is None else f"{pair:.0%}"
        lines.append(
            f"| {r.get('case')} | {r.get('query')} | {expect_s} | {got_s} | "
            f"{m['recall']:.0%} | {m['precision']:.0%} | "
            f"{m['f1']:.0%} | {pair_s} | {miss_s} | {extra_s} |"
        )
    if scored_n:
        macro_r = sum_r / scored_n
        macro_p = sum_p / scored_n
        macro_f1 = sum_f1 / scored_n
    else:
        macro_r = macro_p = macro_f1 = 0.0
    micro_pair = pair_ok_all / pair_n_all if pair_n_all else None
    pair_summary = "—" if micro_pair is None else f"{micro_pair:.1%}"
    lines[3:3] = [
        f"汇总(macro):{METRIC_R}={macro_r:.1%} {METRIC_P}={macro_p:.1%} "
        f"{METRIC_F1}={macro_f1:.1%};"
        f"{METRIC_PAIR}(micro)={pair_summary}{pair_ok_all}/{pair_n_all});"
        f"n={scored_n} error={err_n}",
        "",
    ]
    lines += ["", "## 原始提示词 / 输出", ""]
    for r in results:
        lines.append(f"### {r.get('case')}")
        lines.append("")
        if r.get("error"):
            lines.append(f"error: `{r['error']}`")
            lines.append("")
            continue
        m = r.get("match") or {}
        pair = m.get("pairwise_acc")
        pair_s = "n/a" if pair is None else f"{pair:.0%} ({m.get('pairwise_ok')}/{m.get('pairwise_n')})"
        lines.append(
            f"{format_rpf1(float(m.get('recall') or 0), float(m.get('precision') or 0), float(m.get('f1') or 0), pct=False)} "
            f"{METRIC_PAIR}={pair_s} miss={m.get('miss')} extra={m.get('got_extra')}"
        )
        lines.append("")
        lines.append("**prompt**")
        lines.append("")
        lines.append("```")
        lines.append(r.get("prompt") or "")
        lines.append("```")
        lines.append("")
        kind = "verbal" if r.get("verbal") else "tool arguments"
        lines.append(f"**content**({kind})")
        lines.append("")
        lines.append("```")
        lines.append(r.get("content") or "")
        lines.append("```")
        lines.append("")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"✅ 对照表已写入 {path}")


def main() -> None:
    parser = argparse.ArgumentParser(description="远程 Chat API 关键词匹配评测(默认口头 [keyword]<score>)")
    parser.add_argument("-c", "--cases", type=Path, default=None, 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("--model", default=DEFAULT_MODEL, help=f"模型 id(默认 {DEFAULT_MODEL})")
    parser.add_argument("--url", default=DEFAULT_API_BASE, help=f"API base(默认 {DEFAULT_API_BASE})")
    parser.add_argument("--token", default=None, help="API token")
    parser.add_argument("--retries", type=int, default=3)
    parser.add_argument("--timeout", type=int, default=180)
    parser.add_argument("--sleep", type=float, default=0.2, help="每条请求前额外等待秒(每 worker)")
    parser.add_argument(
        "-j",
        "--jobs",
        type=int,
        default=8,
        help="并发数(用例彼此独立;默认 8)",
    )
    parser.add_argument(
        "--tool",
        action="store_true",
        help="对照:旧 submit_keywords tool 输出(门面已不用;见文件头评测备注)",
    )
    args = parser.parse_args()

    _load_env_file(Path(__file__).resolve().parents[1] / ".env")
    _load_env_file(Path(__file__).resolve().parents[1] / "cf" / "facade" / ".dev.vars")

    if args.review_only:
        if not args.output or not args.review_md:
            print("错误: --review-only 需要 -o 与 --review-md")
            sys.exit(1)
        write_review_markdown(_load_jsonl(args.output), args.review_md)
        return

    if not args.cases:
        print("错误: 需要 -c/--cases")
        sys.exit(1)
    cases, skipped = load_relevant_cases(args.cases)
    token = (
        args.token
        or os.environ.get(OPENROUTER_TOKEN_ENV)
        or os.environ.get(HF_TOKEN_ENV)
    )
    if not token:
        print(f"错误: 需要 --token / {OPENROUTER_TOKEN_ENV} / {HF_TOKEN_ENV}")
        sys.exit(1)

    jobs = max(1, int(args.jobs))
    print(
        f"已加载 {len(cases)} 条相关用例(跳过无关 {skipped});"
        f"model={args.model};jobs={jobs};"
        f"verbal={'off' if args.tool else 'on'}"
    )

    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")

    pending: List[Tuple[int, dict]] = [
        (i, case) for i, case in enumerate(cases) if case["name"] not in completed
    ]
    if args.output:
        args.output.parent.mkdir(parents=True, exist_ok=True)
    write_lock = threading.Lock()
    done_n = len(cases) - len(pending)

    def _work(item: Tuple[int, dict]) -> Tuple[int, dict, dict]:
        i, case = item
        if args.sleep > 0:
            time.sleep(args.sleep)
        record = run_one(
            args.url, args.model, case,
            token=token, timeout=args.timeout, max_retries=args.retries,
            verbal=not args.tool,
        )
        return i, case, record

    def _commit(i: int, case: dict, record: dict) -> None:
        nonlocal done_n
        done_n += 1
        prog = f"[{done_n}/{len(cases)}]"
        all_results.append(record)
        if args.output:
            with write_lock:
                _append_record(args.output, record)
        if record.get("error"):
            print(f"{prog}{case['name']}: {record['error']}", flush=True)
            return
        m = record["match"]
        pair = m.get("pairwise_acc")
        pair_s = "n/a" if pair is None else f"{pair:.0%}({m['pairwise_ok']}/{m['pairwise_n']})"
        print(
            f"{prog}{case['name']} "
            f"{format_rpf1(m['recall'], m['precision'], m['f1'])} "
            f"{METRIC_PAIR}={pair_s} miss={m['miss']} extra={m['got_extra']}",
            flush=True,
        )
        if jobs == 1:
            print("  --- prompt ---", flush=True)
            print(record.get("prompt") or "", flush=True)
            print("  --- content ---", flush=True)
            print(record.get("content") or "", flush=True)

    if jobs == 1:
        for item in pending:
            _commit(*_work(item))
    else:
        with ThreadPoolExecutor(max_workers=jobs) as ex:
            futures = [ex.submit(_work, item) for item in pending]
            for fut in as_completed(futures):
                _commit(*fut.result())

    if args.output:
        print(f"\n✅ 结果已写入 {args.output}(共 {len(all_results)} 条)")
    if args.review_md and all_results:
        order = {c["name"]: i for i, c in enumerate(cases)}
        ordered = sorted(
            all_results,
            key=lambda r: order.get(r.get("case") or "", 10**9),
        )
        write_review_markdown(ordered, args.review_md)


if __name__ == "__main__":
    main()