Files
Jyotisha/scripts/research/jev_intent_current.py
T
jesse-ux d6fc4fb8b3
Independent Staging Quality Gate / validate (push) Successful in 10m16s
Independent Staging Quality Gate / publish (push) Successful in 3m57s
research(rectification): DeepSeek Flash 抽 33% 对照现行分类器提示词
来源 B 仍为 0。Flash 套生产提示词,采集题相对门槛未过。结论仍不可接。
2026-09-19 09:45:29 +08:00

184 lines
8.2 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.
"""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