Files
Jyotisha/scripts/research/jev_intent_questions.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

327 lines
14 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.
"""Jev question definitions for the rectification turn-intent classifier.
Offline research only. Does not change production `turn-intent-classifier.ts`.
Question JSON matches TypeSafe `primitives/advanced` (Choice / Noul with
structured instructions). Combination legality is enforced in code.
"""
from __future__ import annotations
from typing import Any, Mapping, Sequence
INTENT_VALUES = (
"answer_current_focus",
"provide_new_evidence",
"stop_rectification",
"ask_about_result",
"unclear",
)
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
# Literal criteria. Production hedges ("通常", "不要猜", "不要按关键词") are dropped.
INTENT_INSTRUCTIONS = {
"task": "Classify what the user_message is doing in this rectification turn.",
"inputs": ["current_question", "options", "user_message", "case_status"],
"rule": "Pick exactly one option. Use only the literal conditions in criteria.",
}
INTENT_CRITERIA: dict[str, str] = {
"answer_current_focus": (
"user_message is answering current_question. "
"If options is non-empty, the message matches the meaning of one option's answer_class. "
"If options is empty (collect question), the message says the topic happened, did not happen, "
"or is not remembered, or answers the collect question with a dated experience."
),
"provide_new_evidence": (
"user_message only adds a new experience that includes an approximate year or month, "
"and does not answer current_question. "
"If current_question is empty, adding a dated experience also maps here."
),
"stop_rectification": (
"user_message explicitly asks to stop the entire birth-time rectification. "
"Saying the current topic did not happen is not this option."
),
"ask_about_result": (
"user_message asks for the rectification result, the current time range, "
"candidate minutes, or whether a result is ready. It is not answering current_question."
),
"unclear": (
"None of the other four conditions hold, or the message mixes them so no single option applies."
),
}
ANSWER_CLASS_INSTRUCTIONS = {
"task": "If user_message is answering current_question, pick the matching answer_class.",
"rule": "Use only the option list in criteria. If the message is not answering current_question, pick none of the content classes; the caller will null this field.",
}
HAS_NEW_DATED_EVENT_INSTRUCTIONS = (
"这句话里是否出现了一件新的、带大概年或月的经历,且它不是对当前问题的直接回答。"
)
HAS_NEW_DATED_EVENT_CRITERIA = {
"true": (
"除了回答当前问题之外,还另说了一件带大概年或月的经历。"
),
"false": (
"没有另说新的带年月经历。"
"单纯否定、单纯记不清、或只用带年月经历来直接回答当前采集题,都不是。"
),
}
# One row per production prompt sentence that was rewritten or dropped.
CRITERION_MAP: tuple[dict[str, str], ...] = (
{
"production": "结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。",
"jev": "intent Choice with five literal criteria, including unclear as the residual option.",
"lost": "",
},
{
"production": "若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。",
"jev": "answer_class Choice is built only from the live options; code nulls it when intent != answer_current_focus.",
"lost": "",
},
{
"production": "has_new_dated_event 仅在用户同一句里除了回答当前问题之外,还提供了新的、带大概时间的经历时为 true。",
"jev": "has_new_dated_event Noul true/false criteria, same split.",
"lost": "",
},
{
"production": "单纯的否定或单纯的选项回答必须为 false。",
"jev": "Noul false: 单纯否定、单纯记不清不算新经历。",
"lost": "",
},
{
"production": "若同一句话既回答了当前问题又补充了新的带时间经历,intent 仍为 answer_current_focus,has_new_dated_event 为 true。",
"jev": "intent and Noul are separate questions; code keeps answer_current_focus when both fire.",
"lost": "Jev does not itself enforce this pair; code does.",
},
{
"production": "“当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程。",
"jev": "stop_rectification criterion: only an explicit stop of the whole flow. 当前方面没有 is answer_current_focus.",
"lost": "The hedge 通常 is dropped; the stop option is written as an exclusive literal.",
},
{
"production": "只有用户明确要求停止整个校正时才分类为 stop_rectification。",
"jev": "stop_rectification criterion, same literal.",
"lost": "",
},
{
"production": "不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。",
"jev": "answer_class criteria keyed by answer_class, not by A/B/C/D.",
"lost": "The prohibition itself is not sent; Jev is not given A/B/C/D as the choice keys.",
},
{
"production": "「没有、没发生过、这方面没什么」→ intent 为 answer_current_focus,answer_class 为 no。",
"jev": "Collect answer_class criterion for no.",
"lost": "",
},
{
"production": "「记不清、不记得、忘了、想不起来、以后再说」→ answer_class 为 unsure。",
"jev": "Collect answer_class criterion for unsure.",
"lost": "",
},
{
"production": "用户用带大概年月的经历直接回答当前采集题 → intent 为 answer_current_focus,answer_class 为 yes(程度较弱时为 weak_yes),has_new_dated_event 为 false。",
"jev": "Collect yes/weak_yes criteria + Noul false for a dated answer that is the collect answer.",
"lost": "",
},
{
"production": "不要把「没有」或「记不清」标成 yes。",
"jev": "Separate no and unsure criteria; yes requires a dated or affirmative answer.",
"lost": "The 'do not' wording is dropped.",
},
{
"production": "若既没有否定、也没有说记不清、也没有给出带年月经历,intent 为 unclear,answer_class 必须为 null。",
"jev": "unclear residual on intent Choice; code nulls answer_class.",
"lost": "",
},
{
"production": "若用户只在补充带时间的经历、并没有回答当前采集题,intent 为 provide_new_evidence。",
"jev": "provide_new_evidence criterion.",
"lost": "",
},
{
"production": "若同一句话既明确否定当前采集题又补充了新的带时间经历,intent 仍为 answer_current_focus 且 answer_class 为 no,不要改成 provide_new_evidence。",
"jev": "Code keeps no + Noul true. Jev intent/Noul are independent.",
"lost": "Jev may split this pair; code does not re-vote intent from the Noul.",
},
{
"production": "不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。",
"jev": "Not sent. Jev has no keyword table in the question.",
"lost": "The meta-instruction is dropped (Jev answers the written question, not the intended one).",
},
)
LOST_SEMANTICS: tuple[str, ...] = tuple(
row["lost"] for row in CRITERION_MAP if row["lost"]
)
def _option_rows(options: Sequence[Mapping[str, Any]] | None) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
seen: set[str] = set()
for item in options or ():
answer_class = str(item.get("answer_class") or "")
label = str(item.get("label") or "").strip()
if answer_class not in ANSWER_CLASS_VALUES or answer_class in seen:
continue
seen.add(answer_class)
rows.append({"answer_class": answer_class, "label": label})
return rows
def answer_class_criteria(
*,
layer: str,
options: Sequence[Mapping[str, Any]] | None,
) -> dict[str, str]:
"""Choice criteria keyed by answer_class. Collect uses the four fixed classes."""
if layer == "collect" and not _option_rows(options):
return {
"yes": "The message answers the collect question with a dated experience that did happen, stated firmly.",
"weak_yes": "The message answers the collect question with a dated or tentative experience, weakly.",
"no": "The message says this topic did not happen / there was no change. Close this aspect.",
"unsure": "The message says the user does not remember, cannot recall, or wants to skip for now.",
}
criteria: dict[str, str] = {}
for row in _option_rows(options):
criteria[row["answer_class"]] = (
f"user_message matches this option's meaning. label={row['label']}"
)
if not criteria:
criteria = {
"yes": "Affirmative answer to current_question.",
"weak_yes": "Weak affirmative answer to current_question.",
"no": "Negative answer to current_question.",
"unsure": "User does not remember the answer to current_question.",
}
return criteria
def build_state(sample: Mapping[str, Any]) -> dict[str, Any]:
focus = sample.get("focus") if isinstance(sample.get("focus"), dict) else {}
options = list(focus.get("options") or [])
return {
"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"),
}
def build_questions(sample: Mapping[str, Any]) -> dict[str, Any]:
"""Three TypeSafe questions, same request, official fan-out."""
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 {
"intent": {
"type": "choice",
"instructions": INTENT_INSTRUCTIONS,
"criteria": dict(INTENT_CRITERIA),
},
"answer_class": {
"type": "choice",
"instructions": ANSWER_CLASS_INSTRUCTIONS,
"criteria": answer_class_criteria(layer=layer, options=options),
},
"has_new_dated_event": {
"type": "noul",
"instructions": HAS_NEW_DATED_EVENT_INSTRUCTIONS,
"criteria": dict(HAS_NEW_DATED_EVENT_CRITERIA),
},
}
def sdk_questions(sample: Mapping[str, Any]) -> dict[str, Any]:
"""SDK objects when typesafe_sdk is installed; JSON dicts otherwise."""
payload = build_questions(sample)
try:
from typesafe_sdk import Choice, Noul
except ImportError:
return payload
return {
"intent": Choice(
instructions=payload["intent"]["instructions"],
criteria=payload["intent"]["criteria"],
),
"answer_class": Choice(
instructions=payload["answer_class"]["instructions"],
criteria=payload["answer_class"]["criteria"],
),
"has_new_dated_event": Noul(
instructions=payload["has_new_dated_event"]["instructions"],
criteria=payload["has_new_dated_event"]["criteria"],
),
}
def enforce_combo(
intent: str | None,
answer_class: str | None,
has_new_dated_event: bool | None = None,
) -> dict[str, Any]:
"""Production invariant: answer_class is non-null iff intent is answer_current_focus."""
intent_value = intent if intent in INTENT_VALUES else "unclear"
dated = bool(has_new_dated_event)
if intent_value != "answer_current_focus":
return {
"intent": intent_value,
"answer_class": None,
"has_new_dated_event": dated,
}
class_value = answer_class if answer_class in ANSWER_CLASS_VALUES else None
if class_value is None:
return {
"intent": "unclear",
"answer_class": None,
"has_new_dated_event": dated,
}
return {
"intent": intent_value,
"answer_class": class_value,
"has_new_dated_event": dated,
}
def parse_jev_answers(answers: Mapping[str, Any]) -> 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 {}
noul_value = noul_row.get("noul")
dated = None
if isinstance(noul_value, (int, float)):
dated = float(noul_value) >= NOUL_TRUE_THRESHOLD
parsed = enforce_combo(
str(intent_row.get("choice") or "") or None,
str(class_row.get("choice") or "") or None,
dated,
)
parsed["raw"] = {
"intent": {
"choice": intent_row.get("choice"),
"probabilities": intent_row.get("probabilities"),
"confidence": intent_row.get("confidence"),
},
"answer_class": {
"choice": class_row.get("choice"),
"probabilities": class_row.get("probabilities"),
"confidence": class_row.get("confidence"),
},
"has_new_dated_event": {
"noul": noul_value,
},
}
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)):
parsed["confidence"] = min(float(intent_conf), float(class_conf))
elif isinstance(intent_conf, (int, float)):
parsed["confidence"] = float(intent_conf)
else:
parsed["confidence"] = None
return parsed