research: compare Jev intent state with the previous turn
Independent Staging Quality Gate / validate (push) Successful in 12m0s
Independent Staging Quality Gate / publish (push) Successful in 3m35s

V0 on the existing 157 real rows matches the 09-19 cache. The staging extract has no case linkage, so V1/V2 are unmeasured and the verdict stays 缺数据.
This commit is contained in:
jesse-ux
2026-09-27 11:41:53 +08:00
parent 710c848b30
commit e3bd3930e3
13 changed files with 100475 additions and 26 deletions
+4 -4
View File
@@ -74,7 +74,7 @@ def _parse_content(text: str) -> dict[str, Any]:
return payload
def call_current(sample: Mapping[str, Any], *, timeout: float = 60.0) -> dict[str, Any]:
def call_current(sample: Mapping[str, Any], *, timeout: float = 60.0, variant: str = "v0") -> dict[str, Any]:
api_key = os.environ.get("DEEPSEEK_API_KEY") or ""
if not api_key:
raise RuntimeError("DEEPSEEK_API_KEY missing")
@@ -84,7 +84,7 @@ def call_current(sample: Mapping[str, Any], *, timeout: float = 60.0) -> dict[st
"model": model,
"messages": [
{"role": "system", "content": _instructions(sample) + "\n" + JSON_SCHEMA_HINT},
{"role": "user", "content": json.dumps(build_state(sample), ensure_ascii=False)},
{"role": "user", "content": json.dumps(build_state(sample, variant=variant if variant == "v1" else "v0"), ensure_ascii=False)},
],
"temperature": 0,
"max_tokens": 256,
@@ -128,12 +128,12 @@ def call_current(sample: Mapping[str, Any], *, timeout: float = 60.0) -> dict[st
}
def call_current_retry(sample: Mapping[str, Any], *, retries: int = 4) -> dict[str, Any]:
def call_current_retry(sample: Mapping[str, Any], *, retries: int = 4, variant: str = "v0") -> dict[str, Any]:
last_error = ""
delay = 1.0
for _attempt in range(retries):
try:
return call_current(sample)
return call_current(sample, variant=variant)
except urllib.error.HTTPError as exc:
last_error = f"HTTP{exc.code}"
if exc.code in {429, 500, 502, 503, 529}:
+21 -6
View File
@@ -55,14 +55,19 @@ def gold_of(sample: Mapping[str, Any]) -> dict[str, Any]:
return enforce_combo(gold.get("intent"), gold.get("answer_class"), gold.get("has_new_dated_event"))
def call_jev(sample: Mapping[str, Any], *, timeout: float = 60.0) -> dict[str, Any]:
def call_jev(
sample: Mapping[str, Any],
*,
timeout: float = 60.0,
variant: str = "v0",
) -> dict[str, Any]:
from typesafe_sdk import TypeSafeClient
started = time.perf_counter()
with TypeSafeClient() as client:
response = client.system_one(
state=build_state(sample),
questions=sdk_questions(sample),
state=build_state(sample, variant=variant),
questions=sdk_questions(sample, variant=variant),
model=JEV_MODEL,
timeout=timeout,
)
@@ -81,7 +86,11 @@ def call_jev(sample: Mapping[str, Any], *, timeout: float = 60.0) -> dict[str, A
"noul": getattr(value, "noul", None),
}
answers = converted
parsed = parse_jev_answers(answers)
parsed = parse_jev_answers(
answers,
previous_decision=sample.get("previous_decision") if isinstance(sample.get("previous_decision"), dict) else None,
apply_continue=variant == "v2",
)
usage = payload.get("usage") or {}
return {
"ok": True,
@@ -95,15 +104,18 @@ def call_jev(sample: Mapping[str, Any], *, timeout: float = 60.0) -> dict[str, A
"input_tokens": usage.get("input_tokens"),
"output_tokens": usage.get("output_tokens"),
"elapsed_ms": elapsed_ms,
"continued": parsed.get("continued"),
"continues_previous_turn": parsed.get("continues_previous_turn"),
"variant": variant,
}
def call_jev_retry(sample: Mapping[str, Any], *, retries: int = 4) -> dict[str, Any]:
def call_jev_retry(sample: Mapping[str, Any], *, retries: int = 4, variant: str = "v0") -> dict[str, Any]:
last_error = ""
delay = 1.0
for attempt in range(retries):
try:
return call_jev(sample)
return call_jev(sample, variant=variant)
except Exception as exc: # noqa: BLE001
last_error = type(exc).__name__
name = type(exc).__name__
@@ -127,6 +139,9 @@ def call_jev_retry(sample: Mapping[str, Any], *, retries: int = 4) -> dict[str,
"input_tokens": None,
"output_tokens": None,
"elapsed_ms": None,
"continued": None,
"continues_previous_turn": None,
"variant": variant,
"error": last_error,
}
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""Jev intent research v2: previous-turn state variants.
Offline by default for the report. Source B text stays in the gitignored cache.
Committed rows keep gold and model output only.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.research.jev_intent_probe import ( # noqa: E402
CACHE_DIR,
cache_key,
confusion_counts,
layer_metrics,
load_cache,
load_jsonl,
self_consistency,
strip_confidence,
)
from scripts.research.jev_intent_questions import ( # noqa: E402
JEV_MODEL,
previous_turn_payload,
)
from scripts.research.jev_intent_source_b_v2 import OUTPUT_PATH, from_legacy # noqa: E402
REPORT_JSON = ROOT / "docs" / "research" / "jev_intent_2026_09_27.json"
REPORT_MD = ROOT / "docs" / "research" / "jev_intent_2026_09_27.md"
SAMPLES_DIR = ROOT / "scripts" / "research" / "jev_intent_samples"
MIN_WITH_PREVIOUS = 100
PUBLISHED_C_INTENT = {"choice": 0.990, "collect": 0.922, "none": 0.960}
PRED_KEEP = (
"ok",
"unavailable",
"model",
"intent",
"answer_class",
"has_new_dated_event",
"confidence",
"raw",
"input_tokens",
"output_tokens",
"elapsed_ms",
"continued",
"continues_previous_turn",
"variant",
"error",
)
def pct(value: float | None) -> str:
if value is None:
return "—"
return f"{value:.1%}"
def public_pred(pred: Mapping[str, Any] | None) -> dict[str, Any] | None:
if not pred:
return None
return {key: pred.get(key) for key in PRED_KEEP if key in pred}
def has_previous(row: Mapping[str, Any]) -> bool:
return previous_turn_payload(row) is not None
def unclear_to_focus(rows: Sequence[Mapping[str, Any]], pred_key: str) -> int:
count = 0
for row in rows:
if row.get("layer") != "none":
continue
if (row.get("gold") or {}).get("intent") != "unclear":
continue
if (row.get(pred_key) or {}).get("intent") == "answer_current_focus":
count += 1
return count
def strip_block(block: dict[str, Any] | None) -> dict[str, Any] | None:
"""Flash has no confidence. Leave those rates empty instead of printing 0%."""
if not block:
return None
out = dict(block)
out["all"] = strip_confidence(block.get("all"))
out["by_layer"] = {
layer: strip_confidence(metrics) if metrics else None
for layer, metrics in (block.get("by_layer") or {}).items()
}
out["with_previous"] = strip_confidence(block.get("with_previous"))
out["without_previous"] = strip_confidence(block.get("without_previous"))
return out
def pack_metrics(rows: Sequence[Mapping[str, Any]], pred_key: str) -> dict[str, Any] | None:
usable = [row for row in rows if row.get(pred_key)]
if not usable:
return None
by_layer = {}
for layer in ("choice", "collect", "none"):
subset = [row for row in usable if row.get("layer") == layer]
by_layer[layer] = layer_metrics(subset, pred_key=pred_key) if subset else None
with_prev = [row for row in usable if row.get("has_previous_turn")]
without_prev = [row for row in usable if not row.get("has_previous_turn")]
return {
"all": layer_metrics(usable, pred_key=pred_key),
"by_layer": by_layer,
"with_previous": layer_metrics(with_prev, pred_key=pred_key) if with_prev else None,
"without_previous": layer_metrics(without_prev, pred_key=pred_key) if without_prev else None,
"none_unclear_to_focus": unclear_to_focus(usable, pred_key),
"self_consistency": None,
}
def attach_legacy_preds(samples: Sequence[dict[str, Any]], cache: Mapping[str, Any], mapping: Mapping[str, str]) -> dict[str, int]:
missing = {dest: 0 for dest in mapping}
for sample in samples:
for dest, legacy in mapping.items():
key = cache_key(legacy, sample)
if key in cache:
pred = dict(cache[key])
pred["variant"] = "v0"
sample[dest] = pred
else:
missing[dest] += 1
return missing
def public_rows(samples: Sequence[Mapping[str, Any]], pred_keys: Sequence[str]) -> list[dict[str, Any]]:
rows = []
for sample in samples:
row = {
"id": sample.get("id"),
"source": sample.get("source"),
"layer": sample.get("layer"),
"gold": sample.get("gold"),
"gold_source": sample.get("gold_source"),
"has_previous_turn": bool(sample.get("has_previous_turn")),
"previous_turn_source": sample.get("previous_turn_source"),
}
for key in pred_keys:
pred = public_pred(sample.get(key) if isinstance(sample.get(key), dict) else None)
if pred:
row[key] = pred
rows.append(row)
return rows
def decide(meta: Mapping[str, Any], metrics: Mapping[str, Any]) -> dict[str, str]:
blocked: list[str] = []
if meta.get("n_with_previous", 0) < MIN_WITH_PREVIOUS:
blocked.append(f"n_有上一轮 {meta.get('n_with_previous', 0)} < {MIN_WITH_PREVIOUS}")
if not metrics.get("jev_v1"):
blocked.append("V1 没有预测")
if not metrics.get("jev_v2_chain"):
blocked.append("V2 链式没有预测")
if not metrics.get("jev_v2_gold"):
blocked.append("V2 gold 上界没有预测")
if not metrics.get("flash_v1"):
blocked.append("Flash+V1 没有预测")
if not metrics.get("flash_v0"):
blocked.append("Flash V0 没有预测")
represent = metrics.get("representativeness") or {}
if represent.get("fail"):
blocked.append("来源 B 与来源 C 同层 intent 差 > 10pp")
if blocked:
return {
"verdict": "缺数据",
"blocked": ";".join(blocked),
"reason": "上一轮变量没有测全,不能判过门。已有的 V0 数字只作对照,不代替 V1/V2。",
}
v0_none = ((metrics.get("jev_v0") or {}).get("by_layer") or {}).get("none") or {}
v2_none = ((metrics.get("jev_v2_chain") or {}).get("by_layer") or {}).get("none") or {}
v0_high = v0_none.get("high_conf_error_rate")
v2_high = v2_none.get("high_conf_error_rate")
if v0_high is None or v2_high is None:
return {"verdict": "blocked", "blocked": "无焦点层高置信错误写不出", "reason": "V2 相对 V0 的无焦点层比较缺数字。"}
jev = (metrics.get("jev_v2_chain") or {}).get("all") or {}
flash = (metrics.get("flash_v0") or {}).get("all") or {}
high = jev.get("high_conf_error_rate")
recall = jev.get("low_conf_recall")
jev_intent = jev.get("intent_acc")
flash_intent = flash.get("intent_acc")
missing = [
name for name, value in (
("高置信错误", high),
("低置信召回", recall),
("V2 intent", jev_intent),
("Flash V0 intent", flash_intent),
) if value is None
]
if missing:
return {"verdict": "blocked", "blocked": "、".join(missing), "reason": "门槛里有写不出的数字。"}
gates = []
if high > 0.03:
gates.append(f"高置信错误 {high:.1%} > 3%")
if recall < 0.60:
gates.append(f"低置信召回 {recall:.1%} < 60%")
if jev_intent < flash_intent - 0.03:
gates.append(f"intent {jev_intent:.1%} < 现行 {flash_intent:.1%} − 3pp")
if v2_high >= v0_high:
gates.append(f"无焦点层高置信错误 V2 {v2_high:.1%} 没有低于 V0 {v0_high:.1%}")
if gates:
return {"verdict": "未过门", "blocked": "", "reason": ";".join(gates)}
return {
"verdict": "过门",
"blocked": "",
"reason": "三项门槛都过,且 V2 无焦点层高置信错误低于 V0。",
}
def representativeness(source_b: Sequence[Mapping[str, Any]], source_c: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
layers = {}
fail = False
for layer in ("choice", "collect", "none"):
b_rows = [row for row in source_b if row.get("layer") == layer and row.get("jev_v0_1")]
c_rows = [row for row in source_c if row.get("layer") == layer and row.get("jev_v0_1")]
if not b_rows or not c_rows:
layers[layer] = {"n_b": len(b_rows), "n_c": len(c_rows), "delta": None}
continue
b_acc = layer_metrics(b_rows, pred_key="jev_v0_1")["intent_acc"]
c_acc = layer_metrics(c_rows, pred_key="jev_v0_1")["intent_acc"]
delta = abs(b_acc - c_acc)
fail = fail or delta > 0.10
published = PUBLISHED_C_INTENT[layer]
layers[layer] = {
"n_b": len(b_rows),
"n_c": len(c_rows),
"source_b_intent": b_acc,
"source_c_intent": c_acc,
"delta": delta,
"published_c_intent": published,
"published_delta_pp": (c_acc - published) * 100,
}
return {"fail": fail, "by_layer": layers}
def build_from_samples(source_b: Sequence[dict[str, Any]], source_c: Sequence[dict[str, Any]]) -> dict[str, Any]:
for row in list(source_b) + list(source_c):
if "previous_turn" in row:
row["has_previous_turn"] = has_previous(row)
else:
row["has_previous_turn"] = bool(row.get("has_previous_turn"))
pred_keys = ("jev_v0_1", "jev_v0_2", "flash_v0")
b_metrics = {
"jev_v0": pack_metrics(source_b, "jev_v0_1"),
"flash_v0": strip_block(pack_metrics(source_b, "flash_v0")),
"jev_v1": pack_metrics(source_b, "jev_v1_1"),
"jev_v2_chain": pack_metrics(source_b, "jev_v2_chain_1"),
"jev_v2_gold": pack_metrics(source_b, "jev_v2_gold_1"),
"flash_v1": strip_block(pack_metrics(source_b, "flash_v1")),
}
if b_metrics["jev_v0"]:
b_metrics["jev_v0"]["self_consistency"] = self_consistency(source_b, "jev_v0_1", "jev_v0_2")
none_b = [row for row in source_b if row.get("layer") == "none"]
meta = {
"model": JEV_MODEL,
"sdk": "typesafe-sdk 0.7.0",
"source_b_n": len(source_b),
"n_with_previous": sum(1 for row in source_b if row.get("has_previous_turn")),
"n_without_previous": sum(1 for row in source_b if not row.get("has_previous_turn")),
"layers": {
layer: sum(1 for row in source_b if row.get("layer") == layer)
for layer in ("choice", "collect", "none")
},
"gold_source": {},
"source_c_n": len(source_c),
"previous_decision_note": "链式与 gold 上界都未跑。09-19 文件没有 turn_id / case_id,本机没有 staging 库。",
}
gold_counts: dict[str, int] = {}
for row in source_b:
source = str(row.get("gold_source") or "unknown")
gold_counts[source] = gold_counts.get(source, 0) + 1
meta["gold_source"] = gold_counts
metrics = {
**b_metrics,
"source_c_v0": pack_metrics(source_c, "jev_v0_1"),
"representativeness": representativeness(source_b, source_c),
"source_b_none_confusion": {
"jev_v0": confusion_counts(none_b, "jev_v0_1"),
"flash_v0": confusion_counts(none_b, "flash_v0"),
} if none_b else {},
}
if metrics["source_c_v0"]:
metrics["source_c_v0"]["self_consistency"] = self_consistency(source_c, "jev_v0_1", "jev_v0_2")
conclusion = decide(meta, metrics)
return {
"meta": meta,
"metrics": metrics,
"conclusion": conclusion,
"rows": public_rows(list(source_b) + list(source_c), pred_keys + (
"jev_v1_1", "jev_v1_2", "jev_v2_chain_1", "jev_v2_chain_2",
"jev_v2_gold_1", "jev_v2_gold_2", "flash_v1",
)),
}
def load_samples() -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, int]]:
if OUTPUT_PATH.is_file():
source_b = load_jsonl(OUTPUT_PATH)
else:
source_b = from_legacy(load_jsonl(CACHE_DIR / "source_b.jsonl"))
source_b = [row for row in source_b if isinstance(row.get("gold"), dict) and row["gold"].get("intent")]
source_c = load_jsonl(SAMPLES_DIR / "simulated.jsonl")
cache = load_cache()
missing = {}
missing.update(attach_legacy_preds(source_b, cache, {
"jev_v0_1": "jev_1",
"jev_v0_2": "jev_2",
"flash_v0": "current_1",
}))
missing.update(attach_legacy_preds(source_c, cache, {
"jev_v0_1": "jev_1",
"jev_v0_2": "jev_2",
}))
return source_b, source_c, missing
def recompute_from_rows(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
source_b = [dict(row) for row in rows if row.get("source") == "B"]
source_c = [dict(row) for row in rows if row.get("source") == "C"]
return build_from_samples(source_b, source_c)
def metric_line(title: str, block: Mapping[str, Any] | None) -> str:
if not block or not block.get("all"):
return f"| {title} | — | — | — | — | — | — | — |"
all_m = block["all"]
none_high = ((block.get("by_layer") or {}).get("none") or {}).get("high_conf_error_rate")
return (
f"| {title} | {all_m['n']} | {pct(all_m['intent_acc'])} | {pct(all_m['answer_class_acc'])} | "
f"{pct(all_m['dated_acc'])} | {pct(all_m['high_conf_error_rate'])} | {pct(all_m['low_conf_recall'])} | "
f"{block.get('none_unclear_to_focus')} / 无焦点高置信错误 {pct(none_high)} |"
)
def write_markdown(report: Mapping[str, Any]) -> None:
meta = report["meta"]
metrics = report["metrics"]
conclusion = report["conclusion"]
layers = meta.get("layers") or {}
gold = meta.get("gold_source") or {}
represent = metrics.get("representativeness") or {}
lines = [
"# TypeSafe Jev 意图分类 · 上一轮 state 对照(2026-09-27)",
"",
f"- 任务:`docs/tasks/TASK-rectification-jev-intent-classifier-research-v2-20260927.md`",
f"- 基线:`origin/staging` @ `710c848b`",
f"- 模型:`{meta.get('model')}`;SDK `{meta.get('sdk')}`",
f"- 结论:**{conclusion.get('verdict')}**。{conclusion.get('reason')}",
"",
"## 样本",
"",
f"- 来源 B:{meta.get('source_b_n')} 条。有上一轮 {meta.get('n_with_previous')},无上一轮 {meta.get('n_without_previous')}。",
f"- 层:点选 {layers.get('choice', 0)} / 采集 {layers.get('collect', 0)} / 无焦点 {layers.get('none', 0)}。",
f"- gold 来源:{json.dumps(gold, ensure_ascii=False)}。",
f"- {meta.get('previous_decision_note')}",
"",
"## 来源 B",
"",
"| 变体 | n | intent | answer_class | dated | 高置信错误 | 低置信召回 | 无焦点 unclear→answer |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
metric_line("Jev V0(09-19 缓存)", metrics.get("jev_v0")),
metric_line("Flash V0(09-19 缓存,生产提示)", metrics.get("flash_v0")),
metric_line("Jev V1", metrics.get("jev_v1")),
metric_line("Jev V2 链式", metrics.get("jev_v2_chain")),
metric_line("Jev V2 gold 上界", metrics.get("jev_v2_gold")),
metric_line("Flash + V1", metrics.get("flash_v1")),
"",
f"Jev V0 自洽率:{pct((metrics.get('jev_v0') or {}).get('self_consistency'))}。",
"",
"## 来源 C 回归锚(只 V0)",
"",
]
c_block = metrics.get("source_c_v0") or {}
for layer in ("choice", "collect", "none"):
cell = (represent.get("by_layer") or {}).get(layer) or {}
published = cell.get("published_c_intent")
got = cell.get("source_c_intent")
delta = cell.get("published_delta_pp")
lines.append(
f"- {layer}:重算 {pct(got)},09-19 公布 {pct(published)},差 {delta if delta is None else round(delta, 2)} pp(n={cell.get('n_c')})。"
)
lines += [
f"- 来源 C 自洽率:{pct(c_block.get('self_consistency'))}。",
"",
"## 无焦点层混淆(来源 B,V0)",
"",
"计数来自报告 JSON,不含原文。",
"",
]
confusion = (metrics.get("source_b_none_confusion") or {}).get("jev_v0") or {}
labels = confusion.get("labels") or []
counts = confusion.get("counts") or {}
if labels:
lines.append("| gold \\ pred | " + " | ".join(labels) + " |")
lines.append("| --- | " + " | ".join("---:" for _ in labels) + " |")
for gold in labels:
cells = [str((counts.get(gold) or {}).get(pred, 0)) for pred in labels]
lines.append(f"| {gold} | " + " | ".join(cells) + " |")
lines += [
"",
"## 写不出的项",
"",
conclusion.get("blocked") or "无",
"",
"V1、V2、Flash+V1 要等 staging 库抽出带 `case_id` 的上一轮,并且本机有 `DEEPSEEK_API_KEY` 之后才能补。门槛不放宽。",
"",
]
REPORT_MD.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--offline", action="store_true")
parser.add_argument("--from-report", action="store_true", help="recompute tables from the committed JSON rows")
args = parser.parse_args(argv)
if args.from_report:
if not REPORT_JSON.is_file():
print("report json missing", file=sys.stderr)
return 2
payload = json.loads(REPORT_JSON.read_text(encoding="utf-8"))
report = recompute_from_rows(payload.get("rows") or [])
report["meta"]["recomputed_from"] = "report_rows"
write_markdown(report)
print(json.dumps({
"verdict": report["conclusion"]["verdict"],
"source_b_intent": ((report["metrics"].get("jev_v0") or {}).get("all") or {}).get("intent_acc"),
"n": report["meta"]["source_b_n"],
"n_with_previous": report["meta"]["n_with_previous"],
}, ensure_ascii=False))
return 0
source_b, source_c, missing = load_samples()
report = build_from_samples(source_b, source_c)
report["meta"]["legacy_cache_missing"] = missing
report["meta"]["offline"] = True
REPORT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
write_markdown(report)
print(json.dumps({
"verdict": report["conclusion"]["verdict"],
"source_b_n": report["meta"]["source_b_n"],
"n_with_previous": report["meta"]["n_with_previous"],
"missing": missing,
"json": str(REPORT_JSON),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+120 -10
View File
@@ -20,6 +20,8 @@ ANSWER_CLASS_VALUES = ("yes", "weak_yes", "no", "unsure")
COLLECT_ANSWER_CLASSES = ("yes", "weak_yes", "no", "unsure")
JEV_MODEL = "jev-1.13.0"
NOUL_TRUE_THRESHOLD = 0.5
CONTINUE_THRESHOLD = 0.9
STATE_VARIANTS = ("v0", "v1", "v2")
# Literal criteria. Production hedges ("通常", "不要猜", "不要按关键词") are dropped.
INTENT_INSTRUCTIONS = {
@@ -71,6 +73,12 @@ HAS_NEW_DATED_EVENT_CRITERIA = {
),
}
CONTINUES_PREVIOUS_TURN_INSTRUCTIONS = "这句是否只是承接上一轮,没有新的意思。"
CONTINUES_PREVIOUS_TURN_CRITERIA = {
"true": "这句只承接上一轮,没有提出新问题,也没有补充新的意思。",
"false": "这句是在回答新问题、另起一件事,或补充了上一轮没有的意思。",
}
# One row per production prompt sentence that was rewritten or dropped.
CRITERION_MAP: tuple[dict[str, str], ...] = (
{
@@ -201,26 +209,83 @@ def answer_class_criteria(
return criteria
def build_state(sample: Mapping[str, Any]) -> dict[str, Any]:
def previous_turn_payload(sample: Mapping[str, Any]) -> dict[str, str] | None:
"""Prior turn in the same case. Missing case linkage or a first turn is null."""
prev = sample.get("previous_turn")
if not isinstance(prev, dict):
return None
if "assistant_message" not in prev and "user_message" not in prev:
return None
return {
"assistant_message": "" if prev.get("assistant_message") is None else str(prev.get("assistant_message")),
"user_message": "" if prev.get("user_message") is None else str(prev.get("user_message")),
}
def previous_decision_payload(sample: Mapping[str, Any]) -> dict[str, Any] | None:
decision = sample.get("previous_decision")
if not isinstance(decision, dict):
return None
intent = decision.get("intent")
if intent not in INTENT_VALUES:
return None
answer = decision.get("answer_class")
if answer not in ANSWER_CLASS_VALUES:
answer = None
return {"intent": intent, "answer_class": answer}
def intent_instructions(variant: str = "v0") -> dict[str, Any]:
"""V0 stays the 09-19 instructions. V1/V2 name the extra state fields; criteria do not change."""
if variant not in STATE_VARIANTS:
raise ValueError(f"unknown state variant: {variant}")
if variant == "v0":
return INTENT_INSTRUCTIONS
inputs = ["current_question", "options", "user_message", "case_status", "previous_turn"]
context = (
"previous_turn is the immediately previous assistant_message and user_message in this case. "
"It is null on the first turn. Classify the current user_message, not the previous one."
)
if variant == "v2":
inputs.append("previous_decision")
context += " previous_decision is the prior turn's intent and answer_class, not a label for this turn."
return {
"task": INTENT_INSTRUCTIONS["task"],
"inputs": inputs,
"rule": INTENT_INSTRUCTIONS["rule"],
"context": context,
}
def build_state(sample: Mapping[str, Any], *, variant: str = "v0") -> dict[str, Any]:
if variant not in STATE_VARIANTS:
raise ValueError(f"unknown state variant: {variant}")
focus = sample.get("focus") if isinstance(sample.get("focus"), dict) else {}
options = list(focus.get("options") or [])
return {
state: dict[str, Any] = {
"current_question": str(focus.get("current_question") or ""),
"options": options,
"user_message": str(sample.get("user_message") or ""),
"case_status": str(focus.get("case_status") or sample.get("case_status") or "collecting_evidence"),
}
if variant in {"v1", "v2"}:
state["previous_turn"] = previous_turn_payload(sample)
if variant == "v2":
state["previous_decision"] = previous_decision_payload(sample)
return state
def build_questions(sample: Mapping[str, Any]) -> dict[str, Any]:
"""Three TypeSafe questions, same request, official fan-out."""
def build_questions(sample: Mapping[str, Any], *, variant: str = "v0") -> dict[str, Any]:
"""Three TypeSafe questions, same request, official fan-out. V2 adds one Noul."""
if variant not in STATE_VARIANTS:
raise ValueError(f"unknown state variant: {variant}")
layer = str(sample.get("layer") or "none")
focus = sample.get("focus") if isinstance(sample.get("focus"), dict) else {}
options = list(focus.get("options") or [])
return {
questions = {
"intent": {
"type": "choice",
"instructions": INTENT_INSTRUCTIONS,
"instructions": intent_instructions(variant),
"criteria": dict(INTENT_CRITERIA),
},
"answer_class": {
@@ -234,16 +299,23 @@ def build_questions(sample: Mapping[str, Any]) -> dict[str, Any]:
"criteria": dict(HAS_NEW_DATED_EVENT_CRITERIA),
},
}
if variant == "v2":
questions["continues_previous_turn"] = {
"type": "noul",
"instructions": CONTINUES_PREVIOUS_TURN_INSTRUCTIONS,
"criteria": dict(CONTINUES_PREVIOUS_TURN_CRITERIA),
}
return questions
def sdk_questions(sample: Mapping[str, Any]) -> dict[str, Any]:
def sdk_questions(sample: Mapping[str, Any], *, variant: str = "v0") -> dict[str, Any]:
"""SDK objects when typesafe_sdk is installed; JSON dicts otherwise."""
payload = build_questions(sample)
payload = build_questions(sample, variant=variant)
try:
from typesafe_sdk import Choice, Noul
except ImportError:
return payload
return {
built = {
"intent": Choice(
instructions=payload["intent"]["instructions"],
criteria=payload["intent"]["criteria"],
@@ -257,6 +329,13 @@ def sdk_questions(sample: Mapping[str, Any]) -> dict[str, Any]:
criteria=payload["has_new_dated_event"]["criteria"],
),
}
continues = payload.get("continues_previous_turn")
if isinstance(continues, dict):
built["continues_previous_turn"] = Noul(
instructions=continues["instructions"],
criteria=continues["criteria"],
)
return built
def enforce_combo(
@@ -287,7 +366,12 @@ def enforce_combo(
}
def parse_jev_answers(answers: Mapping[str, Any]) -> dict[str, Any]:
def parse_jev_answers(
answers: Mapping[str, Any],
*,
previous_decision: Mapping[str, Any] | None = None,
apply_continue: bool = False,
) -> dict[str, Any]:
intent_row = answers.get("intent") or {}
class_row = answers.get("answer_class") or {}
noul_row = answers.get("has_new_dated_event") or {}
@@ -315,6 +399,13 @@ def parse_jev_answers(answers: Mapping[str, Any]) -> dict[str, Any]:
"noul": noul_value,
},
}
continues_row = answers.get("continues_previous_turn") or {}
continues_value = continues_row.get("noul")
parsed["raw"]["continues_previous_turn"] = {"noul": continues_value}
parsed["continues_previous_turn"] = (
float(continues_value) if isinstance(continues_value, (int, float)) else None
)
parsed["continued"] = False
intent_conf = intent_row.get("confidence")
class_conf = class_row.get("confidence")
if parsed["intent"] == "answer_current_focus" and isinstance(intent_conf, (int, float)) and isinstance(class_conf, (int, float)):
@@ -323,4 +414,23 @@ def parse_jev_answers(answers: Mapping[str, Any]) -> dict[str, Any]:
parsed["confidence"] = float(intent_conf)
else:
parsed["confidence"] = None
decision = previous_decision_payload({"previous_decision": previous_decision or {}})
if (
apply_continue
and isinstance(continues_value, (int, float))
and float(continues_value) >= CONTINUE_THRESHOLD
and decision is not None
):
adopted = enforce_combo(
decision["intent"],
decision.get("answer_class"),
parsed["has_new_dated_event"],
)
parsed["intent_before_continue"] = parsed["intent"]
parsed["answer_class_before_continue"] = parsed["answer_class"]
parsed["intent"] = adopted["intent"]
parsed["answer_class"] = adopted["answer_class"]
parsed["has_new_dated_event"] = adopted["has_new_dated_event"]
parsed["continued"] = True
parsed["confidence"] = float(continues_value)
return parsed
+14 -5
View File
@@ -15,7 +15,9 @@ sha256: `79d53a958b3b42ed18c79e3bbcff4b31deb22b65eedcd89b39326a7fefc9b0e8`
```sql
select
t.id as turn_id,
t.case_id,
t.user_message,
t.assistant_message,
t.status as turn_status,
t.created_at,
c.status as case_status,
@@ -24,16 +26,23 @@ select
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'
left join lateral (
select *
from public.agentic_rectification_conversation_focuses f
where f.case_id = t.case_id
and f.asked_at <= t.created_at
order by f.asked_at desc
limit 1
) f on true
where t.user_message is not null
and length(btrim(t.user_message)) > 0
order by t.created_at desc
limit 500;
order by t.case_id, t.created_at;
```
同一条 SQL 也写在 `scripts/research/jev_intent_source_b_v2.py` 的 `EXTRACT_SQL`。本地脚本按 `(case_id, created_at)` 给每条填 `previous_turn = {assistant_message, user_message}`;同一案件的第一轮,以及没有 `case_id` 的行,都是 `null`。没有 `case_id` 的行不会互相串成上一轮。
- 本地路径(worktree 外,已 gitignore):`G:/Ferti/Jyotisha/.cache/jev_intent/source_b.jsonl`
- v2 路径(同样不提交):`G:/Ferti/Jyotisha/.cache/jev_intent/source_b_v2.jsonl`
- 本机抽取条数:157(点选 17 / 采集 107 / 无焦点 33),gold 已全部人工标注,未用模型代标
- 长度:P25=12 / 中位=21 / P75=28 / 最长=172
- 标注耗时:约 40 分钟(`runtime_intent` 只作参考,85 条有反推 intent,不抄成 gold)
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Build source B v2 rows with the previous turn in the same case.
Offline. Writes only under the gitignored cache. Does not label gold with a
model or with a keyword table. Old gold is copied by turn_id, or by a unique
exact user_message when the 09-19 file has no turn_id.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
CACHE_DIR = Path(r"G:\Ferti\Jyotisha\.cache\jev_intent")
LEGACY_PATH = CACHE_DIR / "source_b.jsonl"
OUTPUT_PATH = CACHE_DIR / "source_b_v2.jsonl"
EXTRACT_SQL = """
select
t.id as turn_id,
t.case_id,
t.user_message,
t.assistant_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 lateral (
select *
from public.agentic_rectification_conversation_focuses f
where f.case_id = t.case_id
and f.asked_at <= t.created_at
order by f.asked_at desc
limit 1
) f on true
where t.user_message is not null
and length(btrim(t.user_message)) > 0
order by t.case_id, t.created_at
"""
def load_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def write_jsonl(path: Path, rows: Sequence[Mapping[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
def attach_previous_turns(rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
"""First turn of a case, and any row without case_id, gets previous_turn null.
Rows that share a missing case_id are not chained: that would glue
different people together.
"""
by_case: dict[str, list[dict[str, Any]]] = {}
for row in rows:
case_id = str(row.get("case_id") or "").strip()
if not case_id:
row["previous_turn"] = None
row["previous_turn_source"] = "no_case"
continue
by_case.setdefault(case_id, []).append(row)
for case_rows in by_case.values():
case_rows.sort(key=lambda item: (
str(item.get("created_at") or ""),
str(item.get("turn_id") or item.get("id") or ""),
))
for index, row in enumerate(case_rows):
if index == 0:
row["previous_turn"] = None
row["previous_turn_source"] = "case_first"
continue
prev = case_rows[index - 1]
row["previous_turn"] = {
"assistant_message": "" if prev.get("assistant_message") is None else str(prev.get("assistant_message")),
"user_message": "" if prev.get("user_message") is None else str(prev.get("user_message")),
}
row["previous_turn_source"] = "prior_turn"
return list(rows)
def match_gold(rows: Sequence[dict[str, Any]], legacy: Sequence[Mapping[str, Any]]) -> dict[str, int]:
by_turn: dict[str, Mapping[str, Any]] = {}
by_message: dict[str, list[Mapping[str, Any]]] = {}
for old in legacy:
turn_id = str(old.get("turn_id") or "").strip()
if turn_id:
by_turn[turn_id] = old
message = old.get("user_message")
if isinstance(message, str) and message:
by_message.setdefault(message, []).append(old)
counts = {"turn_id": 0, "message_exact": 0, "unlabeled": 0, "message_ambiguous": 0}
for row in rows:
gold = None
source = "unlabeled"
legacy_id = None
turn_id = str(row.get("turn_id") or "").strip()
if turn_id and turn_id in by_turn:
gold = by_turn[turn_id].get("gold")
source = "turn_id"
legacy_id = by_turn[turn_id].get("id")
else:
hits = by_message.get(str(row.get("user_message") or "")) or []
if len(hits) == 1 and isinstance(hits[0].get("gold"), dict):
gold = hits[0]["gold"]
source = "message_exact"
legacy_id = hits[0].get("id")
elif len(hits) > 1:
source = "message_ambiguous"
if isinstance(gold, dict) and gold.get("intent"):
row["gold"] = {
"intent": gold.get("intent"),
"answer_class": gold.get("answer_class"),
"has_new_dated_event": gold.get("has_new_dated_event"),
}
row["gold_source"] = source
if legacy_id:
row["legacy_id"] = legacy_id
else:
row["gold_source"] = source
row.pop("gold", None)
counts[source] = counts.get(source, 0) + 1
return counts
def from_legacy(legacy: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""The handed-over 157 rows have no case_id. previous_turn stays null."""
rows: list[dict[str, Any]] = []
for old in legacy:
if not isinstance(old.get("gold"), dict) or not old["gold"].get("intent"):
continue
rows.append({
"id": old.get("id"),
"source": "B",
"layer": old.get("layer"),
"user_message": old.get("user_message"),
"assistant_message": None,
"case_id": None,
"turn_id": old.get("turn_id"),
"created_at": old.get("created_at"),
"focus": old.get("focus"),
"focus_stale": old.get("focus_stale"),
"runtime_intent": old.get("runtime_intent"),
"gold": old.get("gold"),
"gold_source": "legacy_file",
"origin": old.get("origin"),
})
return attach_previous_turns(rows)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--from-legacy", action="store_true")
parser.add_argument("--raw", type=Path, default=None)
parser.add_argument("--out", type=Path, default=OUTPUT_PATH)
args = parser.parse_args(argv)
legacy = load_jsonl(LEGACY_PATH)
if args.from_legacy:
rows = from_legacy(legacy)
write_jsonl(args.out, rows)
print(json.dumps({
"out": str(args.out),
"n": len(rows),
"n_with_previous": sum(1 for row in rows if row.get("previous_turn")),
"gold_source": "legacy_file",
}, ensure_ascii=False))
return 0
if args.raw:
raw_rows = load_jsonl(args.raw)
rows = attach_previous_turns(raw_rows)
counts = match_gold(rows, legacy)
write_jsonl(args.out, rows)
print(json.dumps({
"out": str(args.out),
"n": len(rows),
"n_with_previous": sum(1 for row in rows if row.get("previous_turn")),
"gold": counts,
}, ensure_ascii=False))
return 0
print("pass --from-legacy or --raw; staging SQL is EXTRACT_SQL in this file", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())