Files
Jyotisha/scripts/research/jev_intent_corpus_build.py
T
jesse-ux c2ecbc74cd
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 1m37s
research(jev-intent): 修复轮重造语料并全量对照
来源 C 改为 DeepSeek Flash 生成+独立复核,撤回模板拼接结论。来源 B 157 条人工标注后跑 Jev x2 与 Flash 全量对照,结论为缺数据。
2026-09-19 11:30:31 +08:00

1624 lines
63 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
"""Build the offline Jev intent-classifier corpus (T0).
Source A: ten fixtures derived from the production classifier test file.
Source B: real staging turns, written outside git (not this script's default).
Source C: model-simulated replies. Labels are assigned first; an independent
reviewer that cannot see the target label keeps only agreements.
Does not call production `classifyRectificationTurnIntent`. Celebrity names
from the public holdout file never enter user_message.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import random
import re
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
from pathlib import Path
from typing import Any, Iterable, Mapping, Sequence
from uuid import NAMESPACE_URL, uuid5
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.rectification.probe_question_contract import ( # noqa: E402
EXISTENCE_STYLE_OPTIONS,
QUALITY_STYLE_OPTIONS,
)
from scripts.research.jev_intent_questions import ( # noqa: E402
ANSWER_CLASS_VALUES,
enforce_combo,
)
HOLDOUT = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v4.json"
SAMPLES_DIR = ROOT / "scripts" / "research" / "jev_intent_samples"
POOL_PATH = SAMPLES_DIR / "question_pool.json"
SYNTHETIC_PATH = SAMPLES_DIR / "synthetic.jsonl"
SIMULATED_PATH = SAMPLES_DIR / "simulated.jsonl"
DISPUTED_PATH = SAMPLES_DIR / "disputed.jsonl"
README_PATH = SAMPLES_DIR / "README.md"
CACHE_DIR = Path(r"G:\Ferti\Jyotisha\.cache\jev_intent")
TODAY = date(2026, 9, 19)
SEED = 20260919
PROMPT_VERSION = "gen-review-v3"
DEFAULT_MODEL = "deepseek-flash"
DEFAULT_BASE = "https://api.deepseek.com"
GENERATOR_NAME = os.environ.get("DEEPSEEK_MODEL") or DEFAULT_MODEL
REVIEWER_NAME = os.environ.get("DEEPSEEK_MODEL") or DEFAULT_MODEL
DISPUTE_RATE_LIMIT = 0.15
GEN_TEMPERATURE = 0.85
REVIEW_TEMPERATURE = 0.0
MAX_GEN_RETRIES = 3
COLLECT_PROMPTS: dict[str, str] = {
"relationship": "感情这边,还记得哪年认真在一起、分开,或结婚吗?有年份就很好。",
"career": "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
"family": "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。",
"education": "上学这边,还记得哪年升学、转学或大考吗?",
"relocation": "有没有哪年搬家,或开始长期住在外地?",
"finance": "钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?",
"health_pressure": "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
}
COLLECT_RETRY: dict[str, str] = {
"relationship": "回到感情这边——刚才说的工作我记下了,哪年认真在一起或分开还记得吗?",
"career": "回到工作这边——刚才那件我记下了,哪年入职或换工作还记得吗?",
"family": "再问一次家里:结婚、添丁或住院,大概哪年?",
"education": "回到上学这边——哪年升学、转学或大考,还记得吗?",
"relocation": "搬家或开始长期住外地,大概是哪年?",
"finance": "钱的方面再对一下:哪年收入明显变过、有过大笔支出,或欠过债?",
"health_pressure": "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
}
TARGETED_YEAR_PROMPT = "大概哪年几月?"
SHUFFLED_CHOICE_PROMPT = "2023 年前后,工作状态是否出现明显变化?"
SHUFFLED_OPTIONS = [
{"key": "A", "label": "这段时间没有明显变化", "answer_class": "no"},
{"key": "B", "label": "记不清当时的情况", "answer_class": "unsure"},
{"key": "C", "label": "变化明显而且时间吻合", "answer_class": "yes"},
{"key": "D", "label": "有变化但程度比较弱", "answer_class": "weak_yes"},
]
COLLECT_FINANCE_PROMPT = "钱的方面,还记得哪年收入明显变过吗?"
PERSONAS = (
"terse",
"rambling",
"answer_then_event",
"deny_then_event",
"off_topic",
"impatient_stop",
"ask_result",
"dialect_netspeak",
)
QUOTAS: dict[str, list[tuple[str, str | None, bool, int]]] = {
"choice": [
("answer_current_focus", "yes", False, 45),
("answer_current_focus", "yes", True, 15),
("answer_current_focus", "weak_yes", False, 45),
("answer_current_focus", "weak_yes", True, 15),
("answer_current_focus", "no", False, 30),
("answer_current_focus", "no", True, 30),
("answer_current_focus", "unsure", False, 45),
("answer_current_focus", "unsure", True, 15),
("provide_new_evidence", None, True, 60),
("stop_rectification", None, False, 32),
("ask_about_result", None, False, 32),
("unclear", None, False, 36),
],
"collect": [
("answer_current_focus", "no", False, 80),
("answer_current_focus", "no", True, 20),
("answer_current_focus", "unsure", False, 80),
("answer_current_focus", "yes", False, 34),
("answer_current_focus", "yes", True, 16),
("answer_current_focus", "weak_yes", False, 33),
("answer_current_focus", "weak_yes", True, 17),
("provide_new_evidence", None, True, 48),
("stop_rectification", None, False, 24),
("ask_about_result", None, False, 24),
("unclear", None, False, 24),
],
"none": [
("provide_new_evidence", None, True, 50),
("stop_rectification", None, False, 15),
("ask_about_result", None, False, 15),
("unclear", None, False, 20),
],
}
PERSONA_SPECS: dict[str, str] = {
"terse": "惜字如金。句子短,像随手回一句,不用语气词堆砌。",
"rambling": "口语啰嗦,会绕半句再落到要点,但仍要把必需要素说清楚。少用「嗯/啊/emmm」。",
"answer_then_event": "先用一两句回答当前问题,再用「另外/对了」补一件别的带年份的事。",
"deny_then_event": "先明确否定当前问题,再补一件别的带年份的事。",
"off_topic": "答非所问,说一件无关的小事。不要提校正结果,不要要求停止,不要报带年份的经历。",
"impatient_stop": "不耐烦,明确说先停、不弄了、停止整套出生时间校正。不要评价当前这件事有没有发生。",
"ask_result": "追问校正结果、时间范围、候选分钟或好了没。不要回答当前这道题。",
"dialect_netspeak": "带一点口语或网络说法,但仍能读懂在说什么。不要整句都是无意义的梗。",
}
INTENT_NL: dict[str, str] = {
"answer_current_focus": "这句话是在直接回答当前这道题,不是另起一件无关的事,不是要求停止整套校正,也不是在问结果。",
"provide_new_evidence": "这句话没有回答当前这道题,只是另说了一件带大概年份或月份的经历。",
"stop_rectification": "这句话明确要求停止整个出生时间校正。说当前这件事没发生,不算停止。",
"ask_about_result": "这句话在问校正结果、时间范围、候选分钟,或好了没有。不是在答当前题。",
"unclear": "语义不清或答非所问,无法归入回答当前题、补充新经历、停止校正、询问结果。不要提到年份或月份,不要说停,不要问结果。",
}
ANSWER_NL: dict[str | None, str] = {
"yes": "明确肯定当前问题。点选题对应「明显发生 / 变化明显」;采集题必须用带年月的经历直接回答这道采集题。",
"weak_yes": "有发生或有变化,但程度弱、不太明显、印象不深。不要说完全没有,也不要说非常明显。",
"no": "明确否定当前问题:没有发生、没有变化、这方面没什么。不要再补一句「其实也有一点」。",
"unsure": "记不清、不记得、忘了、想不起来、以后再说。不要给出确定的有或没有。",
None: "不要对当前问题给出肯定、否定或记不清的态度。",
}
DATED_NL: dict[bool, str] = {
True: (
"同一句话里,除了完成上面的意图,还必须另说一件带大概年份的经历,"
"并且那件事不是对当前问题的直接回答。用「另外 / 对了 / 不过」连接。"
"必须出现四位数字年份,例如「2019年」或「2019年3月」。没有四位年份就不合格。"
),
False: (
"不要另说一件带年份或月份的新经历。"
"若当前是采集题且态度是肯定或弱肯定,可以在回答里带上这件事本身的年月,"
"那是在答当前题,不是另说一件;这种情况下也必须出现四位数字年份。"
"若不是采集题的肯定回答,不要写任何 19xx / 20xx 年份。"
),
}
ALT_EVENT_HINTS: dict[str, str] = {
"education": "工作变动或搬家",
"career": "搬家或升学",
"relocation": "换工作或家里的事",
"relationship": "工作或搬家",
"family": "工作或搬家",
"finance": "换工作或搬家",
"health_pressure": "换工作或搬家",
}
YEAR_RE = re.compile(r"(?:19|20)\d{2}")
MONTH_RE = re.compile(r"\d{1,2}\s*月")
GENERATE_SYSTEM = (
"你在模拟一位真人用户,正在做出生时间校正问答。只输出用户会打的那句话,"
"不要解释,不要加引号,不要输出 JSON。"
)
REVIEW_SYSTEM = (
"你只做出生时间校正当前轮的意图分类。不回答用户,不修改状态。"
"只输出一个 JSON 对象,不要解释。"
)
REVIEW_RUBRIC = """根据当前问题和用户回复分类。看不到任何预设标签。
intent 只能是:
- answer_current_focus:用户在回答当前问题
- provide_new_evidence:没有回答当前问题,只补充了一件带大概年或月的经历
- stop_rectification:明确要求停止整个校正;说当前这件事没发生不是停止
- ask_about_result:在问结果、范围、候选分钟或好了没
- unclear:以上都不成立,或混在一起无法单选
answer_class
- 仅当 intent 是 answer_current_focus 时非空,否则必须为 null
- 点选题:按选项 label 的含义选 yes / weak_yes / no / unsure,不要按 A/B/C/D 位置猜
- 采集题:没有/没发生/这方面没什么 → no;记不清/不记得/忘了/想不起来/以后再说 → unsure;
用带大概年月的经历直接回答当前采集题 → yes(程度弱则为 weak_yes
- 不要把「没有」或「记不清」标成 yes
- 采集题若既没有否定、也没有记不清、也没有带年月经历 → intent 为 unclearanswer_class 为 null
- 同一句既明确否定当前采集题又补充了新的带时间经历:intent 仍为 answer_current_focusanswer_class 为 no
has_new_dated_event
- 仅在同一句里除了回答当前问题之外,还提供了新的、带大概时间的经历时为 true
- 单纯否定、单纯记不清、或只用带年月经历来直接回答当前采集题,都是 false
- 无当前问题时,带年月的经历 → intent 为 provide_new_evidencehas_new_dated_event 为 true
只输出:
{"intent":"...","answer_class":"yes"|"weak_yes"|"no"|"unsure"|null,"has_new_dated_event":true|false}
"""
_CACHE_LOCK = threading.Lock()
_LLM_CACHE: dict[str, Any] = {}
_USAGE = {
"generate_input_tokens": 0,
"generate_output_tokens": 0,
"review_input_tokens": 0,
"review_output_tokens": 0,
"generate_calls": 0,
"review_calls": 0,
"generate_cached": 0,
"review_cached": 0,
}
def current_model_id() -> str:
return os.environ.get("DEEPSEEK_MODEL") or DEFAULT_MODEL
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
digest.update(path.read_bytes())
return digest.hexdigest()
def write_jsonl(path: Path, rows: Sequence[Mapping[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
text = "".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in rows)
path.write_text(text, encoding="utf-8")
def holdout_name_denylist() -> set[str]:
payload = json.loads(HOLDOUT.read_text(encoding="utf-8"))
names: set[str] = set()
for case in payload.get("cases") or []:
label = str(case.get("subject_label") or "").strip()
if label:
names.add(label)
for part in label.split():
if len(part) >= 3:
names.add(part)
return names
def assert_no_pii(message: str, denylist: set[str]) -> None:
for name in denylist:
if name and name in message:
raise ValueError(f"simulated reply leaked holdout name {name!r}")
def source_a_samples() -> list[dict[str, Any]]:
"""Ten fixtures from the test file's schema + the two userMessage strings."""
choice_focus = {
"kind": "choice",
"current_question": SHUFFLED_CHOICE_PROMPT,
"options": SHUFFLED_OPTIONS,
"case_status": "distinguishing_candidates",
}
collect_focus = {
"kind": "collect",
"current_question": COLLECT_FINANCE_PROMPT,
"options": [],
"case_status": "collecting_evidence",
}
rows = [
("A01", "choice", choice_focus, "没什么变化", "answer_current_focus", "no", False),
("A02", "choice", choice_focus, "记不清", "answer_current_focus", "unsure", False),
("A03", "choice", choice_focus, "变化很明显", "answer_current_focus", "yes", False),
("A04", "choice", choice_focus, "有一点变化", "answer_current_focus", "weak_yes", False),
("A05", "choice", choice_focus, "没有,不过 2019 年换过岗", "answer_current_focus", "no", True),
("A06", "choice", choice_focus, "先不弄了", "stop_rectification", None, False),
("A07", "choice", choice_focus, "结果出来了吗", "ask_about_result", None, False),
("A08", "collect", collect_focus, "没有", "answer_current_focus", "no", False),
("A09", "collect", collect_focus, "随便吧", "unclear", None, False),
("A10", "collect", collect_focus, "2016年3月入学", "answer_current_focus", "yes", False),
]
samples: list[dict[str, Any]] = []
for sid, layer, focus, message, intent, answer_class, dated in rows:
gold = enforce_combo(intent, answer_class, dated)
samples.append({
"id": sid,
"source": "A",
"layer": layer,
"persona": "test_fixture",
"focus": focus,
"user_message": message,
"gold": gold,
"generator": "test-file",
"reviewer": "manual",
})
return samples
def _hhmm(value: object) -> str | None:
text = str(value or "")[:5]
return text if len(text) == 5 and text[2] == ":" else None
def _probe_prompt(probe: Mapping[str, Any]) -> str:
family = str(probe.get("event_family") or "").replace("", "").replace("?", "").strip()
label = str(probe.get("year_label") or "").strip()
kind = str(probe.get("choice_kind") or "existence")
meaning = str(probe.get("user_meaning") or "").strip()
if kind == "event_quality" and meaning:
from scripts.rectification.probe_question_contract import clipped_probe_label
clipped = clipped_probe_label(meaning) or meaning
if clipped.endswith("") or clipped.endswith("?"):
return clipped
return f"{label},有没有{family}的时候?" if label and family else clipped
if not family:
return label
if kind == "event_quality":
return f"{label},有没有{family}的时候?" if label else f"有没有{family}的时候?"
return f"{label},有没有{family}" if label else f"有没有{family}"
def _probe_options(probe: Mapping[str, Any]) -> list[dict[str, str]]:
raw = probe.get("style_options") or []
rows: list[dict[str, str]] = []
keys = ("A", "B", "C", "D")
for item in raw:
if not isinstance(item, dict):
continue
answer_class = str(item.get("answer_class") or "")
label = str(item.get("label") or "").strip()
if answer_class not in ANSWER_CLASS_VALUES or not label:
continue
rows.append({"key": keys[len(rows)], "label": label, "answer_class": answer_class})
if len(rows) == 4:
break
if len(rows) != 4:
catalog = QUALITY_STYLE_OPTIONS if str(probe.get("choice_kind") or "") == "event_quality" else EXISTENCE_STYLE_OPTIONS
rows = [
{"key": keys[i], "label": str(item["label"]), "answer_class": str(item["answer_class"])}
for i, item in enumerate(catalog)
]
return rows
def build_question_pool(*, max_cases: int | None = None) -> dict[str, Any]:
from scripts.active_rectification_event_engine import AYANAMSA, NODE_MODE
from scripts.rectification.event_probes import discriminating_event_probes
from scripts.rectification.refinement_packet import window_scan
from scripts.rectification.scoring_service import (
build_event_contribution_matrix,
score_from_matrix,
scoreable_request,
)
from scripts.research.probe_supply_after_six import request_from_case
payload = json.loads(HOLDOUT.read_text(encoding="utf-8"))
cases = list(payload.get("cases") or [])
if max_cases is not None:
cases = cases[:max_cases]
choice_questions: list[dict[str, Any]] = []
seen: set[str] = set()
errors: list[str] = []
for case in cases:
case_id = str(case.get("case_id") or "case")
try:
request = request_from_case(case)
request["ayanamsa"] = AYANAMSA
request["node_mode"] = NODE_MODE
scoring = scoreable_request(request)
built = build_event_contribution_matrix(scoring)
rows = score_from_matrix(scoring, built)
times = [stamp for row in rows if (stamp := _hhmm(row.get("time")))]
true_time = str(case["birth"]["time"])[:5]
probes = discriminating_event_probes(
{**request, "refresh_probes": False, "asked_probe_keys": []},
built,
scan=window_scan(built),
candidate_times=times,
representative_time=true_time,
today=TODAY,
)
except Exception as exc: # noqa: BLE001 — keep the other 19 cases
errors.append(f"{case_id}: {type(exc).__name__}")
continue
kept = 0
for probe in probes:
if not isinstance(probe, dict):
continue
prompt = _probe_prompt(probe)
options = _probe_options(probe)
key = f"{prompt}|{','.join(item['answer_class'] + ':' + item['label'] for item in options)}"
if key in seen or not prompt:
continue
seen.add(key)
choice_questions.append({
"id": str(uuid5(NAMESPACE_URL, key)),
"layer": "choice",
"current_question": prompt,
"options": options,
"domain": str(probe.get("domain") or ""),
"choice_kind": str(probe.get("choice_kind") or "existence"),
"year_label": str(probe.get("year_label") or ""),
"event_family": str(probe.get("event_family") or ""),
"case_status": "distinguishing_candidates",
})
kept += 1
print(f"pool {case_id} probes={len(probes)} unique={kept}", flush=True)
collect_questions: list[dict[str, Any]] = []
for domain, prompt in COLLECT_PROMPTS.items():
collect_questions.append({
"id": f"collect:{domain}",
"layer": "collect",
"current_question": prompt,
"options": [],
"domain": domain,
"case_status": "collecting_evidence",
})
collect_questions.append({
"id": f"collect-retry:{domain}",
"layer": "collect",
"current_question": COLLECT_RETRY[domain],
"options": [],
"domain": domain,
"case_status": "collecting_evidence",
})
collect_questions.append({
"id": "collect:targeted-year",
"layer": "collect",
"current_question": TARGETED_YEAR_PROMPT,
"options": [],
"domain": "career",
"case_status": "collecting_evidence",
})
none_questions = [{
"id": "none:open",
"layer": "none",
"current_question": "",
"options": [],
"domain": "",
"case_status": "collecting_evidence",
}]
pool = {
"choice": choice_questions,
"collect": collect_questions,
"none": none_questions,
"errors": errors,
"holdout": str(HOLDOUT.relative_to(ROOT)).replace("\\", "/"),
"generator_note": "choice prompts from discriminating_event_probes; collect copy from user-copy.ts",
}
return pool
def _option_labels(focus: Mapping[str, Any]) -> list[str]:
return [str(item.get("label") or "") for item in (focus.get("options") or []) if item.get("label")]
def gold_equal(left: Mapping[str, Any], right: Mapping[str, Any]) -> bool:
return (
left.get("intent") == right.get("intent")
and left.get("answer_class") == right.get("answer_class")
and bool(left.get("has_new_dated_event")) == bool(right.get("has_new_dated_event"))
)
def _pick_question(pool: Mapping[str, Any], layer: str, rng: random.Random) -> dict[str, Any]:
rows = list(pool.get(layer) or [])
if not rows:
raise RuntimeError(f"empty question pool for layer={layer}")
return dict(rng.choice(rows))
def _has_year_or_month(message: str) -> bool:
return bool(YEAR_RE.search(message) or MONTH_RE.search(message))
def llm_cache_path() -> Path:
return CACHE_DIR / "llm_cache.json"
def load_llm_cache() -> None:
path = llm_cache_path()
if not path.is_file():
return
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
if isinstance(payload, dict):
_LLM_CACHE.update(payload)
def save_llm_cache() -> None:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
with _CACHE_LOCK:
llm_cache_path().write_text(json.dumps(_LLM_CACHE, ensure_ascii=False), encoding="utf-8")
(CACHE_DIR / "llm_usage.json").write_text(json.dumps(_USAGE, ensure_ascii=False, indent=2), encoding="utf-8")
def _parse_model_json(text: str) -> dict[str, Any]:
raw = (text or "").strip()
if raw.startswith("```"):
raw = raw.strip("`")
if raw.lower().startswith("json"):
raw = raw[4:]
raw = raw.strip()
start = raw.find("{")
end = raw.rfind("}")
if start < 0 or end <= start:
raise ValueError("no_json_object")
payload = json.loads(raw[start:end + 1])
if not isinstance(payload, dict):
raise ValueError("json_not_object")
return payload
def _strip_reply(text: str) -> str:
raw = (text or "").strip()
if raw.startswith("```"):
raw = raw.strip("`").strip()
if raw.lower().startswith("text"):
raw = raw[4:].strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "“", "'"}:
raw = raw[1:-1].strip()
return " ".join(raw.split())
def complete_chat(
*,
system: str,
user: str,
temperature: float,
json_mode: bool,
cache_ns: str,
max_tokens: int = 256,
timeout: float = 60.0,
) -> dict[str, Any]:
model = current_model_id()
key_payload = {
"ns": cache_ns,
"prompt_version": PROMPT_VERSION,
"model": model,
"temperature": temperature,
"json_mode": json_mode,
"system": system,
"user": user,
}
cache_key = hashlib.sha256(json.dumps(key_payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
with _CACHE_LOCK:
cached = _LLM_CACHE.get(cache_key)
if isinstance(cached, dict) and cached.get("text"):
_USAGE[f"{cache_ns}_cached"] = int(_USAGE.get(f"{cache_ns}_cached") or 0) + 1
return cached
api_key = os.environ.get("DEEPSEEK_API_KEY") or ""
if not api_key:
raise RuntimeError("DEEPSEEK_API_KEY missing")
base = (os.environ.get("DEEPSEEK_BASE_URL") or DEFAULT_BASE).rstrip("/")
body: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": temperature,
"max_tokens": max_tokens,
"thinking": {"type": "disabled"},
"stream": False,
}
if json_mode:
body["response_format"] = {"type": "json_object"}
request = urllib.request.Request(
f"{base}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
method="POST",
)
delay = 1.0
last_error = "unknown"
for _attempt in range(6):
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
content = (((payload.get("choices") or [{}])[0].get("message") or {}).get("content")) or ""
usage = payload.get("usage") or {}
result = {
"text": content,
"model": payload.get("model") or model,
"input_tokens": usage.get("prompt_tokens") or usage.get("input_tokens") or 0,
"output_tokens": usage.get("completion_tokens") or usage.get("output_tokens") or 0,
}
with _CACHE_LOCK:
_LLM_CACHE[cache_key] = result
_USAGE[f"{cache_ns}_calls"] = int(_USAGE.get(f"{cache_ns}_calls") or 0) + 1
_USAGE[f"{cache_ns}_input_tokens"] = int(_USAGE.get(f"{cache_ns}_input_tokens") or 0) + int(result["input_tokens"] or 0)
_USAGE[f"{cache_ns}_output_tokens"] = int(_USAGE.get(f"{cache_ns}_output_tokens") or 0) + int(result["output_tokens"] or 0)
return result
except urllib.error.HTTPError as exc:
last_error = f"HTTP{exc.code}"
if exc.code in {429, 500, 502, 503, 529}:
time.sleep(delay)
delay = min(delay * 2, 20)
continue
raise
except Exception as exc: # noqa: BLE001
last_error = type(exc).__name__
time.sleep(delay)
delay = min(delay * 2, 20)
raise RuntimeError(f"chat_failed:{last_error}")
def build_generate_prompt(
question: Mapping[str, Any],
*,
intent: str,
answer_class: str | None,
dated: bool,
persona: str,
length_lo: int,
length_hi: int,
) -> str:
options = list(question.get("options") or [])
option_lines = []
for item in options:
option_lines.append(
f"- {item.get('key')}. {item.get('label')}(对应态度:{item.get('answer_class')}"
)
option_block = "\n".join(option_lines) if option_lines else "(本题没有点选选项)"
extra = ""
if dated:
extra = f"另说的那件事请用「{ALT_EVENT_HINTS.get(str(question.get('domain') or ''), '另一件生活事')}」,不要跟当前问题同一件事。"
return f"""当前问题:
{question.get('current_question') or '(当前没有焦点问题)'}
选项:
{option_block}
你必须体现的语义(不要写出这些标签名,不要解释标签):
- 这句话的作用:{INTENT_NL[intent]}
- 对当前问题的态度:{ANSWER_NL[answer_class]}
- 是否另说一件带年月的新经历:{DATED_NL[dated]}
{extra}
人设:{PERSONA_SPECS[persona]}
硬约束:
- 只输出用户会打的那句话
- 字数约 {length_lo}{length_hi} 个汉字(含标点),不要明显超出
- 不得逐字复述上面的选项原文
- 不得出现真实姓名、地名、学校名、公司名、医院名、单位名
- 不要用「有的 / 发生了 / 就是那段 / 没这回事」这种套话,写得像真人随手打的
- 需要带时间时,用大概年份或年月,事件用普通生活经历
"""
def build_review_prompt(sample: Mapping[str, Any]) -> str:
focus = sample.get("focus") if isinstance(sample.get("focus"), dict) else {}
options = list(focus.get("options") or [])
option_lines = []
for item in options:
option_lines.append(f"- {item.get('key')}. {item.get('label')} (answer_class={item.get('answer_class')})")
option_block = "\n".join(option_lines) if option_lines else "(无选项)"
return f"""{REVIEW_RUBRIC}
当前问题:
{focus.get('current_question') or '(无)'}
选项:
{option_block}
用户回复:
{sample.get('user_message') or ''}
"""
def review_sample(sample: Mapping[str, Any]) -> dict[str, Any]:
"""Independent pass: model JSON label, no access to target labels."""
prompt = build_review_prompt(sample)
raw = complete_chat(
system=REVIEW_SYSTEM,
user=prompt,
temperature=REVIEW_TEMPERATURE,
json_mode=True,
cache_ns="review",
max_tokens=256,
)
parsed = _parse_model_json(raw["text"])
return enforce_combo(
parsed.get("intent"),
parsed.get("answer_class"),
parsed.get("has_new_dated_event"),
)
def _message_ok(
message: str,
*,
question: Mapping[str, Any],
intent: str,
answer_class: str | None,
dated: bool,
length_lo: int,
length_hi: int,
denylist: set[str],
) -> str | None:
if not message or len(message) < 2:
return "too_short"
try:
assert_no_pii(message, denylist)
except ValueError:
return "pii"
for label in _option_labels(question):
if label and label in message:
return "copied_option"
n = len(message)
if n < max(6, length_lo - 8) or n > length_hi + 24:
return "length"
has_date = _has_year_or_month(message)
collect_yes = (
str(question.get("layer") or "") == "collect"
and intent == "answer_current_focus"
and answer_class in {"yes", "weak_yes"}
)
if dated and not has_date:
return "missing_date"
if intent == "provide_new_evidence" and not has_date:
return "missing_date"
if collect_yes and not has_date:
return "collect_yes_needs_date"
if intent in {"stop_rectification", "ask_about_result", "unclear"} and not dated and has_date:
return "unexpected_date"
if intent == "answer_current_focus" and answer_class in {"no", "unsure"} and not dated and has_date:
return "unexpected_date"
return None
def generate_reply(
question: Mapping[str, Any],
*,
intent: str,
answer_class: str | None,
dated: bool,
persona: str,
length_lo: int,
length_hi: int,
attempt: int,
last_reason: str | None = None,
) -> str:
user = build_generate_prompt(
question,
intent=intent,
answer_class=answer_class,
dated=dated,
persona=persona,
length_lo=length_lo,
length_hi=length_hi,
)
if dated or intent == "provide_new_evidence" or (
str(question.get("layer") or "") == "collect"
and intent == "answer_current_focus"
and answer_class in {"yes", "weak_yes"}
):
user += "\n再次强调:回复里必须出现一个四位数字年份(19xx 或 20xx)。"
if attempt:
user += f"\n这是第 {attempt + 1} 次重写,请换一种说法,不要重复上次的句子。"
if last_reason in {"missing_date", "collect_yes_needs_date"}:
user += "上次缺少四位年份,这次必须写上例如 2017年3月。"
elif last_reason == "unexpected_date":
user += "上次多写了年份,这次不要出现 19xx 或 20xx。"
elif last_reason == "copied_option":
user += "上次逐字复述了选项原文,这次换自己的话。"
elif last_reason == "length":
user += "上次字数不合要求,这次严格落在指定字数区间。"
raw = complete_chat(
system=GENERATE_SYSTEM,
user=user,
temperature=GEN_TEMPERATURE,
json_mode=False,
cache_ns="generate",
max_tokens=220,
)
return _strip_reply(raw["text"])
def length_stats(messages: Iterable[str]) -> dict[str, float]:
lengths = sorted(len(item) for item in messages)
if not lengths:
return {"n": 0, "p25": 0, "median": 0, "p75": 0}
def pct(p: float) -> float:
if len(lengths) == 1:
return float(lengths[0])
idx = int(round((len(lengths) - 1) * p))
return float(lengths[idx])
return {
"n": len(lengths),
"p25": pct(0.25),
"median": pct(0.5),
"p75": pct(0.75),
"min": float(lengths[0]),
"max": float(lengths[-1]),
}
def quota_deviation(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
by_layer: dict[str, Counter[tuple[str, str | None, bool]]] = {
"choice": Counter(),
"collect": Counter(),
"none": Counter(),
}
for row in rows:
layer = str(row.get("layer") or "")
gold = row.get("gold") or {}
if layer not in by_layer:
continue
by_layer[layer][(
str(gold.get("intent")),
gold.get("answer_class"),
bool(gold.get("has_new_dated_event")),
)] += 1
report: dict[str, Any] = {}
for layer, spec in QUOTAS.items():
target_total = sum(item[3] for item in spec)
actual_total = sum(by_layer[layer].values())
cells = []
for intent, answer_class, dated, count in spec:
actual = by_layer[layer][(intent, answer_class, dated)]
share_target = count / target_total if target_total else 0
share_actual = actual / actual_total if actual_total else 0
cells.append({
"intent": intent,
"answer_class": answer_class,
"has_new_dated_event": dated,
"target": count,
"actual": actual,
"share_delta": share_actual - share_target,
})
max_delta = max((abs(cell["share_delta"]) for cell in cells), default=0.0)
report[layer] = {
"target_total": target_total,
"actual_total": actual_total,
"max_share_delta": max_delta,
"within_20pct": max_delta <= 0.20,
"cells": cells,
}
return report
def persona_stats(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
scoped = [row for row in rows if row.get("layer") in {"choice", "collect"}]
counts = Counter(str(row.get("persona") or "") for row in scoped)
terse_dialect = counts.get("terse", 0) + counts.get("dialect_netspeak", 0)
n = max(len(rows), 1)
n_scoped = max(len(scoped), 1)
return {
"choice_collect": dict(counts),
"all": dict(Counter(str(row.get("persona") or "") for row in rows)),
"each_ge_40": all(counts.get(name, 0) >= 40 for name in PERSONAS),
"terse_dialect_share_all": (Counter(str(row.get("persona") or "") for row in rows).get("terse", 0)
+ Counter(str(row.get("persona") or "") for row in rows).get("dialect_netspeak", 0)) / n,
"terse_dialect_share_choice_collect": terse_dialect / n_scoped,
"min_choice_collect": min((counts.get(name, 0) for name in PERSONAS), default=0),
}
def uniqueness_stats(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
messages = [str(row.get("user_message") or "") for row in rows]
counts = Counter(messages)
return {
"n": len(messages),
"unique": len(counts),
"max_repeat": max(counts.values()) if counts else 0,
"unique_ok": len(counts) >= 810,
"repeat_ok": (max(counts.values()) if counts else 0) <= 5,
}
def length_ok(stats: Mapping[str, Any]) -> bool:
return 8 <= float(stats.get("p25") or 0) <= 16 and 15 <= float(stats.get("median") or 0) <= 27 and 20 <= float(stats.get("p75") or 0) <= 36
def _compatible_personas(intent: str, dated: bool) -> list[str]:
names = ["rambling", "terse", "dialect_netspeak"]
if intent == "stop_rectification":
names.append("impatient_stop")
if intent == "ask_about_result":
names.append("ask_result")
if intent == "unclear":
names.append("off_topic")
if intent == "answer_current_focus" and dated:
names.append("answer_then_event")
if intent == "answer_current_focus" and dated:
names.append("deny_then_event")
return names
def _assign_persona(intent: str, dated: bool, answer_class: str | None, rng: random.Random, remaining: Counter[str]) -> str:
preferred: list[str] = []
if intent == "stop_rectification" and remaining["impatient_stop"] > 0:
preferred.append("impatient_stop")
if intent == "ask_about_result" and remaining["ask_result"] > 0:
preferred.append("ask_result")
if intent == "unclear" and remaining["off_topic"] > 0:
preferred.append("off_topic")
if intent == "answer_current_focus" and dated and answer_class == "no" and remaining["deny_then_event"] > 0:
preferred.append("deny_then_event")
if intent == "answer_current_focus" and dated and remaining["answer_then_event"] > 0:
preferred.append("answer_then_event")
if preferred:
name = preferred[0]
remaining[name] -= 1
return name
weighted: list[str] = []
for name, weight in (("rambling", 5), ("terse", 2), ("dialect_netspeak", 2)):
if remaining[name] > 0:
weighted.extend([name] * weight)
if not weighted:
weighted = [name for name in _compatible_personas(intent, dated)]
name = rng.choice(weighted)
if remaining[name] > 0:
remaining[name] -= 1
return name
def _length_bounds(persona: str, rng: random.Random) -> tuple[int, int]:
if persona == "terse":
lo = rng.randint(8, 12)
return lo, min(16, lo + rng.randint(3, 6))
if persona == "rambling":
lo = rng.randint(22, 28)
return lo, min(36, lo + rng.randint(4, 8))
if persona == "dialect_netspeak":
lo = rng.randint(12, 18)
return lo, min(30, lo + rng.randint(6, 10))
lo = rng.randint(12, 18)
return lo, min(32, lo + rng.randint(6, 12))
def plan_slots(pool: Mapping[str, Any], *, seed: int, extra_factor: float) -> list[dict[str, Any]]:
rng = random.Random(seed)
remaining = Counter({
"terse": 80,
"dialect_netspeak": 80,
"rambling": 260,
"answer_then_event": 120,
"deny_then_event": 50,
"off_topic": 48,
"impatient_stop": 48,
"ask_result": 48,
})
slots: list[dict[str, Any]] = []
counters = {"choice": 0, "collect": 0, "none": 0}
for layer, rows in QUOTAS.items():
for intent, answer_class, dated, count in rows:
need = int(round(count * extra_factor))
for _ in range(need):
counters[layer] += 1
persona = _assign_persona(intent, dated, answer_class, rng, remaining)
lo, hi = _length_bounds(persona, rng)
question = _pick_question(pool, layer, rng)
slots.append({
"id": f"C-{layer}-{counters[layer]:04d}",
"layer": layer,
"intent": intent,
"answer_class": answer_class,
"dated": dated,
"persona": persona,
"length_lo": lo,
"length_hi": hi,
"question": question,
})
rng.shuffle(slots)
return slots
def _sample_from_slot(slot: Mapping[str, Any], message: str, reviewed: Mapping[str, Any] | None) -> dict[str, Any]:
question = slot["question"]
layer = str(slot["layer"])
gold = enforce_combo(slot["intent"], slot["answer_class"], slot["dated"])
focus = {
"kind": layer if layer != "none" else None,
"current_question": str(question.get("current_question") or ""),
"options": list(question.get("options") or []),
"case_status": str(question.get("case_status") or "collecting_evidence"),
}
row = {
"id": slot["id"],
"source": "C",
"layer": layer,
"persona": slot["persona"],
"focus": focus,
"user_message": message,
"gold": gold,
"generator": GENERATOR_NAME,
"reviewer": REVIEWER_NAME,
"target_before_review": gold,
"prompt_version": PROMPT_VERSION,
}
if reviewed is not None:
row["review"] = reviewed
return row
def fill_slot(
slot: Mapping[str, Any],
*,
denylist: set[str],
) -> tuple[dict[str, Any], bool]:
last_sample: dict[str, Any] | None = None
last_reason: str | None = None
for attempt in range(MAX_GEN_RETRIES):
try:
message = generate_reply(
slot["question"],
intent=slot["intent"],
answer_class=slot["answer_class"],
dated=slot["dated"],
persona=slot["persona"],
length_lo=int(slot["length_lo"]),
length_hi=int(slot["length_hi"]),
attempt=attempt,
last_reason=last_reason,
)
except Exception as exc: # noqa: BLE001
last_sample = _sample_from_slot(slot, f"[generate_error:{type(exc).__name__}]", None)
continue
reason = _message_ok(
message,
question=slot["question"],
intent=slot["intent"],
answer_class=slot["answer_class"],
dated=slot["dated"],
length_lo=int(slot["length_lo"]),
length_hi=int(slot["length_hi"]),
denylist=denylist,
)
if reason:
last_reason = reason
last_sample = _sample_from_slot(slot, message, None)
last_sample["reject_reason"] = reason
continue
sample = _sample_from_slot(slot, message, None)
try:
reviewed = review_sample(sample)
except Exception as exc: # noqa: BLE001
sample["reject_reason"] = f"review_error:{type(exc).__name__}"
last_sample = sample
continue
sample["review"] = reviewed
if gold_equal(sample["gold"], reviewed):
return sample, True
last_sample = sample
if last_sample is None:
last_sample = _sample_from_slot(slot, "", None)
return last_sample, False
def progress_paths() -> tuple[Path, Path]:
return CACHE_DIR / "source_c_accepted.jsonl", CACHE_DIR / "source_c_disputed.jsonl"
def load_progress() -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
accepted_path, disputed_path = progress_paths()
accepted: dict[str, dict[str, Any]] = {}
disputed: dict[str, dict[str, Any]] = {}
for path, dest in ((accepted_path, accepted), (disputed_path, disputed)):
if not path.is_file():
continue
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
if row.get("prompt_version") != PROMPT_VERSION:
continue
dest[str(row.get("id"))] = row
return accepted, disputed
def append_progress(path: Path, row: Mapping[str, Any]) -> None:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
with _CACHE_LOCK:
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
def select_simulated(accepted_rows: Sequence[Mapping[str, Any]], *, seed: int) -> list[dict[str, Any]]:
rng = random.Random(seed + 17)
style = {"terse", "dialect_netspeak"}
by_bucket: dict[tuple[str, str, str | None, bool], list[dict[str, Any]]] = {}
for sample in accepted_rows:
gold = sample.get("gold") or {}
key = (
str(sample.get("layer")),
str(gold.get("intent")),
gold.get("answer_class"),
bool(gold.get("has_new_dated_event")),
)
by_bucket.setdefault(key, []).append(dict(sample))
for bucket in by_bucket.values():
rng.shuffle(bucket)
selected: list[dict[str, Any]] = []
used_ids: set[str] = set()
persona_all: Counter[str] = Counter()
persona_cc: Counter[str] = Counter()
message_kept: Counter[str] = Counter()
def bucket_key(row: Mapping[str, Any]) -> tuple[str, str, str | None, bool]:
gold = row.get("gold") or {}
return (
str(row.get("layer")),
str(gold.get("intent")),
gold.get("answer_class"),
bool(gold.get("has_new_dated_event")),
)
def can_take(row: Mapping[str, Any], *, allow_style: bool, cap_style: bool) -> bool:
persona = str(row.get("persona") or "")
message = str(row.get("user_message") or "")
if not message or row.get("id") in used_ids:
return False
if message_kept[message] >= 5:
return False
if persona in style and not allow_style:
return False
if cap_style and persona in style and persona_all[persona] >= 80:
return False
if cap_style and persona in style and persona_all["terse"] + persona_all["dialect_netspeak"] >= 180:
return False
return True
def commit(row: Mapping[str, Any]) -> None:
selected.append(dict(row))
used_ids.add(str(row["id"]))
persona = str(row.get("persona") or "")
persona_all[persona] += 1
if row.get("layer") in {"choice", "collect"}:
persona_cc[persona] += 1
message_kept[str(row.get("user_message") or "")] += 1
def uncommit(index: int) -> dict[str, Any]:
row = selected.pop(index)
used_ids.discard(str(row["id"]))
persona = str(row.get("persona") or "")
persona_all[persona] -= 1
if row.get("layer") in {"choice", "collect"}:
persona_cc[persona] -= 1
message_kept[str(row.get("user_message") or "")] -= 1
return row
for layer, rows in QUOTAS.items():
for intent, answer_class, dated, count in rows:
bucket = list(by_bucket.get((layer, intent, answer_class, dated), []))
taken = 0
for allow_style in (False, True):
for item in bucket:
if taken >= count:
break
if can_take(item, allow_style=allow_style, cap_style=False):
commit(item)
taken += 1
layer_target = {layer: sum(cell[3] for cell in spec) for layer, spec in QUOTAS.items()}
leftovers = [row for row in accepted_rows if row.get("id") not in used_ids]
rng.shuffle(leftovers)
for allow_style in (False, True):
for item in leftovers:
if len(selected) >= 900:
break
layer = str(item.get("layer") or "none")
layer_n = sum(1 for row in selected if row.get("layer") == layer)
if layer_n >= layer_target.get(layer, 0):
continue
if can_take(item, allow_style=allow_style, cap_style=True):
commit(item)
for allow_style in (False, True):
for item in leftovers:
if len(selected) >= 900:
break
if can_take(item, allow_style=allow_style, cap_style=True):
commit(item)
def leftovers_now() -> list[dict[str, Any]]:
return [row for row in accepted_rows if row.get("id") not in used_ids]
def find_replacement(*, persona_name: str | None, non_style: bool, layer: str | None, same_bucket: tuple | None) -> dict[str, Any] | None:
for item in leftovers_now():
if message_kept[str(item.get("user_message") or "")] >= 5:
continue
if persona_name and str(item.get("persona") or "") != persona_name:
continue
if non_style and str(item.get("persona") or "") in style:
continue
if layer and str(item.get("layer") or "") != layer:
continue
if same_bucket and bucket_key(item) != same_bucket:
continue
return item
return None
for persona_name in PERSONAS:
while persona_cc[persona_name] < 40:
replacement = find_replacement(persona_name=persona_name, non_style=False, layer=None, same_bucket=None)
if replacement is None or str(replacement.get("layer") or "") not in {"choice", "collect"}:
break
victim_index = None
for index, row in enumerate(selected):
victim_persona = str(row.get("persona") or "")
if row.get("layer") not in {"choice", "collect"}:
continue
if victim_persona == persona_name:
continue
if persona_cc[victim_persona] <= 40:
continue
if str(replacement.get("layer")) != str(row.get("layer")):
continue
victim_index = index
break
if victim_index is None:
break
uncommit(victim_index)
commit(replacement)
changed = True
while changed and persona_all["terse"] + persona_all["dialect_netspeak"] > 180:
changed = False
for index, row in enumerate(list(selected)):
persona = str(row.get("persona") or "")
if persona not in style:
continue
if row.get("layer") in {"choice", "collect"} and persona_cc[persona] <= 40:
continue
replacement = (
find_replacement(persona_name=None, non_style=True, layer=str(row.get("layer")), same_bucket=bucket_key(row))
or find_replacement(persona_name=None, non_style=True, layer=str(row.get("layer")), same_bucket=None)
)
if replacement is None:
continue
uncommit(index)
commit(replacement)
changed = True
break
return selected[:900]
def generate_source_c(
pool: Mapping[str, Any],
*,
seed: int = SEED,
extra_factor: float = 1.4,
workers: int = 8,
limit: int = 0,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
load_llm_cache()
denylist = holdout_name_denylist()
slots = plan_slots(pool, seed=seed, extra_factor=extra_factor)
if limit:
slots = slots[:limit]
accepted_map, disputed_map = load_progress()
pending = [slot for slot in slots if slot["id"] not in accepted_map and slot["id"] not in disputed_map]
print(f"slots={len(slots)} pending={len(pending)} cached_ok={len(accepted_map)} cached_bad={len(disputed_map)}", flush=True)
accepted_path, disputed_path = progress_paths()
done = 0
if pending:
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool_ex:
futures = {pool_ex.submit(fill_slot, slot, denylist=denylist): slot for slot in pending}
for future in as_completed(futures):
slot = futures[future]
try:
sample, ok = future.result()
except Exception as exc: # noqa: BLE001
sample = _sample_from_slot(slot, f"[worker_error:{type(exc).__name__}]", None)
ok = False
if ok:
accepted_map[sample["id"]] = sample
append_progress(accepted_path, sample)
else:
disputed_map[sample["id"]] = sample
append_progress(disputed_path, sample)
done += 1
if done % 20 == 0 or done == len(pending):
save_llm_cache()
print(
f" fill {done}/{len(pending)} accepted={len(accepted_map)} disputed={len(disputed_map)}",
flush=True,
)
save_llm_cache()
selected = select_simulated(list(accepted_map.values()), seed=seed)
disputed = list(disputed_map.values())
generated = len(accepted_map) + len(disputed_map)
stats = {
"generated": generated,
"accepted_raw": len(accepted_map),
"accepted": len(selected),
"disputed": len(disputed_map),
"dispute_rate": (len(disputed_map) / max(generated, 1)),
"generator": GENERATOR_NAME,
"reviewer": REVIEWER_NAME,
"prompt_version": PROMPT_VERSION,
"seed": seed,
"usage": dict(_USAGE),
}
return selected, list(disputed_map.values()), stats
def dispute_breakdown(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
by_layer: Counter[str] = Counter()
by_intent: Counter[str] = Counter()
for row in rows:
by_layer[str(row.get("layer") or "")] += 1
gold = row.get("gold") or {}
by_intent[str(gold.get("intent") or "")] += 1
return {"by_layer": dict(by_layer), "by_intent": dict(by_intent)}
def write_readme(
*,
synthetic_sha: str,
simulated_sha: str,
disputed_sha: str,
pool_n: int,
source_c_stats: Mapping[str, Any],
quotas: Mapping[str, Any],
lengths: Mapping[str, Any],
source_b_n: int,
personas: Mapping[str, Any],
uniqueness: Mapping[str, Any],
disputes: Mapping[str, Any],
) -> None:
source_b_sql = """
select
t.id as turn_id,
t.user_message,
t.status as turn_status,
t.created_at,
c.status as case_status,
f.question_id,
f.expected_answer_schema,
f.status as focus_status
from public.agentic_rectification_turns t
join public.agentic_rectification_cases c on c.id = t.case_id
left join public.agentic_rectification_conversation_focuses f
on f.case_id = t.case_id
and f.status = 'active'
where t.user_message is not null
and length(btrim(t.user_message)) > 0
order by t.created_at desc
limit 500;
""".strip()
generate_prompt = build_generate_prompt(
{
"current_question": SHUFFLED_CHOICE_PROMPT,
"options": SHUFFLED_OPTIONS,
"domain": "career",
"layer": "choice",
},
intent="answer_current_focus",
answer_class="no",
dated=False,
persona="rambling",
length_lo=16,
length_hi=28,
)
review_prompt = build_review_prompt({
"focus": {
"current_question": SHUFFLED_CHOICE_PROMPT,
"options": SHUFFLED_OPTIONS,
},
"user_message": "(用户回复)",
})
text = f"""# Jev 意图分类样本(T0
## 来源 A · 既有合成样本(提交)
`synthetic.jsonl`10 条。生产测试 `frontend/tests/rectification-turn-intent-classifier.test.ts` **没有** 10 条带期望输出的 `userMessage` 夹具;本文件用该测试的 `SHUFFLED_CHOICE` 焦点、采集题 prompt,以及测试里实际出现的两句 `userMessage``2016年3月入学`、`随便吧`)按分类器提示词写成带标签样本。
sha256: `{synthetic_sha}`
## 来源 B · 真实会话(本地,不提交)
作用:校准锚,不是主测试集。最少 30 条;不足则代表性检验只报数不判定。
- 抽取 SQLstaging`agentic_rectification_turns.user_message` 非空):
```sql
{source_b_sql}
```
- 本地路径(worktree 外,已 gitignore):`G:/Ferti/Jyotisha/.cache/jev_intent/source_b.jsonl`
- 本机抽取条数:{source_b_n}
- 真值:执行方逐条人工标注;能从后续动作反推的另存 `runtime_intent` 列。**不得提交原文。**
## 来源 C · 模型模拟语料(提交,主测试集)
`simulated.jsonl`。问题池 `question_pool.json`20 例公开 AA holdout 走 `discriminating_event_probes` 出点选题;采集题用 `USER_COLLECT_QUESTION` / retry / `TARGETED_YEAR_PROMPT`。生成与复核各一次真实模型调用;复核看不到目标标签。
- 生成模型:`{GENERATOR_NAME}`temperature {GEN_TEMPERATURE}prompt_version `{PROMPT_VERSION}`
- 复核模型:`{REVIEWER_NAME}`temperature {REVIEW_TEMPERATURE},同一家模型、不同提示、看不到标签)
- 生成统计:{json.dumps(source_c_stats, ensure_ascii=False)}
- 争议分层:{json.dumps(disputes, ensure_ascii=False)}
- 配额:{json.dumps({k: {'actual': v['actual_total'], 'target': v['target_total'], 'max_share_delta': v['max_share_delta'], 'ok': v['within_20pct']} for k, v in quotas.items()}, ensure_ascii=False)}
- 长度:{json.dumps(lengths, ensure_ascii=False)}
- 人设:{json.dumps(personas, ensure_ascii=False)}
- 去重:{json.dumps(uniqueness, ensure_ascii=False)}
- sha256 simulated: `{simulated_sha}`
- sha256 disputed: `{disputed_sha}`
- 问题池条数(点选题):{pool_n}
### 生成提示原文
系统:`{GENERATE_SYSTEM}`
用户提示模板示例:
```
{generate_prompt}
```
### 复核提示原文
系统:`{REVIEW_SYSTEM}`
用户提示模板示例:
```
{review_prompt}
```
公开案例姓名不得出现在回复正文。
"""
README_PATH.write_text(text, encoding="utf-8")
def load_source_b() -> list[dict[str, Any]]:
path = Path(r"G:\Ferti\Jyotisha\.cache\jev_intent\source_b.jsonl")
if not path.is_file():
return []
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
if line.strip():
rows.append(json.loads(line))
return rows
def _print_gate(selected: Sequence[Mapping[str, Any]], stats: Mapping[str, Any]) -> int:
quotas = quota_deviation(selected)
lengths = length_stats(str(row.get("user_message") or "") for row in selected)
personas = persona_stats(selected)
uniqueness = uniqueness_stats(selected)
print(json.dumps({
"simulated": len(selected),
"stats": stats,
"quotas": {k: {"actual": v["actual_total"], "ok": v["within_20pct"], "delta": v["max_share_delta"]} for k, v in quotas.items()},
"lengths": lengths,
"personas": personas,
"uniqueness": uniqueness,
"length_ok": length_ok(lengths),
}, ensure_ascii=False, indent=2), flush=True)
failed = []
if len(selected) < 900:
failed.append("simulated<900")
layer_n = Counter(str(row.get("layer")) for row in selected)
if not (
320 <= layer_n.get("choice", 0) <= 480
and 320 <= layer_n.get("collect", 0) <= 480
and 80 <= layer_n.get("none", 0) <= 120
):
failed.append("layer_totals")
if not all(quotas[layer]["within_20pct"] for layer in quotas):
failed.append("quota")
if not length_ok(lengths):
failed.append("length")
if not uniqueness["unique_ok"] or not uniqueness["repeat_ok"]:
failed.append("uniqueness")
if personas["min_choice_collect"] < 40:
failed.append("persona_min")
if personas["terse_dialect_share_all"] > 0.20:
failed.append("terse_dialect")
if failed:
print("FAILED constraints: " + ",".join(failed), flush=True)
return 1
return 0
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--pool-only", action="store_true")
parser.add_argument("--reuse-pool", action="store_true", default=True)
parser.add_argument("--rebuild-pool", action="store_true")
parser.add_argument("--max-cases", type=int, default=None)
parser.add_argument("--seed", type=int, default=SEED)
parser.add_argument("--workers", type=int, default=8)
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--extra-factor", type=float, default=1.4)
parser.add_argument("--reselect-only", action="store_true")
args = parser.parse_args(argv)
SAMPLES_DIR.mkdir(parents=True, exist_ok=True)
reuse = args.reuse_pool and not args.rebuild_pool and POOL_PATH.is_file() and not args.pool_only
if reuse:
pool = json.loads(POOL_PATH.read_text(encoding="utf-8"))
print(f"reusing question pool choice={len(pool.get('choice') or [])}", flush=True)
else:
print("building question pool from holdout + discriminating_event_probes", flush=True)
pool = build_question_pool(max_cases=args.max_cases)
POOL_PATH.write_text(json.dumps(pool, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
f"pool choice={len(pool['choice'])} collect={len(pool['collect'])} errors={pool['errors']}",
flush=True,
)
if args.pool_only:
return 0
if not args.reselect_only and not os.environ.get("DEEPSEEK_API_KEY"):
print("DEEPSEEK_API_KEY missing", file=sys.stderr)
return 2
synthetic = source_a_samples()
write_jsonl(SYNTHETIC_PATH, synthetic)
if args.reselect_only:
accepted_map, disputed_map = load_progress()
accepted = select_simulated(list(accepted_map.values()), seed=args.seed)
disputed = list(disputed_map.values())
usage_path = CACHE_DIR / "llm_usage.json"
usage = dict(_USAGE)
if usage_path.is_file():
try:
usage = json.loads(usage_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
pass
stats = {
"generated": len(accepted_map) + len(disputed_map),
"accepted_raw": len(accepted_map),
"accepted": len(accepted),
"disputed": len(disputed_map),
"dispute_rate": (len(disputed_map) / max(len(accepted_map) + len(disputed_map), 1)),
"generator": GENERATOR_NAME,
"reviewer": REVIEWER_NAME,
"prompt_version": PROMPT_VERSION,
"seed": args.seed,
"usage": usage,
"reselect_only": True,
}
else:
accepted, disputed, stats = generate_source_c(
pool,
seed=args.seed,
extra_factor=args.extra_factor,
workers=args.workers,
limit=args.limit,
)
if stats["dispute_rate"] > DISPUTE_RATE_LIMIT:
print(
f"dispute_rate={stats['dispute_rate']:.3f} > 0.15 (prompt_version={PROMPT_VERSION})",
flush=True,
)
write_jsonl(SIMULATED_PATH, accepted)
write_jsonl(DISPUTED_PATH, disputed)
quotas = quota_deviation(accepted)
lengths = length_stats(str(row.get("user_message") or "") for row in accepted)
personas = persona_stats(accepted)
uniqueness = uniqueness_stats(accepted)
source_b = load_source_b()
write_readme(
synthetic_sha=sha256_file(SYNTHETIC_PATH),
simulated_sha=sha256_file(SIMULATED_PATH),
disputed_sha=sha256_file(DISPUTED_PATH),
pool_n=len(pool.get("choice") or []),
source_c_stats=stats,
quotas=quotas,
lengths=lengths,
source_b_n=len(source_b),
personas=personas,
uniqueness=uniqueness,
disputes=dispute_breakdown(disputed),
)
print(json.dumps({
"synthetic": len(synthetic),
"simulated": len(accepted),
"disputed": len(disputed),
"stats": stats,
"quotas": {k: {"actual": v["actual_total"], "ok": v["within_20pct"], "delta": v["max_share_delta"]} for k, v in quotas.items()},
"lengths": lengths,
"personas": personas,
"uniqueness": uniqueness,
"source_b": len(source_b),
"sha256": {
"synthetic": sha256_file(SYNTHETIC_PATH),
"simulated": sha256_file(SIMULATED_PATH),
"disputed": sha256_file(DISPUTED_PATH),
},
}, ensure_ascii=False, indent=2))
if args.limit:
return 0
if stats["dispute_rate"] > DISPUTE_RATE_LIMIT:
print("WARN: dispute_rate still > 15%; proceeding per 让步 1, not falling back to templates", flush=True)
return _print_gate(accepted, stats)
if __name__ == "__main__":
raise SystemExit(main())