diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 90e654c0..568bd2a0 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -457,6 +457,7 @@ function engineRequestBody(input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; + askedProbeKeys?: readonly string[]; }): Record { 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>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; + askedProbeKeys?: readonly string[]; }): Promise { 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>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; + askedProbeKeys?: readonly string[]; }): Promise { const data = await postEngine("/api/rectification/v5/diagnostics", engineRequestBody(input)); const candidates = readCandidates(data.candidate_decisions, input.candidateRange); diff --git a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts index d9672ed5..610ae276 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -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[] } diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 4add650e..bd3d0f45 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -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, diff --git a/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts new file mode 100644 index 00000000..c64035bb --- /dev/null +++ b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts @@ -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/); +}); diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index 9e59b44c..c0f506d5 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -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, diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index 05e7cc0f..06fae18d 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -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) diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 2635d1b3..e83a3554 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -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[a-z_]+)\.(?P(?: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) diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 1d387ddd..872252ab 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -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()