Files
gplan-2/.qoder/skills/simplified-technical-chinese/tools/calibration/label.py
T

194 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""用模型初判抽样结果是真报还是误报。结果供人工抽查,不直接当结论。
用法:
python3 tools/calibration/label.py <输出目录> --env <含 LLM_* 变量的 .env 文件> [--workers 8] [--limit N]
读取 sample.jsonl,对每条样本给出 verdict(真 / 误 / 不确定)和一句理由,写入 labels.jsonl。
断点续跑:labels.jsonl 里已有的 id 跳过。
.env 里需要:LLM_BASE_URL(OpenAI 兼容)、LLM_API_KEY、LLM_MODEL;可选 LLM_EXTRA_JSON(并入请求体)。
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, Optional
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
SYSTEM = """你是一名中文技术文档编辑。有一套写作规范和一个按规范检查文档的脚本。
脚本在一篇真实文档里报了一条提示。请判断这条提示是否成立。
脚本的字数算法(附录 A):每个汉字计 1,每个英文单词或数字串计 1,行内代码(显示为 CODE)计 1,标点不计。
「命中的句子」只显示前 40 个字符,完整句子看上下文。脚本已经把一个段落的多行拼成一句再分句,不会被换行截断。
判定标准:
- 真:规则按字面确实被违反,而且按规则改写后,这句话不会变得更不准确。
- 误:命中的词或句在这里是正常用法、固定术语、引用的界面文字或代码,或者按规则改写会损害意思。
- 不确定:两种判断都说得通。
只输出一行 JSON:{"verdict": "真|误|不确定", "reason": "不超过 40 字的理由"}"""
def load_env(path: str) -> Dict[str, str]:
env: Dict[str, str] = {}
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'") if not v.strip().startswith("{") else v.strip()
return env
def load_rules() -> Dict[str, str]:
"""规范.md 里每条规则的标题和正文(含批准 / 不批准例子)。"""
with open(os.path.join(ROOT, "规范.md"), encoding="utf-8") as f:
text = f.read()
rules: Dict[str, str] = {}
parts = re.split(r"^(?=### \d+\.\d+ )", text, flags=re.M)
for p in parts:
m = re.match(r"### (\d+\.\d+) (.*)", p)
if not m:
continue
body = re.split(r"^## ", p, flags=re.M)[0].strip()
rules[m.group(1)] = body[:1500]
return rules
def load_vocab_rows() -> Dict[str, str]:
"""词表里每个词所在的表格行,供模型看替换建议。"""
rows: Dict[str, str] = {}
with open(os.path.join(ROOT, "词表.md"), encoding="utf-8") as f:
for line in f:
if not line.startswith("|") or "---" in line:
continue
cells = [c.strip() for c in line.strip().strip("|").split("|")]
for w in re.split("[、,]", cells[0]):
if w and w not in rows:
rows[w] = line.strip()
if len(cells) > 2:
for w in re.split("[、,]", cells[2]):
if w and w not in rows:
rows[w] = line.strip()
return rows
def build_prompt(rec: dict, rules: Dict[str, str], vocab: Dict[str, str]) -> str:
parts = [f"## 规则 {rec['rule']}", rules.get(rec["rule"], "(规则正文缺失)")]
if rec.get("word") and rec["word"] in vocab:
parts += ["", "## 词表条目", vocab[rec["word"]]]
parts += ["", "## 脚本的提示", rec["message"], "", "## 命中的句子", rec["text"]]
if rec.get("context"):
parts += ["", "## 上下文(原文,命中行前后各一行)", *rec["context"]]
parts += ["", f"文档来自 {rec['corpus']},路径 {rec['path']}。这条提示成立吗?"]
return "\n".join(parts)
def call(env: Dict[str, str], prompt: str, timeout: int = 90) -> str:
body: dict = {
"model": env["LLM_MODEL"],
"messages": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}],
"temperature": 0,
"max_tokens": 200,
}
if env.get("LLM_EXTRA_JSON"):
body.update(json.loads(env["LLM_EXTRA_JSON"]))
req = urllib.request.Request(
env["LLM_BASE_URL"].rstrip("/") + "/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {env['LLM_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data["choices"][0]["message"]["content"]
def parse_verdict(text: str) -> Optional[dict]:
m = re.search(r"\{.*\}", text, flags=re.S)
if not m:
return None
try:
obj = json.loads(m.group(0))
except json.JSONDecodeError:
return None
v = str(obj.get("verdict", "")).strip()
if v not in ("真", "误", "不确定"):
return None
return {"verdict": v, "reason": str(obj.get("reason", ""))[:120]}
def label_one(env: Dict[str, str], rec: dict, rules: Dict[str, str], vocab: Dict[str, str]) -> dict:
prompt = build_prompt(rec, rules, vocab)
last_err = ""
for attempt in range(3):
try:
raw = call(env, prompt)
parsed = parse_verdict(raw)
if parsed:
return {"id": rec["id"], "rule": rec["rule"], "word": rec.get("word"), **parsed, "model": env["LLM_MODEL"]}
last_err = f"无法解析:{raw[:80]}"
except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as e:
last_err = repr(e)[:120]
time.sleep(2 * (attempt + 1))
return {"id": rec["id"], "rule": rec["rule"], "word": rec.get("word"), "verdict": "错误", "reason": last_err, "model": env["LLM_MODEL"]}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("out_dir")
ap.add_argument("--env", required=True)
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args()
env = load_env(args.env)
for k in ("LLM_BASE_URL", "LLM_API_KEY", "LLM_MODEL"):
if k not in env:
print(f"缺少 {k}", file=sys.stderr)
return 2
rules, vocab = load_rules(), load_vocab_rows()
labels_path = os.path.join(args.out_dir, "labels.jsonl")
done = set()
if os.path.exists(labels_path):
with open(labels_path, encoding="utf-8") as f:
for line in f:
rec = json.loads(line)
if rec.get("verdict") != "错误":
done.add(rec["id"])
todo = []
with open(os.path.join(args.out_dir, "sample.jsonl"), encoding="utf-8") as f:
for line in f:
rec = json.loads(line)
if rec["id"] not in done:
todo.append(rec)
if args.limit:
todo = todo[: args.limit]
print(f"已标 {len(done)},待标 {len(todo)}", file=sys.stderr)
n_ok = n_err = 0
with open(labels_path, "a", encoding="utf-8") as out, ThreadPoolExecutor(args.workers) as pool:
futures = [pool.submit(label_one, env, rec, rules, vocab) for rec in todo]
for i, fut in enumerate(as_completed(futures), 1):
res = fut.result()
out.write(json.dumps(res, ensure_ascii=False) + "\n")
out.flush()
n_err += res["verdict"] == "错误"
n_ok += res["verdict"] != "错误"
if i % 25 == 0:
print(f" {i}/{len(todo)}(失败 {n_err})", file=sys.stderr)
print(f"完成:成功 {n_ok},失败 {n_err}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())