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