fix(rectification): dedupe same-domain probe years and drop unanchored style cards (BUG-559)

Pass asked probe keys into the engine without changing result fingerprints, block nearby years already asked, and require a dated same-domain ledger event before rendering varga_style cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 22:08:06 +08:00
parent 150d7ef189
commit 3a9ae736e1
8 changed files with 372 additions and 9 deletions
@@ -457,6 +457,7 @@ function engineRequestBody(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
}): Record<string, unknown> {
const snapshot = input.baselineBirthSnapshot;
const birthDate = String(snapshot.birth_date ?? "");
@@ -483,6 +484,7 @@ function engineRequestBody(input: {
timezone_id: snapshot.timezone_id,
timezone_source: snapshot.timezone_source,
local_time_status: snapshot.local_time_status,
...(input.askedProbeKeys?.length ? { asked_probe_keys: [...input.askedProbeKeys] } : {}),
};
}
@@ -595,6 +597,7 @@ export async function runV9CandidateScore(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
}): Promise<V9EngineScoreResult> {
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
@@ -632,6 +635,7 @@ export async function runV9Diagnostics(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
}): Promise<V9EngineDiagnostics> {
const data = await postEngine("/api/rectification/v5/diagnostics", engineRequestBody(input));
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
@@ -34,7 +34,8 @@ export type ProbeRejectReason =
| "insufficient_candidates"
| "insufficient_outcomes"
| "yearless_ungrounded_contrast"
| "no_split_among_active";
| "no_split_among_active"
| "unanchored_varga_style";
export type StyleOptionsResult =
| { ok: true; options: ProbeStyleOption[] }
+11 -2
View File
@@ -50,7 +50,7 @@ import {
evidenceSubjectForDomain,
applyOccupationCollectLedgerNorm,
} from "@/lib/rectification-agentic/v9/evidence-model";
import { USER_COLLECT_QUESTION } from "@/lib/rectification-agentic/user-copy";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "@/lib/rectification-agentic/user-copy";
import {
isHoldoutVerificationQuote,
} from "@/lib/rectification-agentic/v9/choice-card";
@@ -839,6 +839,10 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
compute.baselineProfileFingerprint,
);
const events = toEngineEvents(scorableEvidence(dossier.evidence));
const askedProbeKeys = askedDiscriminatorKeys(
dossier.latestResult?.decisionReceipt,
parsed.evidence,
);
const latest = dossier.latestResult;
const liveIdentity = await readV9EngineScoringIdentity();
if (
@@ -940,6 +944,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events,
askedProbeKeys,
});
const engineCompareMs = Date.now() - scoreStarted;
const vedastroStarted = Date.now();
@@ -1263,7 +1268,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
resultFingerprint: JSON.stringify({ reason: spoken.reason }),
});
const fallbackDomain = nextFollowup.domain ?? "";
const fallbackPrompt = USER_COLLECT_QUESTION[fallbackDomain] ?? USER_COLLECT_QUESTION.other;
const fallbackPrompt = USER_COLLECT_QUESTION[fallbackDomain] ?? GENERIC_COLLECT_QUESTION;
if (
spokenPromptFailures < 2
|| nextFollowup.intent !== "collect_method_evidence"
@@ -1910,6 +1915,10 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events: toEngineEvents(scorableEvidence(dossier.evidence)),
askedProbeKeys: askedDiscriminatorKeys(
dossier.latestResult?.decisionReceipt,
parsed.evidence,
),
});
const projection = {
engine_result_id: diagnostics.engineResultId,
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildMethodFollowupPlan,
datedLedgerAnchor,
existenceProbeAsked,
remainingReverseVerifyProbes,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import type { CandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
function existenceProbe(
domain: DiscriminatingEventProbe["domain"],
year: number,
extra: { month?: number; source?: DiscriminatingEventProbe["source"]; key?: string } = {},
): DiscriminatingEventProbe {
const month = extra.month;
const source = extra.source ?? "dasha_boundary";
const key = extra.key
?? (month
? `${domain}.${year}.${String(month).padStart(2, "0")}.${source}`
: `${domain}.${year}.${source}`);
return {
year,
year_label: month ? `${year}${month} 月前后` : `${year} 年前后`,
month,
domain,
event_family: "入职、升职或职责明显加重",
source,
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: `时间范围锁定 ${year} 年。`,
role: "distinguish",
information_gain: 1.1,
semantic_key: key,
candidate_split_hash: key,
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
choice_kind: "existence",
};
}
function dated(
id: string,
domain: string,
eventKind: string,
occurredFrom: string,
): MethodFollowupEvidence {
return {
id,
status: "confirmed",
domain,
datePrecision: "month",
occurredFrom,
occurredTo: null,
eventKind,
};
}
const D10_STYLE: CandidateContrastPacket["probes"][number] = {
probeId: "contrast:varga.d10.巨蟹座/狮子座",
candidateSetVersion: "05:00-05:20",
question: "平时做事,你更接近下面哪一种?",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:20"] },
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:20"], conflictsCandidateIds: ["05:00"] },
],
candidateSplitHash: "varga.d10.巨蟹座/狮子座",
informationGain: 1.4,
sourceFeatures: [{ technique: "D10", calculationResultId: null }],
domain: "career",
year: null,
semanticKey: "varga.d10.巨蟹座/狮子座",
choiceKind: "varga_style",
styleOptions: [
{ label: "做事以照顾人为主,在意团队里的感受", answerClass: "yes", sign: "巨蟹座" },
{ label: "习惯带头,也不排斥站到台前", answerClass: "weak_yes", sign: "狮子座" },
],
};
test("existenceProbeAsked treats the same career year and nearby years as already asked", () => {
const asked = ["career.2018.05.dasha_boundary"];
assert.equal(existenceProbeAsked(asked, "career", 2018), true);
assert.equal(existenceProbeAsked(asked, "career", 2017), true);
assert.equal(existenceProbeAsked(asked, "career", 2019), true);
assert.equal(existenceProbeAsked(asked, "career", 2020), false);
assert.equal(existenceProbeAsked(asked, "finance", 2018), false);
});
test("remaining reverse-verify probes drop same-domain nearby years after a month probe", () => {
const remaining = remainingReverseVerifyProbes(
[
existenceProbe("career", 2018, { month: 5 }),
existenceProbe("career", 2018, { source: "dasha_activation" }),
existenceProbe("career", 2017, { month: 5 }),
existenceProbe("career", 2019, { month: 5 }),
existenceProbe("career", 2020, { month: 5 }),
],
[],
new Set(),
new Set(["career.2018.05.dasha_boundary"]),
);
const years = remaining.filter((item) => item.domain === "career").map((item) => item.year);
assert.equal(years.includes(2017), false);
assert.equal(years.includes(2018), false);
assert.equal(years.includes(2019), false);
assert.equal(years.includes(2020), true);
});
test("datedLedgerAnchor names the confirmed same-domain month", () => {
const anchor = datedLedgerAnchor([
dated("e-career-month", "career", "career_entry", "2018-07-01"),
], "career");
assert.ok(anchor);
assert.equal(anchor?.label, "2018 年 7 月");
assert.equal(datedLedgerAnchor([
dated("e-edu", "education", "education_start", "2016-09-01"),
], "career"), null);
});
test("unanchored D10 varga_style cards are dropped; anchored cards mention the ledger month", () => {
const baseEvidence = [
dated("e-edu", "education", "education_start", "2016-09-01"),
dated("e-edu-2", "education", "education_completion", "2020-06-01"),
dated("e-rel", "relationship", "relationship_start", "2024-05-01"),
dated("e-fin", "finance", "finance_loss", "2021-01-01"),
];
const packet = {
candidateSetVersion: "05:00-05:20",
vargaDifferences: [] as const,
probes: [D10_STYLE],
};
const unanchored = buildMethodFollowupPlan({
evidence: baseEvidence,
contrastPacket: packet,
candidatesSeparated: false,
});
assert.equal(
unanchored.dropped_probes.some((item) => item.reason === "unanchored_varga_style"),
true,
);
assert.notEqual(unanchored.next_followup?.semantic_key, D10_STYLE.semanticKey);
const anchored = buildMethodFollowupPlan({
evidence: [...baseEvidence, dated("e-career", "career", "career_entry", "2018-07-01")],
contrastPacket: packet,
candidatesSeparated: false,
});
assert.equal(anchored.next_followup?.semantic_key, D10_STYLE.semanticKey);
assert.equal(anchored.next_followup?.choice_kind, "varga_style");
assert.match(anchored.next_followup?.user_prompt_hint ?? "", /2018 年 7 月/);
assert.doesNotMatch(anchored.next_followup?.choice_frame?.prompt ?? "", /2018/);
});
+5 -1
View File
@@ -183,7 +183,11 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
spec = calculation_spec(request)
spec_hash = sha256(spec)
diagnostic_values = run_diagnostics(scoring_request, rows, built)
fingerprint = sha256(request)
fingerprint = sha256({
key: value
for key, value in request.items()
if key != "asked_probe_keys"
})
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
candidate_decisions = build_candidate_decisions(
rows,
+19 -1
View File
@@ -52,7 +52,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
})
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode",
"ayanamsa", "node_mode", "asked_probe_keys",
}) | _REQUEST_PROVENANCE_FIELDS
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
@@ -88,6 +88,7 @@ class RectificationRequest(TypedDict):
timezone_id: NotRequired[str | None]
timezone_source: NotRequired[str | None]
local_time_status: NotRequired[str | None]
asked_probe_keys: NotRequired[list[str]]
JsonObject = dict[str, Any]
@@ -248,4 +249,21 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
_copy_nullable_text(body, cleaned_request, "timezone_id", "timezone_id", 120)
_copy_nullable_text(body, cleaned_request, "timezone_source", "timezone_source", 80)
_copy_nullable_text(body, cleaned_request, "local_time_status", "local_time_status", 120, _LOCAL_TIME_STATUSES)
if "asked_probe_keys" in body:
asked = body.get("asked_probe_keys")
if not isinstance(asked, list) or len(asked) > 200:
raise ValueError("asked_probe_keys must contain between 0 and 200 strings")
cleaned_keys: list[str] = []
seen: set[str] = set()
for index, item in enumerate(asked):
if not isinstance(item, str) or not item.strip() or len(item.strip()) > 120:
raise ValueError(
f"asked_probe_keys[{index}] must be a non-empty string up to 120 characters"
)
key = item.strip()
if key in seen:
continue
seen.add(key)
cleaned_keys.append(key)
cleaned_request["asked_probe_keys"] = cleaned_keys
return cast(RectificationRequest, cleaned_request)
+113 -4
View File
@@ -8,6 +8,7 @@ the case cap is respected. Unanchored quality stays clarification-only.
from __future__ import annotations
import re
from datetime import date, datetime, timedelta
from math import log2
from typing import Any, Sequence
@@ -220,6 +221,20 @@ EXISTENCE_NEARBY_YEARS = {
"career": 1,
"relocation": 1,
}
_SEMANTIC_YEAR = re.compile(r"^(?P<domain>[a-z_]+)\.(?P<year>(?:19|20)\d{2})(?:\.|$)")
def asked_years_for_domain(asked_probe_keys: Sequence[str] | None, domain: str) -> set[int]:
years: set[int] = set()
prefix = f"{domain}."
for raw in asked_probe_keys or []:
key = str(raw or "").strip()
if not key.startswith(prefix):
continue
match = _SEMANTIC_YEAR.match(key)
if match and match.group("domain") == domain:
years.add(int(match.group("year")))
return years
def _clock(value: str) -> int:
@@ -770,23 +785,38 @@ def _agent_brief(
family: str,
quality: bool = False,
exam: bool = False,
nearby_note: str = "",
) -> str:
nearby = nearby_note.strip()
if exam:
return (
f"时间范围锁定 {year_label};领域锁定 {domain}"
"语义目标是那次考试的实际体验。结合最近对话,只选一个容易回答的口语入口,"
"问是否明显失常或压力很大;不要堆叠例子,不得改时间范围。"
+ (f"{nearby}" if nearby else "")
)
if quality:
return (
f"时间范围锁定 {year_label};领域锁定 {domain};语义目标是 {family}"
"结合最近对话,只选一个容易回答的口语入口来核对体验;"
"不要逐字复述语义目标,不要堆叠例子,不得改时间范围。"
+ (f"{nearby}" if nearby else "")
)
lead = (
f"{nearby}时间范围锁定 {year_label};请问用户那段时间身上发生了什么变化;"
if nearby
else f"时间范围锁定 {year_label};领域锁定 {domain};语义目标是 {family}"
"结合最近对话,只选一个容易回答的口语入口,写一句自然的是/否题;"
)
if nearby:
return (
lead
+ f"领域锁定 {domain};语义目标是 {family}"
"选项由服务端给出;不要发明年份,不得改时间范围。"
)
return (
f"时间范围锁定 {year_label};领域锁定 {domain};语义目标是 {family}"
"结合最近对话,只选一个容易回答的口语入口,写一句自然的是/否题;"
"不要逐字复述语义目标,不要把所有例子堆进一句,不得改时间范围。"
lead
+ "不要逐字复述语义目标,不要把所有例子堆进一句,不得改时间范围。"
)
@@ -889,6 +919,79 @@ def _display_date_label(event: dict[str, Any]) -> str:
return f"{year}"
def _event_month_index(year: int, month: int | None) -> int | None:
if month is None or not 1 <= month <= 12:
return None
return year * 12 + month
def nearby_ledger_note(
events: Sequence[dict[str, Any]],
*,
domain: str,
year: int,
month: int | None,
) -> str:
if year <= 0:
return ""
probe_index = _event_month_index(year, month)
best: dict[str, Any] | None = None
best_delta = 99
for event in events:
if not isinstance(event, dict):
continue
other_domain = str(event.get("domain") or "")
if other_domain == domain or other_domain not in DOMAIN_CATALOG:
continue
other_year = _event_year(event)
if other_year is None:
continue
other_month = _event_month(event)
other_index = _event_month_index(other_year, other_month)
if probe_index is not None and other_index is not None:
delta = abs(probe_index - other_index)
if delta > 2:
continue
elif other_year != year:
continue
else:
delta = 2 if probe_index is not None or other_index is not None else 0
if delta < best_delta:
best_delta = delta
best = event
if best is None:
return ""
family = str(DOMAIN_CATALOG[str(best.get("domain") or "")]["event_family"])
return f"账本里 { _display_date_label(best) }{family};题干先提那件事再问。"
def _annotate_nearby_ledger(
probes: Sequence[dict[str, Any]],
events: Sequence[dict[str, Any]],
) -> None:
for probe in probes:
if not isinstance(probe, dict):
continue
if str(probe.get("choice_kind") or "existence") != "existence":
continue
if str(probe.get("source") or "") not in {"dasha_boundary", "dasha_activation"}:
continue
year = probe.get("year")
if not isinstance(year, int) or year <= 0:
continue
note = nearby_ledger_note(
events,
domain=str(probe.get("domain") or ""),
year=year,
month=int(probe["month"]) if isinstance(probe.get("month"), int) else None,
)
if not note:
continue
meaning = str(probe.get("user_meaning") or "")
if note not in meaning:
probe["user_meaning"] = f"{note}{meaning}"
def _event_kind_name(event: dict[str, Any]) -> str:
return str(event.get("event_kind") or event.get("kind") or "")
@@ -1402,6 +1505,11 @@ def _discriminating_event_probe_lists(
except ValueError:
return empty
events = [item for item in (request.get("events") or []) if isinstance(item, dict)]
asked_probe_keys = [
str(item).strip()
for item in (request.get("asked_probe_keys") or [])
if isinstance(item, str) and str(item).strip()
]
if not discriminator_gate_open(events):
return empty
holdout_keys = holdout_domain_years(events)
@@ -1438,7 +1546,7 @@ def _discriminating_event_probe_lists(
for domain in domains:
if domain not in DOMAIN_CATALOG:
continue
known_years = _event_years(events, domain)
known_years = _event_years(events, domain) | asked_years_for_domain(asked_probe_keys, domain)
blocked_years = _existence_blocked_years(domain, known_years)
domain_lo = _domain_year_floor(birth_year, domain, lo)
eligible = [
@@ -1506,6 +1614,7 @@ def _discriminating_event_probe_lists(
holdout_ids=set(holdout_event_ids(events)),
holdout_keys=set(holdout_keys),
))
_annotate_nearby_ledger(probes, events)
probes.sort(key=_probe_sort_key)
public, dropped = _partition_ranked_probes(probes)
assert_distinguish_contract(public)
+59
View File
@@ -1060,6 +1060,65 @@ class QualityDistinguishDedupeTests(unittest.TestCase):
])
self.assertEqual(len(over_cap), MAX_QUALITY_DISTINGUISH_PROBES)
def test_asked_career_month_probe_blocks_same_and_nearby_years(self) -> None:
from scripts.rectification.event_probes import (
asked_years_for_domain,
_existence_blocked_years,
nearby_ledger_note,
)
years = asked_years_for_domain(["career.2022.05.dasha_boundary"], "career")
self.assertEqual(years, {2022})
self.assertEqual(_existence_blocked_years("career", years), {2021, 2022, 2023})
self.assertEqual(asked_years_for_domain(["career.2022.05.dasha_boundary"], "finance"), set())
note = nearby_ledger_note(
[{
"domain": "relocation",
"date_start": "2022-07-01",
"precision": "month",
}],
domain="career",
year=2022,
month=5,
)
self.assertIn("2022 年 7 月", note)
self.assertNotIn("summary", note)
def test_asked_career_month_probe_is_not_reemitted_for_same_or_nearby_year(self) -> None:
from unittest.mock import patch
from scripts.rectification import event_probes as probes_mod
built = {
"static_contexts": [
_context("05:13", d4_asc=0, sun_house=4, sun_varga_sign=3, moon=100.0),
_context("05:40", d4_asc=1, sun_house=10, sun_varga_sign=9, moon=101.0),
]
}
def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[date]:
return [date(2018, 5, 15)] if moon <= 100.0 else [date(2019, 5, 20)]
def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[date]:
moon = float(planets.get("Moon") or 0)
return [date(2018, 5, 15)] if moon <= 100.0 else [date(2019, 5, 20)]
def fake_score(context: dict, *, birth_date: str, domain: str, year: int, month: int | None = None) -> dict:
del birth_date, domain, context
if year in {2018, 2019} and month == 5:
return {"rule_ids": ["vim_ad_domain_lord"]}
return {"rule_ids": ["no_domain_activation"]}
request = _request(asked_probe_keys=["career.2018.05.dasha_boundary"])
with (
patch.object(probes_mod, "_vim_start_dates", side_effect=fake_vim),
patch.object(probes_mod, "_narayana_start_dates", side_effect=fake_narayana),
patch.object(probes_mod, "_score_year", side_effect=fake_score),
):
probes = _probes(request, built, ["05:13", "05:40"], "05:13")
career = [item for item in probes if item["domain"] == "career"]
self.assertFalse(any(item["year"] in {2017, 2018, 2019} for item in career), career)
if __name__ == "__main__":
unittest.main()