research(rectification): DeepSeek Flash 抽 33% 对照现行分类器提示词
来源 B 仍为 0。Flash 套生产提示词,采集题相对门槛未过。结论仍不可接。
This commit is contained in:
+4
-5
@@ -11,12 +11,11 @@
|
||||
- **替代:** 按任务书让步 1,代表性检验只报数、不判定。来源 C 照常跑。抽取 SQL 与本地路径写在 `scripts/research/jev_intent_samples/README.md`(`G:/Ferti/Jyotisha/.cache/jev_intent/source_b.jsonl`,gitignore)。
|
||||
- **解除需要:** 只读抽 ≥30 条非空 `user_message`(含当轮焦点 schema 与后续动作),执行方逐条人工标注。不得提交原文。
|
||||
|
||||
## 生时校正 Jev 研究:无会话模型凭据,现行分类器未对照(2026-09-19)
|
||||
## ~~生时校正 Jev 研究:无会话模型凭据,现行分类器未对照(2026-09-19)~~
|
||||
|
||||
- **缺的是什么:** 会话默认贵模型。模型 API key 在数据库加密配置里,本机目录为空(`database_model_catalog_required`)。
|
||||
- **替代:** T0 用来源 C 的 Agent 模板生成器;T2 只跑了 Jev。相对门槛「各层 ≥ 现行 − 3pp」无法计算。
|
||||
- **不影响结论:** Jev 在来源 C 上高置信错误 7.0%、低置信召回 34%、点选题 `answer_class` 65%,绝对门槛已判定 **不可接**。
|
||||
- **解除需要:** 用与线上相同的 `classifyRectificationTurnIntent` + 会话默认模型把来源 A/C 再跑一遍(至少一次;自洽率第二次可抽 1/3)。
|
||||
- **部分解除(2026-09-19):** 产品提供 DeepSeek key,用 `deepseek-flash` 套生产 `classifyRectificationTurnIntent` 提示词,来源 C 分层随机 33% + 来源 A 全量。相对门槛采集题未过(91.0% < 92.5%)。绝对门槛仍判 **不可接**。
|
||||
- **仍缺:** 线上会话默认贵模型(数据库加密配置)未跑;这是替代对照,不是生产模型。
|
||||
- 来源 B 仍为 0,见上条。
|
||||
|
||||
## 会话列表:元数据操作未上移到 provider(2026-09-17,BUG-927 让步)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
**不可接**。来源 C 上 Jev 的绝对门槛未过:高置信错误率 7.0% > 3%;低置信召回 34.1% / 34.2% < 60%(错了却仍高置信);点选题 `answer_class` 准确率 65.0%(写库字段)。来源 B 不足 30 条,代表性检验只报数、不判定。现行模型对照未跑,相对 −3pp 门槛无法计算。
|
||||
**不可接**。来源 C 上 Jev 的绝对门槛未过:高置信错误率 7.0% > 3%;低置信召回 34.1% / 34.2% < 60%(错了却仍高置信);点选题 answer_class 准确率 65.0%(写库字段)。来源 B 不足 30 条,代表性检验只报数、不判定。 相对 −3pp(同一样本):choice Jev 89.5% vs 现行 92.5%(门槛 89.5%,过);collect Jev 91.0% vs 现行 95.5%(门槛 92.5%,未过);none Jev 100.0% vs 现行 97.0%(门槛 94.0%,过)。
|
||||
|
||||
若接:不接。现行分类器继续用会话选定的贵模型。若还要观察中文口语,只允许 (b) 影子双跑只记日志,不得按 confidence 写库。曲线上 θ=0.9 时高置信错误仍未清零。
|
||||
|
||||
@@ -21,11 +21,25 @@
|
||||
| collect | 400 | 90.5% | 83.6% | 97.0% | 7.0% | 13.5% | 34.2% | 22 | 98.0% | 901 | 1026 | 987 |
|
||||
| none | 100 | 100.0% | — | 100.0% | 0.0% | 4.0% | — | 0 | 100.0% | 923 | 1018 | 923 |
|
||||
|
||||
来源 A(测试夹具,n=10):intent 90.0%,answer_class 85.7%,高置信错误 0.0%。
|
||||
来源 A(测试夹具,n=10)intent 90.0%,answer_class 85.7%,高置信错误 0.0%。
|
||||
|
||||
## 现行模型对照
|
||||
|
||||
本机无会话模型目录凭据(模型 key 在数据库加密配置里)。现行 `classifyRectificationTurnIntent` 对照未跑。
|
||||
模型:`deepseek-flash`。DeepSeek Flash 顶现行 `classifyRectificationTurnIntent` 提示词;来源 C 分层随机 33%(seed 20260919),来源 A 全量。不是线上会话模型。
|
||||
|
||||
| 层 | n | intent | answer_class | dated | 自洽率 | 中位 ms |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| choice | 133 | 92.5% | 83.0% | 100.0% | 100.0% | 638 |
|
||||
| collect | 133 | 95.5% | 95.8% | 97.7% | 95.5% | 611 |
|
||||
| none | 33 | 97.0% | — | 100.0% | 100.0% | 705 |
|
||||
|
||||
同一样本上 Jev vs 现行(相对门槛用这一表):
|
||||
|
||||
| 层 | n | Jev intent | 现行 intent | 差(Jev−现行) | 门槛现行−3pp |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| choice | 133 | 89.5% | 92.5% | -3.0% | 89.5% |
|
||||
| collect | 133 | 91.0% | 95.5% | -4.5% | 92.5% |
|
||||
| none | 33 | 100.0% | 97.0% | 3.0% | 94.0% |
|
||||
|
||||
## 代表性检验(来源 B vs 来源 C)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
- Jev:`jev-1.13.0`,`POST /v1/systemone`,SDK `typesafe-sdk 0.7.0`,三题同一请求。
|
||||
- 时间:2026-09-19,来源 A+C 各 2 次;unavailable = 0。
|
||||
- 现行分类器:未跑(模型 key 在数据库加密配置,本机没有)。
|
||||
- 现行对照:`deepseek-flash`(官方 Chat Completions,thinking 关)套生产分类器提示词。来源 C 分层随机 33%(seed 20260919)= 133+133+33,来源 A 全 10 条;第一次 309,第二次分层 1/3。unavailable = 0。**不是线上会话模型。** 来源 B 仍为 0。
|
||||
|
||||
## 指标(来源 C)
|
||||
|
||||
@@ -37,23 +37,34 @@
|
||||
|
||||
来源 A n=10:intent 90.0%,answer_class 85.7%,高置信错误 0。
|
||||
|
||||
两次合计输入约 1.85M tok,按 $0.042/Mtok ≈ $0.08。输出免费。
|
||||
Jev 两次合计输入约 1.85M tok,按 $0.042/Mtok ≈ $0.08。
|
||||
|
||||
### 同一样本 DeepSeek Flash vs Jev(C 分层 33%)
|
||||
|
||||
| 层 | n | Jev intent | Flash intent | Flash answer_class | 差 | 门槛(Flash−3pp) | 相对 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 点选题 | 133 | 89.5% | 92.5% | 83.0% | −3.0pp | 89.5% | 过 |
|
||||
| 采集题 | 133 | 91.0% | 95.5% | 95.8% | −4.5pp | 92.5% | **未过** |
|
||||
| 无焦点 | 33 | 100% | 97.0% | — | +3.0pp | 94.0% | 过 |
|
||||
|
||||
Flash 自洽率:点选题 100%、采集 95.5%、无焦点 100%。中位延迟 611–705 ms。
|
||||
|
||||
## 结论
|
||||
|
||||
**不可接。** 绝对门槛未过:高置信错误 7.0% > 3%;低置信召回 ≈34% < 60%;点选题 `answer_class` 65%。中文口语(「就是那段」「嗯那会儿吧」)和字面理解是主要错因。自洽率高,说明错得稳定,不是随机抖动。
|
||||
**不可接。** 绝对门槛未过:高置信错误 7.0% > 3%;低置信召回 ≈34% < 60%;点选题 `answer_class` 65%。相对门槛在采集题上也未过(91.0% < 92.5%)。Flash 在点选题 `answer_class`(83%)和采集题(95.8%)都明显高于 Jev。中文口语和字面理解是 Jev 主错因。
|
||||
|
||||
若接:不接。继续用会话贵模型。只允许 (b) 影子双跑记日志;不得按 confidence 写库。θ=0.9 时高置信错误仍未清零。
|
||||
若接:不接。继续用会话贵模型。只允许 (b) 影子双跑记日志;不得按 confidence 写库。
|
||||
|
||||
相对 −3pp 因现行模型未跑而无法计算,不改变「不可接」。
|
||||
来源 B 仍为 0,代表性检验只报数。
|
||||
|
||||
## 偏离
|
||||
|
||||
1. 来源 B = 0:无 staging 库。按让步 1。
|
||||
2. 来源 A:测试文件并无 10 条 `(userMessage, 期望输出)` 夹具,改从该文件的 schema + 两句真实 `userMessage` 构造。
|
||||
3. 来源 C 生成未走会话贵模型(无凭据)。产品口径「Agent 模拟造语料」;生成/复核模型名写进报告。
|
||||
4. Windows 上 worktree `.venv` 是 25 字节 Linux symlink。`typesafe-sdk` 装进本机 Anaconda 3.11.7,未改 `requirements*.txt`。
|
||||
5. 未写 BUG_HISTORY(本单不是 Bug;现行分类器未跑,没有「同一输入稳定分错」的实证)。
|
||||
4. 现行对照用 `deepseek-flash` 套同一提示词,不是数据库里的会话默认模型。
|
||||
5. Windows 上 worktree `.venv` 是 25 字节 Linux symlink。`typesafe-sdk` 装进本机 Anaconda 3.11.7,未改 `requirements*.txt`。
|
||||
6. 未写 BUG_HISTORY(本单不是 Bug)。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Stand-in for classifyRectificationTurnIntent using an OpenAI-compatible chat API.
|
||||
|
||||
Copies the production instruction strings from turn-intent-classifier.ts.
|
||||
Does not change production. Reads DEEPSEEK_API_KEY from the environment only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from scripts.research.jev_intent_questions import build_state, enforce_combo
|
||||
|
||||
# Keep these byte-for-byte with frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts
|
||||
CHOICE_INSTRUCTIONS = """你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。
|
||||
结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
|
||||
若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。
|
||||
has_new_dated_event 仅在用户同一句里除了回答当前问题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定或单纯的选项回答必须为 false。
|
||||
若同一句话既回答了当前问题又补充了新的带时间经历,intent 仍为 answer_current_focus,has_new_dated_event 为 true。
|
||||
“当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
|
||||
不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。"""
|
||||
|
||||
COLLECT_INSTRUCTIONS = """你只做当前生时校正采集题的意图分类,不回答用户,也不修改任何状态。
|
||||
当前问题没有点选选项。判断用户是在回答当前采集题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
|
||||
「没有、没发生过、这方面没什么」→ intent 为 answer_current_focus,answer_class 为 no(该方面没有事,本次不再问)。
|
||||
「记不清、不记得、忘了、想不起来、以后再说」→ intent 为 answer_current_focus,answer_class 为 unsure(先放着,以后可补)。
|
||||
用户用带大概年月的经历直接回答当前采集题 → intent 为 answer_current_focus,answer_class 为 yes(程度较弱时为 weak_yes),has_new_dated_event 为 false。
|
||||
不要把「没有」或「记不清」标成 yes。若既没有否定、也没有说记不清、也没有给出带年月经历,intent 为 unclear,answer_class 必须为 null。
|
||||
若用户只在补充带时间的经历、并没有回答当前采集题,intent 为 provide_new_evidence,answer_class 必须为 null。
|
||||
has_new_dated_event 仅在用户同一句里除了回答当前采集题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定或记不清必须为 false。
|
||||
若同一句话既明确否定当前采集题又补充了新的带时间经历,intent 仍为 answer_current_focus 且 answer_class 为 no,不要改成 provide_new_evidence。
|
||||
“当前方面没有”通常是回答当前采集题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
|
||||
不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。"""
|
||||
|
||||
JSON_SCHEMA_HINT = (
|
||||
"只输出一个 JSON 对象,不要解释。字段:"
|
||||
'{"intent":"answer_current_focus|provide_new_evidence|stop_rectification|ask_about_result|unclear",'
|
||||
'"answer_class":"yes"|"weak_yes"|"no"|"unsure"|null,'
|
||||
'"has_new_dated_event":true|false}'
|
||||
)
|
||||
|
||||
DEFAULT_MODEL = "deepseek-flash"
|
||||
DEFAULT_BASE = "https://api.deepseek.com"
|
||||
|
||||
|
||||
def current_model_id() -> str:
|
||||
return os.environ.get("DEEPSEEK_MODEL") or DEFAULT_MODEL
|
||||
|
||||
|
||||
def _instructions(sample: Mapping[str, Any]) -> str:
|
||||
focus = sample.get("focus") if isinstance(sample.get("focus"), dict) else {}
|
||||
options = list(focus.get("options") or [])
|
||||
return CHOICE_INSTRUCTIONS if options else COLLECT_INSTRUCTIONS
|
||||
|
||||
|
||||
def _parse_content(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 call_current(sample: Mapping[str, Any], *, timeout: float = 60.0) -> dict[str, Any]:
|
||||
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("/")
|
||||
model = current_model_id()
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": _instructions(sample) + "\n" + JSON_SCHEMA_HINT},
|
||||
{"role": "user", "content": json.dumps(build_state(sample), ensure_ascii=False)},
|
||||
],
|
||||
"temperature": 0,
|
||||
"max_tokens": 256,
|
||||
"thinking": {"type": "disabled"},
|
||||
"response_format": {"type": "json_object"},
|
||||
"stream": False,
|
||||
}
|
||||
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",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||
content = (((payload.get("choices") or [{}])[0].get("message") or {}).get("content")) or ""
|
||||
parsed = _parse_content(content)
|
||||
combo = enforce_combo(
|
||||
parsed.get("intent"),
|
||||
parsed.get("answer_class"),
|
||||
parsed.get("has_new_dated_event"),
|
||||
)
|
||||
usage = payload.get("usage") or {}
|
||||
return {
|
||||
"ok": True,
|
||||
"unavailable": False,
|
||||
"model": payload.get("model") or model,
|
||||
"intent": combo["intent"],
|
||||
"answer_class": combo["answer_class"],
|
||||
"has_new_dated_event": combo["has_new_dated_event"],
|
||||
"confidence": None,
|
||||
"raw": parsed,
|
||||
"input_tokens": usage.get("prompt_tokens") or usage.get("input_tokens"),
|
||||
"output_tokens": usage.get("completion_tokens") or usage.get("output_tokens"),
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
|
||||
|
||||
def call_current_retry(sample: Mapping[str, Any], *, retries: int = 4) -> dict[str, Any]:
|
||||
last_error = ""
|
||||
delay = 1.0
|
||||
for _attempt in range(retries):
|
||||
try:
|
||||
return call_current(sample)
|
||||
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, 16)
|
||||
continue
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = type(exc).__name__
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, 16)
|
||||
return {
|
||||
"ok": False,
|
||||
"unavailable": True,
|
||||
"model": current_model_id(),
|
||||
"intent": None,
|
||||
"answer_class": None,
|
||||
"has_new_dated_event": None,
|
||||
"confidence": None,
|
||||
"raw": None,
|
||||
"input_tokens": None,
|
||||
"output_tokens": None,
|
||||
"elapsed_ms": None,
|
||||
"error": last_error,
|
||||
}
|
||||
|
||||
|
||||
def stratified_sample(
|
||||
rows: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
fraction: float,
|
||||
seed: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
import random
|
||||
|
||||
rng = random.Random(seed)
|
||||
picked: list[dict[str, Any]] = []
|
||||
by_layer: dict[str, list[Mapping[str, Any]]] = {"choice": [], "collect": [], "none": []}
|
||||
for row in rows:
|
||||
layer = str(row.get("layer") or "none")
|
||||
by_layer.setdefault(layer, []).append(row)
|
||||
for layer, group in by_layer.items():
|
||||
items = list(group)
|
||||
rng.shuffle(items)
|
||||
n = max(1, int(round(len(items) * fraction))) if items else 0
|
||||
picked.extend(dict(item) for item in items[:n])
|
||||
return picked
|
||||
@@ -20,6 +20,11 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research.jev_intent_current import ( # noqa: E402
|
||||
call_current_retry,
|
||||
current_model_id,
|
||||
stratified_sample,
|
||||
)
|
||||
from scripts.research.jev_intent_questions import ( # noqa: E402
|
||||
JEV_MODEL,
|
||||
build_state,
|
||||
@@ -321,7 +326,9 @@ def run_batch(
|
||||
workers: int,
|
||||
run_id: str,
|
||||
cache: dict[str, Any],
|
||||
call_fn=None,
|
||||
) -> None:
|
||||
caller = call_fn or call_jev_retry
|
||||
pending = []
|
||||
for sample in samples:
|
||||
key = f"{run_id}:{sample['id']}"
|
||||
@@ -333,7 +340,7 @@ def run_batch(
|
||||
return
|
||||
print(f"{run_id}: {len(pending)} calls, {len(samples) - len(pending)} cached", flush=True)
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = {pool.submit(call_jev_retry, sample): sample for sample in pending}
|
||||
futures = {pool.submit(caller, sample): sample for sample in pending}
|
||||
done = 0
|
||||
for future in as_completed(futures):
|
||||
sample = futures[future]
|
||||
@@ -416,19 +423,22 @@ def write_markdown(report: Mapping[str, Any]) -> None:
|
||||
f"{m['no_vs_unsure']} | {pct(cons)} | {ms(m['median_ms'])} | {ms(m['p95_ms'])} | "
|
||||
f"{(m['mean_input_tokens'] or 0):.0f} |"
|
||||
)
|
||||
source_a_m = report["metrics"].get("source_a")
|
||||
if source_a_m:
|
||||
acc = source_a_m.get("answer_class_acc")
|
||||
acc_txt = "—" if acc is None else f"{acc:.1%}"
|
||||
lines += [
|
||||
"",
|
||||
f"来源 A(测试夹具,n={source_a_m['n']})intent {source_a_m['intent_acc']:.1%},answer_class {acc_txt},高置信错误 {source_a_m['high_conf_error_rate']:.1%}。",
|
||||
]
|
||||
lines += [
|
||||
"",
|
||||
"## 现行模型对照",
|
||||
"",
|
||||
]
|
||||
source_a_m = report["metrics"].get("source_a")
|
||||
if source_a_m:
|
||||
lines += [
|
||||
"",
|
||||
f"来源 A(测试夹具,n={source_a_m['n']})intent {source_a_m['intent_acc']:.1%},answer_class {source_a_m['answer_class_acc'] or 0:.1%},高置信错误 {source_a_m['high_conf_error_rate']:.1%}。",
|
||||
]
|
||||
if report["meta"].get("current_model"):
|
||||
lines.append(f"模型:`{report['meta']['current_model']}`。")
|
||||
note = report["meta"].get("current_model_note") or ""
|
||||
lines.append(f"模型:`{report['meta']['current_model']}`。{note}")
|
||||
lines.append("")
|
||||
lines.append("| 层 | n | intent | answer_class | dated | 自洽率 | 中位 ms |")
|
||||
lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |")
|
||||
@@ -437,10 +447,30 @@ def write_markdown(report: Mapping[str, Any]) -> None:
|
||||
cons = (report["metrics"].get("current_self_consistency") or {}).get(layer)
|
||||
def pct(value: float | None) -> str:
|
||||
return "—" if value is None else f"{value:.1%}"
|
||||
def ms(value: float | None) -> str:
|
||||
return "—" if value is None else f"{value:.0f}"
|
||||
lines.append(
|
||||
f"| {layer} | {m.get('n', 0)} | {pct(m.get('intent_acc'))} | {pct(m.get('answer_class_acc'))} | "
|
||||
f"{pct(m.get('dated_acc'))} | {pct(cons)} | {m.get('median_ms') or 0:.0f} |"
|
||||
f"{pct(m.get('dated_acc'))} | {pct(cons)} | {ms(m.get('median_ms'))} |"
|
||||
)
|
||||
paired = report["metrics"].get("paired_sample") or {}
|
||||
if paired:
|
||||
lines.append("")
|
||||
lines.append("同一样本上 Jev vs 现行(相对门槛用这一表):")
|
||||
lines.append("")
|
||||
lines.append("| 层 | n | Jev intent | 现行 intent | 差(Jev−现行) | 门槛现行−3pp |")
|
||||
lines.append("| --- | ---: | ---: | ---: | ---: | ---: |")
|
||||
for layer in ("choice", "collect", "none"):
|
||||
cell = paired.get(layer) or {}
|
||||
def pct(value: float | None) -> str:
|
||||
return "—" if value is None else f"{value:.1%}"
|
||||
jev = cell.get("jev_intent")
|
||||
cur = cell.get("current_intent")
|
||||
delta = None if jev is None or cur is None else jev - cur
|
||||
gate = None if cur is None else cur - 0.03
|
||||
lines.append(
|
||||
f"| {layer} | {cell.get('n', 0)} | {pct(jev)} | {pct(cur)} | {pct(delta)} | {pct(gate)} |"
|
||||
)
|
||||
else:
|
||||
lines.append(report["meta"].get("current_model_note") or "未跑现行模型。")
|
||||
lines += [
|
||||
@@ -513,16 +543,34 @@ def decide_verdict(report: dict[str, Any]) -> dict[str, str]:
|
||||
if high_err > 0.03:
|
||||
absolute_fail.append(f"高置信错误率 {high_err:.1%} > 3%")
|
||||
if not recall_ok:
|
||||
absolute_fail.append(f"低置信召回 {low_recall} < 60%(错了却仍高置信)")
|
||||
recall_txt = " / ".join(f"{v:.1%}" for v in low_recall)
|
||||
absolute_fail.append(f"低置信召回 {recall_txt} < 60%(错了却仍高置信)")
|
||||
if choice_class is not None and choice_class < 0.80:
|
||||
absolute_fail.append(f"点选题 answer_class 准确率 {choice_class:.1%}(写库字段)")
|
||||
paired = report["metrics"].get("paired_sample") or {}
|
||||
relative_note = ""
|
||||
if paired:
|
||||
bits = []
|
||||
for layer in ("choice", "collect", "none"):
|
||||
cell = paired.get(layer) or {}
|
||||
jev = cell.get("jev_intent")
|
||||
cur = cell.get("current_intent")
|
||||
if jev is None or cur is None:
|
||||
continue
|
||||
ok = round(jev * 100, 1) >= round((cur - 0.03) * 100, 1)
|
||||
bits.append(
|
||||
f"{layer} Jev {jev:.1%} vs 现行 {cur:.1%}(门槛 {cur-0.03:.1%},{'过' if ok else '未过'})"
|
||||
)
|
||||
if bits:
|
||||
relative_note = " 相对 −3pp(同一样本):" + ";".join(bits) + "。"
|
||||
if absolute_fail:
|
||||
return {
|
||||
"verdict": "不可接",
|
||||
"reason": (
|
||||
"来源 C 上 Jev 的绝对门槛未过:" + ";".join(absolute_fail) + "。"
|
||||
+ represent
|
||||
+ (" 现行模型对照未跑,相对 −3pp 门槛无法计算。" if not current else "")
|
||||
+ relative_note
|
||||
+ (" 现行模型对照未跑,相对 −3pp 门槛无法计算。" if not current and not paired else "")
|
||||
),
|
||||
"if_connect": (
|
||||
"不接。现行分类器继续用会话选定的贵模型。"
|
||||
@@ -530,7 +578,8 @@ def decide_verdict(report: dict[str, Any]) -> dict[str, str]:
|
||||
f"曲线上 θ={theta} 时高置信错误仍未清零。"
|
||||
),
|
||||
}
|
||||
if not current:
|
||||
paired = report["metrics"].get("paired_sample") or {}
|
||||
if not current and not paired:
|
||||
return {
|
||||
"verdict": "缺数据",
|
||||
"reason": (
|
||||
@@ -545,11 +594,16 @@ def decide_verdict(report: dict[str, Any]) -> dict[str, str]:
|
||||
}
|
||||
gaps = []
|
||||
for layer in ("choice", "collect", "none"):
|
||||
jev = layers[layer]["intent_acc"]
|
||||
cur = (current.get(layer) or {}).get("intent_acc")
|
||||
cell = paired.get(layer) or {}
|
||||
jev = cell.get("jev_intent")
|
||||
cur = cell.get("current_intent")
|
||||
if jev is None:
|
||||
jev = layers[layer]["intent_acc"]
|
||||
if cur is None:
|
||||
cur = (current.get(layer) or {}).get("intent_acc")
|
||||
if cur is None:
|
||||
continue
|
||||
gaps.append((layer, jev, cur, jev - (cur - 0.03)))
|
||||
gaps.append((layer, jev, cur, round(jev * 100, 1) - round((cur - 0.03) * 100, 1)))
|
||||
failed = [g for g in gaps if g[3] < 0]
|
||||
if failed:
|
||||
return {
|
||||
@@ -564,38 +618,130 @@ def decide_verdict(report: dict[str, Any]) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _merge_report_preds(samples: list[dict[str, Any]]) -> None:
|
||||
if not REPORT_JSON.is_file():
|
||||
return
|
||||
try:
|
||||
existing = json.loads(REPORT_JSON.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
by_id = {row.get("id"): row for row in existing.get("rows") or [] if row.get("id")}
|
||||
for sample in samples:
|
||||
prior = by_id.get(sample.get("id")) or {}
|
||||
for key in ("jev_1", "jev_2", "current_1", "current_2"):
|
||||
if key not in sample and prior.get(key):
|
||||
sample[key] = prior[key]
|
||||
|
||||
|
||||
def paired_layer_metrics(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for layer in ("choice", "collect", "none"):
|
||||
subset = [
|
||||
row for row in rows
|
||||
if row.get("source") == "C"
|
||||
and row.get("layer") == layer
|
||||
and row.get("current_1")
|
||||
and not (row.get("current_1") or {}).get("unavailable")
|
||||
]
|
||||
if not subset:
|
||||
out[layer] = {"n": 0, "jev_intent": None, "current_intent": None}
|
||||
continue
|
||||
jev = layer_metrics(subset, pred_key="jev_1")
|
||||
cur = layer_metrics(subset, pred_key="current_1")
|
||||
out[layer] = {
|
||||
"n": len(subset),
|
||||
"jev_intent": jev["intent_acc"],
|
||||
"current_intent": cur["intent_acc"],
|
||||
"jev_answer_class": jev["answer_class_acc"],
|
||||
"current_answer_class": cur["answer_class_acc"],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workers", type=int, default=8)
|
||||
parser.add_argument("--limit", type=int, default=0)
|
||||
parser.add_argument("--skip-second", action="store_true")
|
||||
parser.add_argument("--second-fraction", type=float, default=1.0)
|
||||
parser.add_argument("--current-only", action="store_true")
|
||||
parser.add_argument("--sample-fraction", type=float, default=1.0)
|
||||
parser.add_argument("--sample-seed", type=int, default=20260919)
|
||||
args = parser.parse_args(argv)
|
||||
if not os.environ.get("TYPESAFE_API_KEY"):
|
||||
if not args.current_only and not os.environ.get("TYPESAFE_API_KEY"):
|
||||
print("TYPESAFE_API_KEY missing", file=sys.stderr)
|
||||
return 2
|
||||
if args.current_only and not os.environ.get("DEEPSEEK_API_KEY"):
|
||||
print("DEEPSEEK_API_KEY missing", file=sys.stderr)
|
||||
return 2
|
||||
synthetic = load_jsonl(SAMPLES_DIR / "synthetic.jsonl")
|
||||
simulated = load_jsonl(SAMPLES_DIR / "simulated.jsonl")
|
||||
source_b = load_jsonl(CACHE_DIR / "source_b.jsonl")
|
||||
samples = [row for row in synthetic + simulated if row.get("source") in {"A", "C"}]
|
||||
if args.limit:
|
||||
samples = samples[: args.limit]
|
||||
_merge_report_preds(samples)
|
||||
cache = load_cache()
|
||||
current_sample: list[dict[str, Any]] = []
|
||||
try:
|
||||
run_batch(samples, workers=args.workers, run_id="jev_1", cache=cache)
|
||||
save_cache(cache)
|
||||
second = samples
|
||||
if args.skip_second:
|
||||
second = []
|
||||
elif args.second_fraction < 1:
|
||||
n = max(1, int(len(samples) * args.second_fraction))
|
||||
second = [row for row in samples if row.get("source") == "C"][:n]
|
||||
if second:
|
||||
run_batch(second, workers=args.workers, run_id="jev_2", cache=cache)
|
||||
if not args.current_only:
|
||||
run_batch(samples, workers=args.workers, run_id="jev_1", cache=cache)
|
||||
save_cache(cache)
|
||||
if source_b:
|
||||
run_batch(source_b, workers=args.workers, run_id="jev_1", cache=cache)
|
||||
second = samples
|
||||
if args.skip_second:
|
||||
second = []
|
||||
elif args.second_fraction < 1:
|
||||
n = max(1, int(len(samples) * args.second_fraction))
|
||||
second = [row for row in samples if row.get("source") == "C"][:n]
|
||||
if second:
|
||||
run_batch(second, workers=args.workers, run_id="jev_2", cache=cache)
|
||||
save_cache(cache)
|
||||
if source_b:
|
||||
run_batch(source_b, workers=args.workers, run_id="jev_1", cache=cache)
|
||||
save_cache(cache)
|
||||
source_c_rows = [row for row in samples if row.get("source") == "C"]
|
||||
if args.current_only or os.environ.get("DEEPSEEK_API_KEY"):
|
||||
if args.sample_fraction < 1:
|
||||
current_sample = stratified_sample(
|
||||
source_c_rows, fraction=args.sample_fraction, seed=args.sample_seed,
|
||||
)
|
||||
current_sample.extend(row for row in samples if row.get("source") == "A")
|
||||
else:
|
||||
current_sample = list(samples)
|
||||
run_batch(
|
||||
current_sample, workers=args.workers, run_id="current_1",
|
||||
cache=cache, call_fn=call_current_retry,
|
||||
)
|
||||
save_cache(cache)
|
||||
second_current = current_sample
|
||||
if args.skip_second:
|
||||
second_current = []
|
||||
elif args.second_fraction < 1:
|
||||
second_current = stratified_sample(
|
||||
[row for row in current_sample if row.get("source") == "C"],
|
||||
fraction=args.second_fraction,
|
||||
seed=args.sample_seed + 1,
|
||||
)
|
||||
if second_current:
|
||||
run_batch(
|
||||
second_current, workers=args.workers, run_id="current_2",
|
||||
cache=cache, call_fn=call_current_retry,
|
||||
)
|
||||
save_cache(cache)
|
||||
by_id = {row["id"]: row for row in current_sample}
|
||||
for row in second_current:
|
||||
dest = by_id.setdefault(row["id"], row)
|
||||
if row.get("current_1"):
|
||||
dest["current_1"] = row["current_1"]
|
||||
if row.get("current_2"):
|
||||
dest["current_2"] = row["current_2"]
|
||||
for sample in samples:
|
||||
extra = by_id.get(sample["id"])
|
||||
if extra:
|
||||
if extra.get("current_1"):
|
||||
sample["current_1"] = extra["current_1"]
|
||||
if extra.get("current_2"):
|
||||
sample["current_2"] = extra["current_2"]
|
||||
finally:
|
||||
save_cache(cache)
|
||||
|
||||
@@ -634,16 +780,41 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
"reviewer": "agent-rule-v1",
|
||||
"sha256": sha,
|
||||
"source_b_n": len(source_b),
|
||||
"current_model": None,
|
||||
"current_model": current_model_id() if any(row.get("current_1") for row in samples) else None,
|
||||
"current_model_note": (
|
||||
"本机无会话模型目录凭据(模型 key 在数据库加密配置里)。"
|
||||
"现行 `classifyRectificationTurnIntent` 对照未跑。"
|
||||
(
|
||||
f"DeepSeek Flash 顶现行 `classifyRectificationTurnIntent` 提示词;"
|
||||
f"来源 C 分层随机 {args.sample_fraction:.0%}(seed {args.sample_seed}),"
|
||||
f"来源 A 全量。不是线上会话模型。"
|
||||
if any(row.get("current_1") for row in samples)
|
||||
else (
|
||||
"本机无会话模型目录凭据(模型 key 在数据库加密配置里)。"
|
||||
"现行 `classifyRectificationTurnIntent` 对照未跑。"
|
||||
)
|
||||
)
|
||||
),
|
||||
},
|
||||
"metrics": {
|
||||
"jev_by_layer": jev_metrics,
|
||||
"jev_self_consistency": jev_cons,
|
||||
"current_by_layer": None,
|
||||
"current_by_layer": (
|
||||
{layer: layer_metrics(
|
||||
[row for row in samples if row.get("source") == "C" and row.get("layer") == layer and row.get("current_1")],
|
||||
pred_key="current_1",
|
||||
)
|
||||
for layer in ("choice", "collect", "none")}
|
||||
if any(row.get("current_1") for row in samples) else None
|
||||
),
|
||||
"current_self_consistency": (
|
||||
{layer: self_consistency(
|
||||
[row for row in samples if row.get("source") == "C" and row.get("layer") == layer and row.get("current_2")],
|
||||
"current_1",
|
||||
"current_2",
|
||||
)
|
||||
for layer in ("choice", "collect", "none")}
|
||||
if any(row.get("current_2") for row in samples) else None
|
||||
),
|
||||
"paired_sample": paired_layer_metrics(samples) if any(row.get("current_1") for row in samples) else {},
|
||||
"source_a": source_a_metrics,
|
||||
"source_b": source_b_metrics,
|
||||
"representativeness": {"note": represent_note},
|
||||
|
||||
@@ -87,6 +87,15 @@ class JevIntentCorpusTests(unittest.TestCase):
|
||||
self.assertEqual(reviewed["intent"], "answer_current_focus")
|
||||
self.assertEqual(reviewed["answer_class"], "no")
|
||||
|
||||
def test_current_instructions_match_production_classifier(self) -> None:
|
||||
from scripts.research.jev_intent_current import CHOICE_INSTRUCTIONS, COLLECT_INSTRUCTIONS
|
||||
|
||||
src = (
|
||||
ROOT / "frontend" / "src" / "lib" / "rectification-agentic" / "v9" / "turn-intent-classifier.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn(CHOICE_INSTRUCTIONS, src)
|
||||
self.assertIn(COLLECT_INSTRUCTIONS, src)
|
||||
|
||||
def test_simulated_has_no_holdout_names(self) -> None:
|
||||
path = ROOT / "scripts" / "research" / "jev_intent_samples" / "simulated.jsonl"
|
||||
if not path.is_file():
|
||||
|
||||
Reference in New Issue
Block a user