fix(rectification): unify discriminator question contract and rank by information gain
Independent Staging Quality Gate / validate (push) Failing after 11m51s
Independent Staging Quality Gate / publish (push) Has been skipped

Python and TypeScript now share a four-option probe contract, persist Focus before asking, and pick the highest-value renderable probe instead of preferring low-gain career events over D24.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-28 00:25:35 +08:00
parent 85db4591d3
commit 7f82428b44
20 changed files with 1175 additions and 141 deletions
+6 -1
View File
@@ -36,6 +36,7 @@ from scripts.rectification.candidate_contrast import (
opportunity_from_probe,
)
from scripts.rectification.case_holdout import holdout_domain_years
from scripts.rectification.probe_question_contract import complete_style_options
from scripts.rectification.refinement_packet import match_level
MAX_PROBES = 3
@@ -564,6 +565,9 @@ def _public_probe(
payload.update(extra)
if payload["role"] == "distinguish":
payload["candidate_ids"] = candidate_ids_from_outcomes(payload.get("expected_outcomes") or [])
style_options = complete_style_options(payload.get("choice_kind"), payload.get("style_options"))
if style_options:
payload["style_options"] = style_options
return payload
@@ -717,6 +721,7 @@ def _evaluate_contexts(
version = set_version or candidate_set_version([yes_times, no_times])
outcomes = [
{"answer_class": "yes", "supports": yes_times, "conflicts": no_times},
{"answer_class": "weak_yes", "supports": yes_times, "conflicts": no_times},
{"answer_class": "no", "supports": no_times, "conflicts": yes_times},
{"answer_class": "unsure", "supports": [], "conflicts": []},
]
@@ -752,7 +757,7 @@ def _evaluate_contexts(
"calculationResultId": None,
}],
)
if distinguish_contract_errors(probe):
if not probe or distinguish_contract_errors(probe):
return None
return probe
@@ -0,0 +1,115 @@
"""Shared probe → choice-card contract for Python event probes.
Must stay aligned with frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts.
"""
from __future__ import annotations
import re
from typing import Any, Sequence
QUESTION_CONTRACT_VERSION = "probe-question-v1"
ANSWER_CLASSES = ("yes", "weak_yes", "no", "unsure")
EXISTENCE_STYLE_OPTIONS: tuple[dict[str, str], ...] = (
{"label": "明确发生且时间吻合", "answer_class": "yes"},
{"label": "发生过但程度较弱", "answer_class": "weak_yes"},
{"label": "明确没有发生", "answer_class": "no"},
{"label": "这段记不清楚", "answer_class": "unsure"},
)
QUALITY_STYLE_OPTIONS: tuple[dict[str, str], ...] = (
{"label": "发挥明显失常或压力很大", "answer_class": "yes"},
{"label": "有压力但不算明显失常", "answer_class": "weak_yes"},
{"label": "发挥正常、没有明显失常", "answer_class": "no"},
{"label": "这段记不清楚", "answer_class": "unsure"},
)
VARGA_NONE_STYLE_OPTION = {"label": "都不是这些特质", "answer_class": "no"}
UNSURE_STYLE_OPTION = {"label": "这段记不清楚", "answer_class": "unsure"}
_FORBIDDEN = ("外貌", "体质", "胎记", "疤痕", "伤疤", "身高", "体型")
_CLOCK = re.compile(r"(?:[01]?\d|2[0-3]):[0-5]\d")
def probe_question_kind(value: Any) -> str:
if value in {"varga_style", "event_quality"}:
return str(value)
return "existence"
def clipped_probe_label(value: Any, minimum: int = 4, maximum: int = 80) -> str | None:
if not isinstance(value, str):
return None
text = " ".join(value.split())
if len(text) < minimum or len(text) > maximum:
return None
if any(token in text for token in _FORBIDDEN) or _CLOCK.search(text):
return None
return text
def _incoming_option(row: Any) -> dict[str, str] | None:
if not isinstance(row, dict):
return None
answer = row.get("answer_class") or row.get("answerClass")
label = clipped_probe_label(row.get("label"))
if not label or answer not in ANSWER_CLASSES:
return None
payload = {"label": label, "answer_class": str(answer)}
sign = row.get("sign")
if isinstance(sign, str) and sign.strip():
payload["sign"] = sign.strip()
return payload
def complete_style_options(
choice_kind: Any,
style_options: Sequence[Any] | None = None,
) -> list[dict[str, str]] | None:
kind = probe_question_kind(choice_kind)
incoming = [item for item in (_incoming_option(row) for row in (style_options or [])) if item]
by_class: dict[str, dict[str, str]] = {}
if kind == "varga_style":
for option in incoming:
by_class[option["answer_class"]] = option
by_class.setdefault("unsure", dict(UNSURE_STYLE_OPTION))
scoring = [item for item in ANSWER_CLASSES if item != "unsure" and item in by_class]
if len(scoring) < 2:
return None
by_class.setdefault("no", dict(VARGA_NONE_STYLE_OPTION))
if "yes" not in by_class or "weak_yes" not in by_class:
return None
else:
catalog = QUALITY_STYLE_OPTIONS if kind == "event_quality" else EXISTENCE_STYLE_OPTIONS
for option in catalog:
by_class[option["answer_class"]] = dict(option)
for option in incoming:
by_class[option["answer_class"]] = option
ordered: list[dict[str, str]] = []
seen: set[str] = set()
for answer_class in ANSWER_CLASSES:
option = by_class.get(answer_class)
if not option:
return None
label = option["label"]
if label in seen and option.get("sign"):
label = f"{label}{option['sign']}"
if label in seen:
label = f"{label}·{answer_class}"
seen.add(label)
ordered.append({**option, "label": label})
labels = {item["label"] for item in ordered}
classes = {item["answer_class"] for item in ordered}
if len(ordered) != 4 or labels != {item["label"] for item in ordered} or classes != set(ANSWER_CLASSES):
return None
if len(labels) != 4:
return None
return ordered
def is_renderable_probe(probe: dict[str, Any]) -> bool:
gain = probe.get("information_gain")
if not isinstance(gain, (int, float)) or gain <= 0:
return False
candidate_ids = probe.get("candidate_ids") or []
outcomes = probe.get("expected_outcomes") or []
if len(candidate_ids) < 2 or len(outcomes) < 2:
return False
return complete_style_options(probe.get("choice_kind"), probe.get("style_options")) is not None