Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
195 lines
6.7 KiB
Python
195 lines
6.7 KiB
Python
"""用一个外部 LLM judge 校准并评估所有候选写作规则。
|
|
|
|
规则本身来自开放式提炼,不携带正则或预置模式。judge 根据规则定义、正反例与
|
|
作用域作语义判定。每条规则必须先在独立人工金标上达到一致率门槛,之后才可激活。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any, Callable, Dict, List, Tuple
|
|
|
|
from llm_client import chat
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
DATA_DIR = ROOT / "data"
|
|
|
|
Verdicts = Dict[Tuple[str, str], Dict[str, Any]]
|
|
JudgeFn = Callable[[List[Dict[str, Any]], List[Dict[str, str]]], Verdicts]
|
|
|
|
_JUDGE_PROMPT = """你是独立的中文写作质量评判者。请逐一判断每段待评文本是否命中每条规则。
|
|
严格依据规则的定义、适用范围和正反例:相似词语本身不等于命中,只有规则所描述的滥用
|
|
确实出现才判 true。不要因为文本来自评估集而猜标签。
|
|
|
|
必须为每个 text_id 与 rule_id 的组合返回一项。只返回 JSON:
|
|
{{"verdicts": [{{"text_id": "...", "rule_id": "...", "hit": true,
|
|
"evidence": "命中时摘录最短证据;未命中时为空字符串"}}]}}
|
|
|
|
规则:
|
|
{rules}
|
|
|
|
待评文本:
|
|
{texts}
|
|
"""
|
|
|
|
|
|
def _strip_json_fence(content: str) -> str:
|
|
return re.sub(r"^```(?:json)?\s*|\s*```$", "", content.strip(), flags=re.I)
|
|
|
|
|
|
def llm_judge(
|
|
*,
|
|
provider: str,
|
|
model: str | None = None,
|
|
seed: int = 8901,
|
|
receipts: List[Dict[str, Any]] | None = None,
|
|
) -> JudgeFn:
|
|
"""创建批量 LLM judge;一次调用可判定多条规则或多段文本。"""
|
|
|
|
def judge(rules: List[Dict[str, Any]], texts: List[Dict[str, str]]) -> Verdicts:
|
|
brief_rules = [
|
|
{
|
|
"id": rule["id"],
|
|
"name": rule["name"],
|
|
"definition": rule["definition"],
|
|
"bad_example": rule.get("bad_example", ""),
|
|
"good_example": rule.get("good_example", ""),
|
|
"scope": rule.get("scope", []),
|
|
}
|
|
for rule in rules
|
|
]
|
|
content, receipt = chat(
|
|
[{
|
|
"role": "user",
|
|
"content": _JUDGE_PROMPT.format(
|
|
rules=json.dumps(brief_rules, ensure_ascii=False, indent=2),
|
|
texts=json.dumps(texts, ensure_ascii=False, indent=2),
|
|
),
|
|
}],
|
|
provider=provider,
|
|
model=model,
|
|
seed=seed,
|
|
# Responses API 的 max_output_tokens 也覆盖 reasoning tokens;小上限会让
|
|
# JSON 在中途被截断。给 reasoning 与每个 verdict 都留出明确余量。
|
|
max_tokens=max(2000, 1000 + len(rules) * len(texts) * 120),
|
|
)
|
|
if receipts is not None:
|
|
receipts.append(receipt)
|
|
|
|
expected = {(rule["id"], text["id"]) for rule in rules for text in texts}
|
|
try:
|
|
payload = json.loads(_strip_json_fence(content))
|
|
except (json.JSONDecodeError, AttributeError):
|
|
return {
|
|
key: {"hit": False, "evidence": "", "parse_error": True}
|
|
for key in expected
|
|
}
|
|
|
|
verdicts: Verdicts = {}
|
|
for item in payload.get("verdicts", []):
|
|
key = (str(item.get("rule_id", "")), str(item.get("text_id", "")))
|
|
if key not in expected or not isinstance(item.get("hit"), bool):
|
|
continue
|
|
verdicts[key] = {
|
|
"hit": item["hit"],
|
|
"evidence": str(item.get("evidence", "")),
|
|
}
|
|
for key in expected - set(verdicts):
|
|
verdicts[key] = {"hit": False, "evidence": "", "missing": True}
|
|
return verdicts
|
|
|
|
return judge
|
|
|
|
|
|
def _calibration_cases(
|
|
rule: Dict[str, Any], golden_set: List[Dict[str, Any]]
|
|
) -> List[Dict[str, Any]]:
|
|
sources = set(rule.get("source_ids", []))
|
|
cases = []
|
|
for item in golden_set:
|
|
matching = [
|
|
label["expected"]
|
|
for label in item.get("labels", [])
|
|
if sources & set(label.get("source_ids", []))
|
|
]
|
|
if not matching:
|
|
continue
|
|
if len(set(matching)) != 1:
|
|
raise ValueError(f"金标 {item['id']} 对规则 {rule['id']} 给出了冲突标签")
|
|
cases.append({"id": item["id"], "text": item["text"], "expected": matching[0]})
|
|
return cases
|
|
|
|
|
|
def calibrate(
|
|
rule: Dict[str, Any],
|
|
golden_set: List[Dict[str, Any]],
|
|
judge_fn: JudgeFn,
|
|
*,
|
|
threshold: float = 0.8,
|
|
) -> Dict[str, Any]:
|
|
"""用与候选来源关联的独立人工金标校准一条规则。"""
|
|
labeled = _calibration_cases(rule, golden_set)
|
|
texts = [{"id": item["id"], "text": item["text"]} for item in labeled]
|
|
verdicts = judge_fn([rule], texts) if texts else {}
|
|
cases = []
|
|
agree = 0
|
|
for item in labeled:
|
|
verdict = verdicts.get((rule["id"], item["id"]), {"hit": False, "missing": True})
|
|
got = bool(verdict["hit"])
|
|
ok = got == item["expected"] and not verdict.get("missing") and not verdict.get("parse_error")
|
|
agree += int(ok)
|
|
cases.append({
|
|
"id": item["id"],
|
|
"expected": item["expected"],
|
|
"judged": got,
|
|
"evidence": verdict.get("evidence", ""),
|
|
"missing": bool(verdict.get("missing")),
|
|
"parse_error": bool(verdict.get("parse_error")),
|
|
"agree": ok,
|
|
})
|
|
total = len(cases)
|
|
agreement = agree / total if total else 0.0
|
|
decision = "activate" if total and agreement >= threshold else "reject"
|
|
return {
|
|
"rule_id": rule["id"],
|
|
"cases": cases,
|
|
"total": total,
|
|
"agree": agree,
|
|
"agreement": agreement,
|
|
"threshold": threshold,
|
|
"decision": decision,
|
|
"note": (
|
|
"judge 与独立人工金标的一致率达到阈值,允许上线"
|
|
if decision == "activate"
|
|
else "金标覆盖不足或一致率低于阈值,拒绝上线该规则"
|
|
),
|
|
}
|
|
|
|
|
|
def score_text(
|
|
text: str,
|
|
rules: List[Dict[str, Any]],
|
|
judge_fn: JudgeFn,
|
|
*,
|
|
text_id: str = "target",
|
|
) -> Dict[str, Any]:
|
|
"""用 LLM judge 对全部 active 规则一次性打分。"""
|
|
if not rules:
|
|
return {}
|
|
verdicts = judge_fn(rules, [{"id": text_id, "text": text}])
|
|
result: Dict[str, Any] = {}
|
|
for rule in rules:
|
|
verdict = verdicts.get((rule["id"], text_id), {})
|
|
if verdict.get("hit"):
|
|
result[rule["id"]] = {
|
|
"verdict": True,
|
|
"evidence": verdict.get("evidence", ""),
|
|
}
|
|
return result
|
|
|
|
|
|
def load_golden_set(path: Path | None = None) -> List[Dict[str, Any]]:
|
|
return json.loads((path or DATA_DIR / "golden_set.json").read_text(encoding="utf-8"))
|