Files
Jyotisha/scripts/research/jev_intent_corpus_build.py
T
jesse-ux fde541c2ca
Independent Staging Quality Gate / validate (push) Successful in 10m0s
Independent Staging Quality Gate / publish (push) Successful in 3m49s
research(rectification): Jev 意图分类离线对照,结论不可接
来源 C 900 条 + jev-1.13.0 双跑。高置信错误 7%、点选题 answer_class 65%。不改线上分类器。
2026-09-19 09:27:10 +08:00

973 lines
39 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: Agent-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 random
import re
import sys
from collections import Counter
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"
TODAY = date(2026, 9, 19)
SEED = 20260919
GENERATOR_NAME = "agent-template-v1"
REVIEWER_NAME = "agent-rule-v1"
DISPUTE_RATE_LIMIT = 0.15
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]]] = {
# layer -> (intent, answer_class, has_new_dated_event, count)
"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, 45),
("answer_current_focus", "no", True, 15),
("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, 100),
("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),
],
}
EVENT_STEMS: dict[str, tuple[str, ...]] = {
"education": ("升学", "转学", "大考", "换学校"),
"career": ("入职", "换工作", "职责变重", "离职"),
"relocation": ("搬家", "开始长期住外地", "换城市住"),
"relationship": ("认真在一起", "分开", "结婚"),
"family": ("家里添丁", "长辈住院", "家里有人结婚"),
"finance": ("收入明显变过", "有过大笔支出", "欠过债"),
"health_pressure": ("生病", "受伤", "特别难熬过一段时间"),
}
FILLER = (
"我想想啊就是那种说不上来特别大但也不是完全没有的感觉,",
"当时身边人也没特别提,我自己也没当回事,",
"现在回想起来细节对不上,只能说个大概,",
"别的先不说,就这一句你先记着,",
)
DIALECT = (
"嗯那会儿吧咋说呢",
"额这个嘛就那样",
"不是很能整明白",
"emmm 感觉一般般",
)
YEAR_RE = re.compile(r"(?:19|20)\d{2}")
MONTH_RE = re.compile(r"\d{1,2}\s*月")
STOP_RE = re.compile(r"(停止整套|停止校正|别搞了|不弄了|先停|不做了|结束校正|莫搞了|不玩了)")
ASK_RE = re.compile(r"(结果呢|范围呢|出结果|看盘|候选分钟|出来了吗|好了没|现在几分钟)")
NO_RE = re.compile(r"(没有|没发生|没变|无变化|这方面没什么|没这回事|没有过|那段没有)")
UNSURE_RE = re.compile(r"(记不清|不记得|忘了|想不起来|以后再说|说不清|不太记得)")
YES_RE = re.compile(r"(有的|发生了|确实有|对的|就是那段|明显有过)")
WEAK_RE = re.compile(r"(一点点|不太明显|好像有过|将就吧|有过但不算)")
EXTRA_RE = re.compile(r"(另外|不过|对了还有)")
PROVIDE_RE = re.compile(r"^(另外|对了还有|另说一件)")
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 index, item in enumerate(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 _year(rng: random.Random) -> int:
return rng.randint(1998, 2024)
def _month(rng: random.Random) -> int:
return rng.randint(1, 12)
def _stem(domain: str, rng: random.Random) -> str:
stems = EVENT_STEMS.get(domain) or EVENT_STEMS["career"]
return rng.choice(stems)
def _dated(domain: str, rng: random.Random, *, with_month: bool = False) -> str:
year = _year(rng)
stem = _stem(domain, rng)
if with_month or rng.random() < 0.4:
return f"{year}年{_month(rng)}月{stem}"
return f"{year}年{stem}"
def _pad(text: str, rng: random.Random, target: int) -> str:
while len(text) < target:
text += rng.choice(FILLER)
if len(text) >= target:
break
return text[:target] if len(text) > target + 8 else text
def _persona_for(intent: str, dated: bool, rng: random.Random) -> str:
if not dated and rng.random() < 0.34:
return "terse"
compatible = ["terse", "rambling", "dialect_netspeak"]
if intent == "answer_current_focus" and dated:
compatible.append("answer_then_event")
compatible.append("deny_then_event")
if intent == "stop_rectification":
compatible.append("impatient_stop")
if intent == "ask_about_result":
compatible.append("ask_result")
if intent == "unclear":
compatible.append("off_topic")
return rng.choice(compatible)
def _choice_answer(answer_class: str, rng: random.Random, persona: str) -> str:
bank = {
"yes": ("有的", "发生了", "确实有", "就是那段", "明显有过"),
"weak_yes": ("一点点", "不太明显", "好像有过", "有过但不算", "将就吧"),
"no": ("没有", "没变", "没这回事", "那段没有", "没有过"),
"unsure": ("忘了", "记不清", "想不起来", "说不清", "以后再说"),
}[answer_class]
if persona == "terse":
short = [item for item in bank if len(item) <= 6]
return rng.choice(short or bank)
if persona == "dialect_netspeak":
return rng.choice(DIALECT) + rng.choice(bank)
if persona == "rambling":
return _pad(rng.choice(bank) + "," + rng.choice(FILLER), rng, rng.randint(28, 55))
return rng.choice(bank)
def _collect_answer(answer_class: str, domain: str, rng: random.Random, persona: str) -> str:
if answer_class in {"yes", "weak_yes"}:
dated = _dated(domain, rng, with_month=answer_class == "yes")
if answer_class == "weak_yes":
dated = "大概" + dated + "吧,印象不深"
if persona == "terse":
return f"{_year(rng)}年{_stem(domain, rng)}"[:6] if len(dated) > 6 else dated
if persona == "rambling":
return _pad("有的," + dated + "。", rng, rng.randint(30, 60))
if persona == "dialect_netspeak":
return rng.choice(DIALECT) + dated
return dated
return _choice_answer(answer_class, rng, persona)
def _extra_event(domain: str, rng: random.Random) -> str:
other = rng.choice([key for key in EVENT_STEMS if key != domain] or ["career"])
return _dated(other, rng, with_month=True)
def render_reply(
*,
layer: str,
domain: str,
intent: str,
answer_class: str | None,
dated: bool,
persona: str,
rng: random.Random,
) -> str:
if intent == "stop_rectification":
bank = ("不弄了", "先停", "别搞了", "不做了", "莫搞了")
if persona == "terse":
return rng.choice([item for item in bank if len(item) <= 6])
if persona == "impatient_stop":
return "问了这么多我先不弄了,停止整套。"
if persona == "dialect_netspeak":
return "莫搞了,这套先停。"
if persona == "rambling":
return _pad("这校正先停吧,停止整套,我今天不想继续了。", rng, 40)
return rng.choice(bank)
if intent == "ask_about_result":
bank = ("结果呢", "范围呢", "好了没", "出结果了吗", "现在几分钟")
if persona == "terse":
return rng.choice([item for item in bank if len(item) <= 6])
if persona == "ask_result":
return "我想看结果,现在几分钟了?"
if persona == "rambling":
return _pad("先不问这题了,出结果了吗,范围呢。", rng, 42)
if persona == "dialect_netspeak":
return "emmm 结果呢好了没"
return rng.choice(bank)
if intent == "unclear":
bank = ("随便吧", "看你", "嗯", "哦", "那啥", "天气真好")
if persona == "terse":
return rng.choice([item for item in bank if len(item) <= 6])
if persona == "off_topic":
return "中午吃面还是米饭,这跟校正没关系。"
if persona == "rambling":
return _pad("我也说不好你问的是啥,反正就那样吧。", rng, 36)
if persona == "dialect_netspeak":
return "hahaha 随便啦"
return rng.choice(bank)
if intent == "provide_new_evidence":
text = _extra_event(domain or "career", rng)
if persona == "terse":
return "另外" + f"{_year(rng)}年{_stem(domain or 'career', rng)}"
if persona == "rambling":
return _pad("另外说一件:" + text + "。", rng, 48)
if persona == "dialect_netspeak":
return "对了还有一件," + text
return "另外," + text
# answer_current_focus
if layer == "collect":
core = _collect_answer(answer_class or "no", domain or "career", rng, persona)
else:
core = _choice_answer(answer_class or "no", rng, persona)
if dated:
extra = _extra_event(domain or "career", rng)
if persona == "deny_then_event" or answer_class == "no":
core = f"{core}。不过{extra}"
else:
core = f"{core}。另外{extra}"
if persona == "rambling" and len(core) < 28:
core = _pad(core + "。", rng, rng.randint(28, 52))
if persona == "terse" and not dated:
core = core[:6]
return core.strip("。, ")
SHORT_BY_LABEL = {
("stop_rectification", None, False): ("不弄了", "先停", "别搞了"),
("ask_about_result", None, False): ("结果呢", "范围呢", "好了没"),
("unclear", None, False): ("随便吧", "看你", "嗯"),
("answer_current_focus", "no", False): ("没有", "没变", "没有过"),
("answer_current_focus", "unsure", False): ("忘了", "记不清", "说不清"),
("answer_current_focus", "yes", False): ("有的", "发生了", "确实有"),
("answer_current_focus", "weak_yes", False): ("一点点", "将就吧", "不太明显"),
}
def enforce_length_floor(rows: list[dict[str, Any]], rng: random.Random) -> None:
"""Push P25 ≤ 6 when source B is missing, without changing labels."""
stats = length_stats(str(row.get("user_message") or "") for row in rows)
if stats["p25"] <= 6:
return
candidates = [
row for row in rows
if (row["gold"]["intent"], row["gold"]["answer_class"], bool(row["gold"]["has_new_dated_event"])) in SHORT_BY_LABEL
and len(str(row.get("user_message") or "")) > 6
]
rng.shuffle(candidates)
need = max(0, int(0.26 * len(rows)) - sum(1 for row in rows if len(str(row.get("user_message") or "")) <= 6))
for row in candidates[:need]:
gold = row["gold"]
original = row["user_message"]
original_persona = row.get("persona")
row["user_message"] = rng.choice(
SHORT_BY_LABEL[(gold["intent"], gold["answer_class"], bool(gold["has_new_dated_event"]))]
)
reviewed = review_sample(row)
if gold_equal(gold, reviewed):
row["persona"] = "terse"
row["review"] = reviewed
else:
row["user_message"] = original
row["persona"] = original_persona
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 _strip_option_labels(message: str, labels: Sequence[str]) -> str:
text = message
for label in labels:
if label and label in text:
text = text.replace(label, "")
return text.strip() or message
def generate_candidate(
question: Mapping[str, Any],
*,
intent: str,
answer_class: str | None,
dated: bool,
rng: random.Random,
index: int,
) -> dict[str, Any]:
layer = str(question.get("layer") or "none")
domain = str(question.get("domain") or "career")
persona = _persona_for(intent, dated, rng)
message = render_reply(
layer=layer,
domain=domain,
intent=intent,
answer_class=answer_class,
dated=dated,
persona=persona,
rng=rng,
)
message = _strip_option_labels(message, _option_labels(question))
gold = enforce_combo(intent, answer_class, 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"),
}
return {
"id": f"C-{layer}-{index:04d}",
"source": "C",
"layer": layer,
"persona": persona,
"focus": focus,
"user_message": message,
"gold": gold,
"generator": GENERATOR_NAME,
"reviewer": REVIEWER_NAME,
"target_before_review": gold,
}
def _has_dated(message: str) -> bool:
return bool(YEAR_RE.search(message) or MONTH_RE.search(message))
def review_sample(sample: Mapping[str, Any]) -> dict[str, Any]:
"""Independent pass: production-prompt rules, no access to target labels."""
message = str(sample.get("user_message") or "")
layer = str(sample.get("layer") or "none")
focus = sample.get("focus") if isinstance(sample.get("focus"), dict) else {}
question = str(focus.get("current_question") or "")
dated = _has_dated(message)
extra = bool(EXTRA_RE.search(message) and dated)
if STOP_RE.search(message) and not NO_RE.search(message):
return enforce_combo("stop_rectification", None, False)
if ASK_RE.search(message) and not YES_RE.search(message) and not NO_RE.search(message):
return enforce_combo("ask_about_result", None, False)
if layer == "none" or not question:
if dated:
return enforce_combo("provide_new_evidence", None, True)
return enforce_combo("unclear", None, False)
if PROVIDE_RE.search(message.strip()) and dated and not YES_RE.search(message) and not NO_RE.search(message) and not UNSURE_RE.search(message):
return enforce_combo("provide_new_evidence", None, True)
if layer == "collect":
if NO_RE.search(message):
return enforce_combo("answer_current_focus", "no", extra)
if UNSURE_RE.search(message) and not dated:
return enforce_combo("answer_current_focus", "unsure", False)
if UNSURE_RE.search(message) and extra:
return enforce_combo("answer_current_focus", "unsure", True)
if dated and not PROVIDE_RE.search(message.strip()):
weak = bool(WEAK_RE.search(message) or "大概" in message)
return enforce_combo("answer_current_focus", "weak_yes" if weak else "yes", extra)
if YES_RE.search(message):
return enforce_combo("answer_current_focus", "yes", extra)
if WEAK_RE.search(message):
return enforce_combo("answer_current_focus", "weak_yes", extra)
return enforce_combo("unclear", None, False)
if NO_RE.search(message):
return enforce_combo("answer_current_focus", "no", extra)
if UNSURE_RE.search(message) and not YES_RE.search(message):
return enforce_combo("answer_current_focus", "unsure", extra)
if WEAK_RE.search(message):
return enforce_combo("answer_current_focus", "weak_yes", extra)
if YES_RE.search(message):
return enforce_combo("answer_current_focus", "yes", extra)
if dated:
return enforce_combo("provide_new_evidence", None, True)
return enforce_combo("unclear", None, False)
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 generate_source_c(
pool: Mapping[str, Any],
*,
seed: int = SEED,
extra_factor: float = 1.35,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
rng = random.Random(seed)
denylist = holdout_name_denylist()
accepted: list[dict[str, Any]] = []
disputed: list[dict[str, Any]] = []
per_bucket: dict[tuple[str, str, str | None, bool], list[dict[str, Any]]] = {}
generated = 0
for layer, rows in QUOTAS.items():
for intent, answer_class, dated, count in rows:
need = int(round(count * extra_factor))
bucket: list[dict[str, Any]] = []
attempts = 0
while len(bucket) < need and attempts < need * 8:
attempts += 1
generated += 1
question = _pick_question(pool, layer, rng)
sample = generate_candidate(
question,
intent=intent,
answer_class=answer_class,
dated=dated,
rng=rng,
index=generated,
)
try:
assert_no_pii(sample["user_message"], denylist)
except ValueError:
continue
reviewed = review_sample(sample)
sample["review"] = reviewed
if gold_equal(sample["gold"], reviewed):
bucket.append(sample)
else:
disputed.append(sample)
per_bucket[(layer, intent, answer_class, dated)] = bucket
# fill quotas from accepted buckets
for layer, rows in QUOTAS.items():
for intent, answer_class, dated, count in rows:
bucket = per_bucket[(layer, intent, answer_class, dated)]
if len(bucket) < count:
# keep what we have; quota deviation is reported
accepted.extend(bucket)
else:
accepted.extend(bucket[:count])
enforce_length_floor(accepted, rng)
stats = {
"generated": generated,
"accepted": len(accepted),
"disputed": len(disputed),
"dispute_rate": (len(disputed) / max(generated, 1)),
"generator": GENERATOR_NAME,
"reviewer": REVIEWER_NAME,
"seed": seed,
}
return accepted, disputed, stats
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 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),
}
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,
) -> 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()
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 条;不足则代表性检验只报数不判定。
- 抽取 SQL(staging,`agentic_rectification_turns.user_message` 非空):
```sql
{source_b_sql}
```
- 本地路径(worktree 外,已 gitignore):`G:/Ferti/Jyotisha/.cache/jev_intent/source_b.jsonl`
- 本机抽取条数:{source_b_n}(无 staging 库凭据则为 0,长度分布用任务书兜底 P25≤6 / 中位≤15 / P75≤40)
- 真值:执行方逐条人工标注;能从后续动作反推的另存 `runtime_intent` 列。**不得提交原文。**
## 来源 C · Agent 模拟语料(提交,主测试集)
`simulated.jsonl`。问题池 `question_pool.json`:20 例公开 AA holdout 走 `discriminating_event_probes` 出点选题;采集题用 `USER_COLLECT_QUESTION` / retry / `TARGETED_YEAR_PROMPT`。
- 生成器:`{GENERATOR_NAME}`(标签先定再写回复;本机无会话模型凭据,不走线上贵模型)
- 复核器:`{REVIEWER_NAME}`(不同规则、看不到目标标签;不一致进 `disputed.jsonl`,不用第三次投票)
- 生成统计:{json.dumps(source_c_stats, 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)}
- sha256 simulated: `{simulated_sha}`
- sha256 disputed: `{disputed_sha}`
- 问题池条数(点选题):{pool_n}
公开案例姓名不得出现在回复正文。
"""
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 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")
parser.add_argument("--max-cases", type=int, default=None)
parser.add_argument("--seed", type=int, default=SEED)
args = parser.parse_args(argv)
SAMPLES_DIR.mkdir(parents=True, exist_ok=True)
if args.reuse_pool and POOL_PATH.is_file() and not args.pool_only:
pool = json.loads(POOL_PATH.read_text(encoding="utf-8"))
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
synthetic = source_a_samples()
write_jsonl(SYNTHETIC_PATH, synthetic)
accepted, disputed, stats = generate_source_c(pool, seed=args.seed)
if stats["dispute_rate"] > DISPUTE_RATE_LIMIT:
print(
f"dispute_rate={stats['dispute_rate']:.3f} > 0.15, regenerating with extra_factor=1.8",
flush=True,
)
accepted, disputed, stats = generate_source_c(pool, seed=args.seed + 1, extra_factor=1.8)
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)
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),
)
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,
"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 len(accepted) < 900:
print("FAILED: simulated < 900", flush=True)
return 1
if stats["dispute_rate"] > DISPUTE_RATE_LIMIT:
print("WARN: dispute_rate still > 15% after one repair pass; proceeding per 让步 1a", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())