diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e59bc03d..a61e6b05 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5531,6 +5531,38 @@ - 复发自:无 - 修复版本:未修复 +## BUG-380 | 覆盖完成后仍用整窗 D9/D24 追问已入账日期 + +- 状态:resolved +- 首次发现:2026-08-25 +- 最近更新:2026-08-25 +- 影响面:`session_outcome`、`candidate_contrast_packet`、`method_followup_plan`、`record_agentic_rectification_evidence_batch`、生时纠正系统提示 +- 用户现象:方法覆盖已齐、引擎探针已空、候选仍是并列区间时,访谈继续问整窗学业/感情盘,并再次追问已入账的入学或分手日期。同一日感情结束用不同原句会写入第二行。 +- 触发条件:挡住出牌的方法层已齐;剩余候选分钟共享 D9;学业质量与职业备注已入账;引擎 `discriminating_event_probes` 为空;用户用另一句话重复已记日期。 +- 根因:(1) 收集计划把整窗 `varga_observation` 当成区分探针,`discriminatorProbe: null` 被 `??` 回退成该追问,会话无法进入 `provisional_range`。(2) 剩余分钟拆分仍问已编码的高考发挥与职业类型,且 D4 不在剩余层顺序里。(3) 证据批处理只按 quote 做幂等。 +- 修复:区分探针只来自引擎探针和当前候选分钟的剩余分盘;已编码的学业质量/职业备注跳过 D24/D10;加入 D4 剩余拆分。没有剩余拆分时 `next_followup` 为空并落实 `offer_provisional_range`。同 case、同 kind、同发生日的确认行按日期去重。不改 Skill `10.0.11`。 +- 验证:`frontend/tests/rectification-eight-method.test.ts`、`frontend/tests/rectification-decide-next-action.test.ts`、`frontend/tests/rectification-inference-machine.test.ts`、`frontend/tests/rectification-v9-agent.test.ts`、`frontend/tests/rectification-v9-migration.test.ts`。 +- 防复发:覆盖完成后不得把整窗 D9/D10/D24 观察当成区分探针。已入账的入学、分手日期和考试质量不得再问。没有剩余分钟拆分时必须给并列区间,不得继续访谈。不得改已哈希 Skill `10.0.11`。 +- 相关记录:BUG-192、BUG-351、BUG-366、BUG-375、BUG-379 +- 复发自:BUG-379(邻近年学业存在性探针已跳过,但仍用整窗观察和剩余 D24 题干继续问入学/高考) +- 修复版本:待发布 + +## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学 + +- 状态:resolved +- 首次发现:2026-08-25 +- 最近更新:2026-08-25 +- 影响面:`discriminating_event_probes`、`method_followup_plan`、`serverOwnedChoiceCopy`、生时纠正系统提示 +- 用户现象:已经记下入学年月后,下一问仍假定某年高考、再问大学哪年入学或毕业。点选卡题干变成空泛的「有没有这件事」。 +- 触发条件:账本已有带日期 `education_start`;引擎仍按出生年+17 发出邻近年学业存在性探针;模型把探针年说成已经发生的事实。 +- 根因:(1) 学业存在性探针只跳过同一日历年,高考/入学同一学年的前一年仍会出题。(2) 冲突探针在方法覆盖未齐时插队,挡住事业等下一层。(3) 系统提示只写「不得发明年份」,没有禁止把探针年说成事实,也没有禁止再问已入账的入学/分手日期。(4) 服务器点选题干写成「有没有这件事」,丢掉事件家族。 +- 修复:学业存在性探针跳过已记学业年的 ±1 年;质量探针仍按精确年。Followup 对邻近年存在性探针不再插队。追问提示带上已记入学/毕业/感情起止,并禁止把未证实年份说成已经发生。点选题干改用假设句里的事件家族。不改 Skill `10.0.11`。 +- 验证:`frontend/tests/rectification-eight-method.test.ts`、`frontend/tests/rectification-choice-card.test.ts`、`frontend/tests/rectification-v9-agent.test.ts`、`tests/test_rectification_event_probes.py`。 +- 防复发:已有入学证据时不得再问入学年,也不得把年龄带推算的高考年说成事实。邻近年学业存在性探针不得挡住下一方法层。点选题干必须带事件家族,不能只写「有没有这件事」。不得改已哈希 Skill `10.0.11`。 +- 相关记录:BUG-192、BUG-351、BUG-375 +- 复发自:BUG-192(已说外地上大学后仍换词重问迁居;本次是已记入学后按年龄带编造高考年再问入学) +- 修复版本:待发布 + ## BUG-378 | staging 质量门 2021/2022:activity 合同仍要求 settled.spoken - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/core/build-state.ts b/frontend/src/lib/rectification-agentic/core/build-state.ts index c2fdfe97..6f4dcc3a 100644 --- a/frontend/src/lib/rectification-agentic/core/build-state.ts +++ b/frontend/src/lib/rectification-agentic/core/build-state.ts @@ -340,5 +340,6 @@ function phaseFor(status: ResultStatus, fallback: RectificationPhase): Rectifica if (status === "converged" || status === "credible_range") return "completed"; if (status === "max_rounds_reached" || status === "validation_failed") return "stopped"; if (status === "insufficient_evidence") return "event_collection"; + if (status === "discriminating") return "discrimination"; return fallback; } diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 732d1374..8f824d1d 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -67,29 +67,13 @@ export type EngineContrastProbe = Readonly<{ right_time?: string; }>; -const D10_PREDICTIONS: Readonly> = { - 白羊: "某一阶段身份独立、承担主导职责", - 金牛: "稳定技术或事务执行、在组织内慢慢积累", - 双子: "沟通、写作或频繁切换任务", - 巨蟹: "照顾、家庭或情感劳动更重的职责", - 狮子: "站到台前、带人、或公开担责", - 处女: "稳定技术执行、组织内分析或服务", - 天秤: "跨领域合作、工作环境或合作模式变化", - 天蝎: "研究、转化、或把一件事做深", - 射手: "跨领域、教学或更开阔的工作环境", - 摩羯: "管理职责、长期结构、延迟回报", - 水瓶: "独立技术路线、非传统协作", - 双鱼: "服务、艺术或界限更模糊的工作", -}; - -const REMAINING_LAYER_ORDER = ["d24", "d5", "d10", "d9"] as const; -const DUTY_ANSWERED_RE = /技术执行|算法|分析|数据处理|系统维护|组织型|第三个|照顾、家庭|台前|带人|公开担责/; +const REMAINING_LAYER_ORDER = ["d24", "d5", "d10", "d9", "d4"] as const; +const DUTY_ANSWERED_RE = /技术执行|算法|分析|数据处理|系统维护|组织型|第三个|照顾、家庭|台前|带人|公开担责|程序员|前端|工程师|开发/; +const EXAM_QUALITY_RE = /失利|失常|复读|没考好|考砸|发挥不好|发挥失常|压力很大/; +const RELOCATION_RE = /搬家|离乡|迁居|长期异地|离开家/; +const LIVE_EVIDENCE = new Set(["confirmed", "draft", "pending_confirmation"]); const ANSWER_CLASSES: ReadonlySet = new Set(["yes", "weak_yes", "no", "unsure"]); -function signKey(value: string): string { - return value.replace(/座$/, "").trim(); -} - function clockMinutes(time: string): number { const [hour, minute] = time.split(":").map(Number); return hour * 60 + minute; @@ -136,19 +120,39 @@ export function remainingVargaSplits( return rows; } -export function askedKeysFromOccupationEvidence( +export function askedKeysFromLedgerEvidence( evidence: readonly Readonly<{ + status?: string | null; domain?: string | null; eventKind?: string | null; summary?: string | null; }>[], ): string[] { + const keys = new Set(); for (const item of evidence) { + if (item.status && !LIVE_EVIDENCE.has(item.status)) continue; + const summary = item.summary ?? ""; const occupation = item.domain === "occupation" || item.eventKind === "occupation_note"; - if (!occupation) continue; - if (DUTY_ANSWERED_RE.test(item.summary ?? "")) return ["varga.d10"]; + if (occupation || DUTY_ANSWERED_RE.test(summary)) keys.add("varga.d10"); + if (item.domain === "education" && EXAM_QUALITY_RE.test(summary)) { + keys.add("varga.d24"); + keys.add("varga.d5"); + } + const relocation = item.domain === "relocation" || item.eventKind === "home_change"; + if (relocation || RELOCATION_RE.test(summary)) keys.add("varga.d4"); } - return []; + return [...keys]; +} + +export function askedKeysFromOccupationEvidence( + evidence: readonly Readonly<{ + status?: string | null; + domain?: string | null; + eventKind?: string | null; + summary?: string | null; + }>[], +): string[] { + return askedKeysFromLedgerEvidence(evidence); } export function buildCandidateContrastPacket(input: { @@ -179,7 +183,6 @@ export function buildCandidateContrastPacket(input: { transitions: input.transitions ?? [], }); const fromVarga = vargaProbe( - vargaDifferences, remainingSplits, input.candidateSetVersion, input.calculationResultId ?? null, @@ -293,48 +296,14 @@ function probeFromEngine( } function vargaProbe( - differences: readonly VargaDifference[], remainingSplits: readonly RemainingVargaSplit[], candidateSetVersion: string, calculationResultId: string | null, asked: ReadonlySet, ): CandidateDiscriminatorProbe | null { const remaining = remainingSplits.find((item) => !vargaLayerAsked(asked, item.layer)); - if (remaining) { - return vargaProbeFromRemaining(remaining, candidateSetVersion, calculationResultId); - } - const d10 = differences.find((item) => item.layer === "d10" && item.signs.length >= 2); - const d9 = differences.find((item) => item.layer === "d9" && item.signs.length >= 2); - const chosen = d10 && !vargaLayerAsked(asked, "d10") - ? d10 - : d9 && !vargaLayerAsked(asked, "d9") - ? d9 - : null; - if (!chosen) return null; - const semanticKey = `varga.${chosen.layer}.${chosen.signs.join("|")}`; - if (asked.has(semanticKey)) return null; - const predictions = chosen.signs.map((sign) => D10_PREDICTIONS[signKey(sign)] ?? `${sign} 这一段更常见的前事`); - const outcomes: ContrastExpectedOutcome[] = chosen.signs.map((sign, index) => ({ - outcomeId: `supports_${signKey(sign)}`, - supportsCandidateIds: [sign], - conflictsCandidateIds: chosen.signs.filter((_, other) => other !== index), - })); - const layerLabel = chosen.layer.toUpperCase(); - const question = chosen.layer === "d10" - ? `当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:更接近${predictions.join(",还是")}?` - : `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`; - return { - probeId: `contrast:${semanticKey}`, - candidateSetVersion, - question, - expectedOutcomes: outcomes, - candidateSplitHash: semanticKey, - informationGain: 0.12, - sourceFeatures: [{ technique: layerLabel, calculationResultId }], - domain: chosen.layer === "d10" ? "career" : "relationship", - year: null, - semanticKey, - }; + if (!remaining) return null; + return vargaProbeFromRemaining(remaining, candidateSetVersion, calculationResultId); } function vargaLayerAsked(asked: ReadonlySet, layer: string): boolean { @@ -354,12 +323,8 @@ function vargaProbeFromRemaining( const outcomes = remainingOutcomes(split.groups, allMinutes); const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`; const layerLabel = split.layer.toUpperCase(); - const education = split.layer === "d24" || split.layer === "d5"; - const question = education - ? "当前几个候选在学业盘上还分得开。请核对一段还没用进评分的学业前事:那次高考或重要考试有没有发挥明显失常、压力很大?" - : split.layer === "d10" - ? "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:长期更接近照顾或家庭,还是台前带人,还是技术执行或分析?" - : `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`; + const domain = remainingDomain(split.layer); + const question = remainingQuestion(split.layer, layerLabel); return { probeId: `contrast:${semanticKey}`, candidateSetVersion, @@ -368,12 +333,32 @@ function vargaProbeFromRemaining( candidateSplitHash: semanticKey, informationGain: split.layer === "d24" || split.layer === "d5" ? 0.16 : 0.12, sourceFeatures: [{ technique: layerLabel, calculationResultId }], - domain: education ? "education" : split.layer === "d10" ? "career" : "relationship", + domain, year: null, semanticKey, }; } +function remainingDomain(layer: string): string { + if (layer === "d24" || layer === "d5") return "education"; + if (layer === "d10") return "career"; + if (layer === "d4") return "relocation"; + return "relationship"; +} + +function remainingQuestion(layer: string, layerLabel: string): string { + if (layer === "d24" || layer === "d5") { + return "当前几个候选在学业盘上还分得开。请核对一段还没用进评分的学业前事:那次高考或重要考试有没有发挥明显失常、压力很大?"; + } + if (layer === "d10") { + return "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:长期更接近照顾或家庭,还是台前带人,还是技术执行或分析?"; + } + if (layer === "d4") { + return "当前几个候选在居所盘上还分得开。请核对一段还没用进评分的搬家或离乡:那几年有没有明显搬家、离乡或长期异地?"; + } + return `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`; +} + function remainingOutcomes( groups: readonly (readonly string[])[], allMinutes: readonly string[], diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index aa8ed05d..e4ab7616 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -28,6 +28,7 @@ export type RectificationChoiceFrame = Readonly<{ question_id: string; method_id: string; period: string; + prompt: string; varga: string | null; why: string; option_a_hint: string; @@ -296,6 +297,7 @@ export function buildChoiceFrame( question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`, method_id: followup.method_id, period: periodFor(input.evidence, domain, input.probes, input.birthDate), + prompt: hypothesis.prompt, varga: hypothesis.varga, why: hypothesis.why, option_a_hint: hypothesis.a, @@ -318,7 +320,8 @@ function clippedCopy(value: unknown, min: number, max: number): string | null { } export function serverOwnedChoiceCopy(frame: RectificationChoiceFrame): AgentChoiceCopy | null { - const prompt = clippedCopy(`${frame.period},有没有这件事?`, 4, 80) + const prompt = clippedCopy(frame.prompt, 4, 80) + ?? clippedCopy(`${frame.period},有没有这件事?`, 4, 80) ?? clippedCopy(frame.period, 4, 80); const optionA = clippedCopy(frame.option_a_hint, 4, 80); const optionB = clippedCopy(frame.option_b_hint, 4, 80); diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index e69c3c30..6d88dc91 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -10,7 +10,7 @@ import { import { isDuplicateProbe } from "../core/duplicate-probes.ts"; import { probeFromEngine } from "../core/probes-from-engine.ts"; import { selectHighestGainProbe } from "../core/select-probe.ts"; -import { askedKeysFromOccupationEvidence } from "../core/candidate-contrast-packet.ts"; +import { askedKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts"; import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts"; import { isHoldoutVerificationQuote, @@ -58,7 +58,7 @@ export function askedDiscriminatorKeys( ): string[] { return [ ...askedProbeKeysFromReceipt(receipt), - ...askedKeysFromOccupationEvidence(evidence), + ...askedKeysFromLedgerEvidence(evidence), ]; } @@ -71,7 +71,9 @@ export function compactInferenceProjection(state: InferenceState | null | undefi algorithm_version: state.algorithm_version, candidate_set_id: state.candidate_set_id, revision: state.revision, - phase: state.phase, + phase: state.result_status === "discriminating" && state.phase === "event_collection" + ? "discrimination" + : state.phase, result_status: state.result_status, entropy: state.entropy, representative_time: state.representative_time, diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index 0d1d3adb..5e9e164c 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -6,7 +6,7 @@ */ import { - askedKeysFromOccupationEvidence, + askedKeysFromLedgerEvidence, buildCandidateContrastPacket, } from "../core/candidate-contrast-packet.ts"; import { evaluateCandidateSeparation } from "../core/candidate-separation.ts"; @@ -71,7 +71,7 @@ export function choiceCardFromCaseDossier(dossier: { const candidateScores = candidateScoresFromDossier(dossier.latestResult); const askedProbeKeys = [ ...askedProbeKeysFromReceipt(dossier.latestResult?.decisionReceipt), - ...askedKeysFromOccupationEvidence(dossier.evidence), + ...askedKeysFromLedgerEvidence(dossier.evidence), ]; const contrastPacket = buildCandidateContrastPacket({ candidateSetVersion: inference?.candidate_set_id ?? dossier.latestResult?.resultId ?? "none", diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index a8a5a9b6..7a8266bf 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -46,7 +46,7 @@ import { type HoldoutValidationStatus, } from "../core/decide-next-action.ts"; import { - askedKeysFromOccupationEvidence, + askedKeysFromLedgerEvidence, selectDiscriminatorProbe, type CandidateContrastPacket, type CandidateDiscriminatorProbe, @@ -151,16 +151,63 @@ function evidenceYear(item: MethodFollowupEvidence): number | null { return year >= 1900 && year <= 2100 ? year : null; } -function hasDatedEvidenceInYear( +/** 高考与当年 9 月入学通常是同一学年;年龄带常落在入学年的前一年。 */ +const EDUCATION_EXISTENCE_NEARBY_YEARS = 1; + +const RECORDED_KIND_LABEL: Readonly> = { + education_start: "入学", + education_completion: "毕业", + education_interruption: "学业中断", + education_change: "转学或学业变化", + education_milestone: "学业节点", + relationship_start: "感情开始", + relationship_commitment: "关系确认", + relationship_separation: "感情分开", + relationship_end: "感情结束", + career_entry: "入职", + career_change: "事业变化", + promotion: "升职", + relocation: "搬家", + home_change: "住处变化", +}; + +function existenceNearbyYears(domain: string): number { + return domain === "education" ? EDUCATION_EXISTENCE_NEARBY_YEARS : 0; +} + +function probeYearAlreadyCovered( evidence: readonly MethodFollowupEvidence[], domain: string, year: number, ): boolean { - return evidence.some((item) => - (item.status === "confirmed" || item.status === "draft" || item.status === "pending_confirmation") - && item.domain === domain - && evidenceYear(item) === year - ); + const nearby = existenceNearbyYears(domain); + return evidence.some((item) => { + if (item.status !== "confirmed" && item.status !== "draft" && item.status !== "pending_confirmation") { + return false; + } + if (item.domain !== domain) return false; + const itemYear = evidenceYear(item); + if (itemYear === null) return false; + return Math.abs(itemYear - year) <= nearby; + }); +} + +function recordedKindYearHint(evidence: readonly MethodFollowupEvidence[]): string { + const labels: string[] = []; + const seen = new Set(); + for (const item of evidence) { + if (!isConfirmedDated(item)) continue; + const year = evidenceYear(item); + if (year === null) continue; + const kindLabel = RECORDED_KIND_LABEL[item.eventKind ?? ""] ?? null; + if (!kindLabel) continue; + const token = `${year}:${kindLabel}`; + if (seen.has(token)) continue; + seen.add(token); + labels.push(`${year} 年${kindLabel}`); + } + if (labels.length === 0) return ""; + return `已记下 ${labels.join("、")}。不要再问这些事发生在哪一年。`; } function isOccupationNote(item: MethodFollowupEvidence): boolean { @@ -264,7 +311,7 @@ function remainingReverseVerifyProbes( for (const probe of probes ?? []) { if (probe.source === "known_event_quality" || probe.role === "distinguish") continue; if (declined.has(probe.domain)) continue; - if (hasDatedEvidenceInYear(evidence, probe.domain, probe.year)) continue; + if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; if (CONFLICT_PROBE_SOURCES.has(probe.source)) { dasha.push(probe); } else { @@ -284,7 +331,7 @@ function remainingConflictProbes( for (const probe of probes ?? []) { if (!CONFLICT_PROBE_SOURCES.has(probe.source)) continue; if (declined.has(probe.domain)) continue; - if (hasDatedEvidenceInYear(evidence, probe.domain, probe.year)) continue; + if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const split = probe.candidate_split_hash ?? ""; if (askedKeys.has(semantic) || (split && askedKeys.has(split))) continue; @@ -302,12 +349,22 @@ function coverage( return { method_id: methodId, status }; } -function collectHint(why: string, varga: string, extra = ""): string { - return `${why}本题绑定 ${varga}。${extra}用自然语言问一件带大概年份的经历。不要调用 set-focus,界面不出点选卡。允许模糊年份。`.replace(/\s+/g, " ").trim(); +function collectHint( + why: string, + varga: string, + extra = "", + evidence: readonly MethodFollowupEvidence[] = [], +): string { + return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}用自然语言问一件带大概年份的经历。不要调用 set-focus,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim(); } -function agentHint(why: string, varga: string, extra = ""): string { - return `${why}本题绑定 ${varga}。${extra}点选卡已由服务器按 choice_frame 持久化。用简体中文只问这一句已持久化的题干;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份。不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim(); +function agentHint( + why: string, + varga: string, + extra = "", + evidence: readonly MethodFollowupEvidence[] = [], +): string { + return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}点选卡已由服务器按 choice_frame 持久化。用简体中文只问这一句已持久化的题干;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得把探针年份说成已经发生的事实。不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim(); } export function shouldAttachChoiceFrame( @@ -394,11 +451,10 @@ export function isOfferBlockingFollowup( function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null { if (!followup) return null; - const discriminator = followup.source === "event_probe" - || followup.source === "varga_observation" - || followup.source === "precision_stage" - || followup.intent === "distinguish_candidates"; - if (!discriminator) return null; + const realProbe = followup.source === "event_probe" + || followup.source === "reverse_verify" + || (followup.source === "active_focus" && followup.intent === "distinguish_candidates"); + if (!realProbe) return null; const split = followup.candidate_split_hash ?? followup.semantic_key ?? followup.method_id; return { probeId: split, @@ -441,7 +497,9 @@ export function conversationalSessionOutcome(input: { selectionAllowed: input.selectionAllowed, snapshotCurrent: input.snapshotCurrent, candidateScores: input.candidateScores ?? [], - discriminatorProbe: input.discriminatorProbe ?? discriminatorFromFollowup(input.nextFollowup), + discriminatorProbe: input.discriminatorProbe !== undefined + ? input.discriminatorProbe + : discriminatorFromFollowup(input.nextFollowup), holdoutValidation: input.holdoutValidation, }); return sessionKindFromNextAction(decided.type); @@ -576,6 +634,10 @@ export function buildMethodFollowupPlan(input: { : null, }; }; + const collect = (why: string, varga: string, extra = "") => + collectHint(why, varga, extra, input.evidence); + const ask = (why: string, varga: string, extra = "") => + agentHint(why, varga, extra, input.evidence); const declined = declinedDomains(input.declinedTopics ?? []); const dashaCovered = input.evidence.some(isConfirmedDated); const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship"); @@ -666,7 +728,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: theme, domain: probe.domain, kind_hint: REVERSE_VERIFY_KIND[probe.domain], - user_prompt_hint: agentHint( + user_prompt_hint: ask( `当前排盘已采用。按该分钟核对:${probe.year_label} 是否有${probe.event_family}。对得上写入账本并重算;对不上可以改选其他候选。不确认唯一分钟。`, REVERSE_VERIFY_VARGA[probe.domain], ), @@ -688,7 +750,7 @@ export function buildMethodFollowupPlan(input: { const stage = input.precisionStage ?? null; const askedKeys = new Set([ ...(input.askedProbeKeys ?? []), - ...askedKeysFromOccupationEvidence(input.evidence), + ...askedKeysFromLedgerEvidence(input.evidence), ]); const conflictProbe = dashaCovered ? remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys)[0] ?? null @@ -700,7 +762,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "dated_event", domain: null, kind_hint: null, - user_prompt_hint: collectHint( + user_prompt_hint: collect( "可以先从最容易想起的一件带大概时间的经历开始。", "本命 Dasha + 行运(方法1)", "不要求一次列出 10–15 条。", @@ -714,7 +776,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: REVERSE_VERIFY_THEME[conflictProbe.domain], domain: conflictProbe.domain, kind_hint: REVERSE_VERIFY_KIND[conflictProbe.domain], - user_prompt_hint: agentHint( + user_prompt_hint: ask( `当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`, REVERSE_VERIFY_VARGA[conflictProbe.domain], ), @@ -731,7 +793,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "relationship_style", domain: "relationship", kind_hint: "relationship_start", - user_prompt_hint: collectHint( + user_prompt_hint: collect( "可以先说一段记得大概时间的感情或关系变化。", "D9", "对照 D9 上升类型表(白羊主动热情、天蝎深刻占有等)只作校时方法,不是命运承诺。", @@ -745,7 +807,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "career_style", domain: "career", kind_hint: "career_entry", - user_prompt_hint: collectHint( + user_prompt_hint: collect( "可以先说一段记得大概时间的工作或事业变化。同一件事会同时对照本命第 10 宫和 D10。", "D10", "可用 D10 事业类型表作校时对照。", @@ -759,7 +821,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "family_event", domain: "family", kind_hint: "family_event", - user_prompt_hint: collectHint( + user_prompt_hint: collect( "可以先说一段记得大概时间的家人相关变化。同一件事会对照 D12 父母盘、D7 子女盘和 D3 兄弟盘。", "D12 / D7 / D3", "六亲用 Raman 六步:前三步可执行,后三步标方法论层。", @@ -773,7 +835,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "occupation", domain: "occupation", kind_hint: "occupation_note", - user_prompt_hint: collectHint( + user_prompt_hint: collect( "你长期做什么工作?对照本命第 10 宫和 D10。", "D1-H10 + D10", "可用事业类型表(白羊领导创业、天蝎研究转化等)作校时方法。", @@ -788,7 +850,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: REVERSE_VERIFY_THEME[domain], domain, kind_hint: REVERSE_VERIFY_KIND[domain], - user_prompt_hint: agentHint( + user_prompt_hint: ask( contrastProbe.question, REVERSE_VERIFY_VARGA[domain], "按候选盘面差异核对前事,不要问两套盘哪个更像。", @@ -806,7 +868,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "dated_event", domain: null, kind_hint: null, - user_prompt_hint: agentHint( + user_prompt_hint: ask( "窗口里本命上升还可能落在两段。按 choice_frame.period 与探针问一件前事是否发生,用来筛这两段。不要问两套盘哪个更像。", "本命上升 / Dasha", ), @@ -819,7 +881,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "relationship_style", domain: "relationship", kind_hint: "relationship_change", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "关系盘仍会换升。按探针年份问感情这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D9", "对照 D9 上升类型表只作校时方法。", @@ -833,7 +895,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "career_style", domain: "career", kind_hint: "career_change", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "事业盘仍会换升。按探针年份问事业这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D10", "可用 D10 事业类型表作校时对照。", @@ -847,7 +909,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "home_change", domain: "relocation", kind_hint: "home_change", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "居所盘仍会换升。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D4", ), @@ -860,7 +922,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "education_style", domain: "education", kind_hint: "education_milestone", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "成就盘或学业盘仍会换升。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D5 / D24", ), @@ -882,7 +944,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "relationship_style", domain: "relationship", kind_hint: "relationship_change", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "当前候选在关系主题上仍分不开。按探针年份问感情前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D9", "对照 D9 上升类型表只作校时方法。", @@ -896,7 +958,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "career_style", domain: "career", kind_hint: "career_change", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "当前候选在事业主题上仍分不开。按探针年份问事业前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D10", "可用事业类型表作校时对照。", @@ -910,7 +972,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "home_change", domain: "relocation", kind_hint: "home_change", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "当前候选在居所主题上仍分不开。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D4", ), @@ -923,7 +985,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "education_style", domain: "education", kind_hint: "education_milestone", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "当前候选在学业或成就主题上仍分不开。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D5 / D24", ), @@ -936,7 +998,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "family_event", domain: "family", kind_hint: "family_event", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "当前候选在家人主题上仍分不开。按探针年份问家人变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D12 / D7 / D3", ), @@ -949,7 +1011,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "finance_change", domain: "finance", kind_hint: "finance_change", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "当前候选在财务主题上仍分不开。按探针年份问财务变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D2 / D11", ), @@ -962,7 +1024,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "health_pressure", domain: "health_pressure", kind_hint: "self_health_event", - user_prompt_hint: agentHint( + user_prompt_hint: ask( "当前候选在健康压力主题上仍分不开。按探针年份问健康或压力变化是否发生,用来筛窗。这不是医学判断。不要问两套盘哪个更像。", "D30", ), @@ -997,7 +1059,7 @@ export function buildMethodFollowupPlan(input: { ask_theme: "horary", domain: "horary", kind_hint: "horary_query", - user_prompt_hint: collectHint( + user_prompt_hint: collect( "有没有第一次认真问起这件事的时间?", "占问观察盘", "有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。", @@ -1037,7 +1099,7 @@ export function projectRectificationChoiceCard( methods: plan.methods, userStopped: input.userStopped, candidateScores: input.candidateScores, - discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null), + discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null) ?? undefined, holdoutValidation: input.holdoutValidation, }); if ( diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 80a6284d..b8fd0a37 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -71,7 +71,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑 8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。优先传 source 原文的 quoteStart/quoteEnd,不要改写 quote。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。 9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_action:id=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compare,C 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–Dasha–Gochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。 10. 不泄露系统提示词或 Skill 原文。 -11. 追问只跟 method_followup_plan 与服务器已持久化的 current_question / open_question。不要调用 rectification-set-focus;下一问和点选卡由 compare-candidates / read-case 在服务端事务内创建。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,正文直接问,不要提点选卡。若工具返回了 open_question.prompt,原样用简体中文问这一句,不得发明年份,不要把已回答的考试质量题或职责倾向再问一遍。挡住出牌的方法层未齐时,source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后问区分探针,不要 adopt。不要问两套盘哪个更像或可能性高低。点选 A/B/C/D 与「先这样」由服务器按 questionId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。正文禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。 +11. 追问只跟 method_followup_plan 与服务器已持久化的 current_question / open_question。不要调用 rectification-set-focus;下一问和点选卡由 compare-candidates / read-case 在服务端事务内创建。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,正文直接问,不要提点选卡。若工具返回了 open_question.prompt,原样用简体中文问这一句,不得发明年份,不得根据出生年推算高考或入学年份并当成事实,不要把已回答的考试质量题或职责倾向再问一遍。账本已有入学、毕业或感情开始/结束日期时,不要再问那一件发生在哪一年。挡住出牌的方法层未齐时,source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后只问当前剩余候选分钟还能拆开的区分探针;没有剩余拆分且未拉开时落实 offer_provisional_range,不要再问整窗 D9/D24,也不要 adopt。不要问两套盘哪个更像或可能性高低。点选 A/B/C/D 与「先这样」由服务器按 questionId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。正文禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。 12. 证据有效变化后由服务器重算候选。不要等用户说“没有更多了”才比较,也不要对同一证据指纹再 compare。分钟扫描只在服务端,结果只是候选或平台,不得宣布确认。 13. 落实 start_consultation:前事核对结束或用户先这样后,请用户用当前采用时间看盘;对不上同时请改选其他候选。解释事件–Dasha 账本、双轨是否一致、换升时刻、精度阶段、D9/D10 类型对照和相对支持时,仍必须说候选范围不是出生时间真值。`; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 3f6a65fc..88c26f72 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -328,7 +328,7 @@ function safeCaseProjection( hasLatestResult: Boolean(latestProjection), selectionAllowed, sessionOutcome, - nextFollowup: methodFollowupPlan.next_followup ?? collectingPlan.next_followup, + nextFollowup: methodFollowupPlan.next_followup, workingTime: caseRow.acceptedTime ?? (typeof birthContext.active_birth_time === "string" ? birthContext.active_birth_time : null) ?? (typeof birthContext.reported_birth_time === "string" ? birthContext.reported_birth_time : null), @@ -572,6 +572,61 @@ function collectingFollowupForParsed( }); } +function sessionAwareFollowupForParsed( + parsed: DossierForTools, + latest: NonNullable, + options?: { birthDate?: string | null; snapshotCurrent?: boolean }, +) { + const collectingPlan = collectingFollowupForParsed(parsed, latest); + const contrastPacket = contrastPacketFromLatest(latest, parsed.evidence); + const candidateScores = candidateScoresFromLatest(latest); + const holdoutValidation = holdoutStatusFromLatest(latest); + const sessionOutcome = conversationalSessionOutcome({ + selectionAllowed: latest.selectionAllowed, + proposeAllowed: readProposeAllowed(latest.decisionReceipt), + confirmationAllowed: false, + nextFollowup: collectingPlan.next_followup, + methods: collectingPlan.methods, + userStopped: latestUserStoppedCollecting(parsed.turns), + candidateScores, + discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + holdoutValidation, + snapshotCurrent: options?.snapshotCurrent, + }); + if (sessionOutcome === "collect_evidence") { + return { + plan: { ...collectingPlan, session_outcome: sessionOutcome }, + contrastPacket, + sessionOutcome, + }; + } + const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); + const observations = internalObservationsFromWindowScan(windowScan); + const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); + const separation = evaluateCandidateSeparation(candidateScores); + return { + plan: buildMethodFollowupPlan({ + evidence: parsed.evidence, + activeFocus: parsed.conversationSummary.activeFocus, + declinedTopics: parsed.conversationSummary.declinedSkippedTopics, + observations, + sessionOutcome, + precisionStage: refinement.precision_stage?.current, + nakshatraBoundary: refinement.nakshatra_boundary, + oosBlindPrompts: refinement.oos_blind_prompts, + eventProbes: refinement.discriminating_event_probes, + askedProbeKeys: askedDiscriminatorKeys(latest.decisionReceipt, parsed.evidence), + birthDate: options?.birthDate ?? null, + accepted: Boolean(parsed.case.acceptedTime), + candidatesSeparated: separation.sufficient, + contrastPacket, + holdoutValidation, + }), + contrastPacket, + sessionOutcome, + }; +} + function safeConversationSummary(dossier: DossierForTools) { const summary = dossier.conversationSummary; const focus = summary.activeFocus; @@ -854,7 +909,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { parsed: DossierForTools, latest: NonNullable, ) => { - const collectingPlan = collectingFollowupForParsed(parsed, latest); + const { plan: collectingPlan, contrastPacket } = sessionAwareFollowupForParsed(parsed, latest); const persistedFocus = await persistServerOwnedFocus({ accounting, userId, @@ -863,7 +918,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { decisionReceipt: latest.decisionReceipt, followup: collectingPlan.next_followup, }); - return { collectingPlan, persistedFocus }; + return { collectingPlan, persistedFocus, contrastPacket }; }; const autoRescoreAfterEvidenceChange = async (targetCaseId: string) => { @@ -1525,12 +1580,13 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { algorithmVersion: scored.persisted.algorithmVersion, decisionReceipt: scored.persisted.decisionReceipt, }; - const { collectingPlan, persistedFocus } = await persistPlanFocus(scored.parsed, latest); + const { collectingPlan, persistedFocus, contrastPacket } = await persistPlanFocus(scored.parsed, latest); const latestProjection = latestResultToolProjection(latest, { proposeAllowed: readProposeAllowed(latest.decisionReceipt), nextFollowup: collectingPlan.next_followup, methods: collectingPlan.methods, userStopped: latestUserStoppedCollecting(scored.parsed.turns), + discriminatorProbe: selectDiscriminatorProbe(contrastPacket), evidence: scored.parsed.evidence, }); const projection = { @@ -1631,10 +1687,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { throw new RectificationToolServiceError("no_candidate_result"); } const latest = parsed.latestResult; - const collectingPlan = collectingFollowupForParsed(parsed, latest); + const { plan: collectingPlan, contrastPacket } = sessionAwareFollowupForParsed(parsed, latest); const proposeAllowed = readProposeAllowed(latest.decisionReceipt); const userStopped = latestUserStoppedCollecting(parsed.turns); - const contrastPacket = contrastPacketFromLatest(latest, parsed.evidence); const candidateScores = candidateScoresFromLatest(latest); const currentSnapshot = snapshotSourceFromDossier(parsed, null); const storedSnapshot = storedSnapshotSource(latest); diff --git a/frontend/supabase/migrations/20260825010000_rectification_evidence_kind_date_dedup.sql b/frontend/supabase/migrations/20260825010000_rectification_evidence_kind_date_dedup.sql new file mode 100644 index 00000000..5ffed908 --- /dev/null +++ b/frontend/supabase/migrations/20260825010000_rectification_evidence_kind_date_dedup.sql @@ -0,0 +1,347 @@ +begin; + +-- Dated evidence restated with a new quote must reuse the existing +-- case_id + domain + event_kind + occurred_from/to row. Quote-only +-- idempotency_key still covers exact retries. + +create or replace function public.record_agentic_rectification_evidence_batch( + p_user_id uuid, + p_case_id uuid, + p_source_turn_id uuid, + p_focus_id uuid, + p_items jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_turn public.agentic_rectification_turns%rowtype; + v_focus public.agentic_rectification_conversation_focuses%rowtype; + v_focus_replay boolean := false; + v_focus_match_id uuid; + v_focus_match_count integer := 0; + v_focus_resolution text := 'not_requested'; + v_item jsonb; + v_index bigint; + v_item_key text; + v_quote text; + v_subject text; + v_kind text; + v_domain text; + v_precision text; + v_summary text; + v_from date; + v_to date; + v_status text; + v_outcome text; + v_error text; + v_clarification jsonb; + v_existing public.agentic_rectification_evidence%rowtype; + v_evidence_id uuid; + v_idempotent boolean; + v_results jsonb := '[]'::jsonb; + v_accepted integer := 0; + v_needs integer := 0; + v_rejected integer := 0; +begin + if p_user_id is null or p_case_id is null or p_source_turn_id is null + or p_items is null or jsonb_typeof(p_items) <> 'array' + or jsonb_array_length(p_items) = 0 or jsonb_array_length(p_items) > 12 then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_turn + from public.agentic_rectification_turns + where id = p_source_turn_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + if v_turn.user_message is null then + raise exception 'agentic_rectification_quote_not_grounded' using errcode = 'P0001'; + end if; + + if p_focus_id is not null then + select * into v_focus + from public.agentic_rectification_conversation_focuses + where id = p_focus_id and case_id = p_case_id + for update; + if not found or v_focus.status not in ('active', 'resolved') then + raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001'; + end if; + v_focus_replay := v_focus.status = 'resolved'; + if not v_focus_replay and v_focus.target_evidence_id is not null then + raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001'; + end if; + v_focus_resolution := case when v_focus_replay then 'idempotent' else 'active' end; + if v_focus_replay and exists ( + select 1 + from jsonb_array_elements(p_items) item + where length(btrim(coalesce(item->>'idempotency_key', ''))) = 0 + or not exists ( + select 1 from public.agentic_rectification_evidence e + where e.case_id = p_case_id + and e.idempotency_key = btrim(item->>'idempotency_key') + ) + ) then + raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001'; + end if; + end if; + + for v_item, v_index in + select value, ordinality + from jsonb_array_elements(p_items) with ordinality + loop + v_item_key := btrim(coalesce(v_item->>'idempotency_key', '')); + v_quote := btrim(coalesce(v_item->>'quote', '')); + v_subject := coalesce(v_item->>'subject', ''); + v_kind := coalesce(v_item->>'event_kind', ''); + v_domain := coalesce(v_item->>'domain', ''); + v_precision := coalesce(v_item->>'date_precision', ''); + v_summary := btrim(coalesce(v_item->>'summary', '')); + v_from := null; + v_to := null; + v_error := null; + v_clarification := '[]'::jsonb; + v_evidence_id := null; + v_idempotent := false; + + begin + if nullif(v_item->>'occurred_from', '') is not null then + v_from := (v_item->>'occurred_from')::date; + end if; + if nullif(v_item->>'occurred_to', '') is not null then + v_to := (v_item->>'occurred_to')::date; + end if; + exception when others then + v_error := 'invalid_date'; + end; + + if v_error is null and ( + length(v_item_key) = 0 or length(v_item_key) > 160 + or length(v_quote) = 0 or length(v_summary) = 0 + or v_subject not in ('self', 'family', 'other') + or not (v_kind = any (public.agentic_rectification_evidence_kinds())) + or not (v_domain = any (public.agentic_rectification_evidence_domains())) + or not (v_precision = any (public.agentic_rectification_date_precisions())) + ) then + v_error := 'invalid_item'; + end if; + + if v_error is null and position( + public.agentic_rectification_normalize_quote(v_quote) + in public.agentic_rectification_normalize_quote(v_turn.user_message) + ) = 0 then + v_error := 'quote_not_grounded'; + end if; + + if v_error is null and v_precision = 'range' + and (v_from is null or v_to is null or v_from > v_to) then + v_error := 'invalid_range'; + end if; + + if v_error is not null then + v_outcome := 'rejected'; + v_status := 'rejected'; + v_rejected := v_rejected + 1; + else + select * into v_existing + from public.agentic_rectification_evidence + where case_id = p_case_id and idempotency_key = v_item_key; + + if found then + if v_existing.source_turn_id is distinct from p_source_turn_id + or v_existing.user_quote is distinct from v_quote + or v_existing.subject is distinct from v_subject + or v_existing.event_kind is distinct from v_kind + or v_existing.domain is distinct from v_domain + or v_existing.occurred_from is distinct from v_from + or v_existing.occurred_to is distinct from v_to + or v_existing.date_precision is distinct from v_precision + or v_existing.summary is distinct from v_summary then + v_outcome := 'rejected'; + v_status := 'rejected'; + v_error := 'idempotency_conflict'; + v_rejected := v_rejected + 1; + else + v_evidence_id := v_existing.id; + v_idempotent := true; + if v_existing.status = 'confirmed' then + v_outcome := 'accepted'; + v_status := 'confirmed'; + v_accepted := v_accepted + 1; + elsif public.agentic_rectification_allows_dateless_confirm(v_kind) + and (v_precision = 'unknown' or v_from is null) then + update public.agentic_rectification_evidence + set status = 'confirmed', + confirmed_at = coalesce(confirmed_at, pg_catalog.now()), + updated_at = pg_catalog.now() + where id = v_existing.id + and status is distinct from 'confirmed'; + v_outcome := 'accepted'; + v_status := 'confirmed'; + v_accepted := v_accepted + 1; + else + v_outcome := 'needs_clarification'; + v_status := v_existing.status; + v_clarification := case + when v_existing.date_precision = 'unknown' then '["date"]'::jsonb + else '[]'::jsonb + end; + v_needs := v_needs + 1; + end if; + end if; + else + -- Same dated event restated with a new quote must not insert a second row. + select * into v_existing + from public.agentic_rectification_evidence e + where e.case_id = p_case_id + and v_from is not null + and e.event_kind = v_kind + and e.domain = v_domain + and e.occurred_from is not distinct from v_from + and e.occurred_to is not distinct from v_to + and e.status in ('confirmed', 'pending_confirmation', 'draft') + and not exists ( + select 1 + from public.agentic_rectification_evidence later + where later.supersedes_evidence_id = e.id + ) + order by e.created_at asc + limit 1; + + if found then + v_evidence_id := v_existing.id; + v_idempotent := true; + if v_existing.status = 'confirmed' then + v_outcome := 'accepted'; + v_status := 'confirmed'; + v_accepted := v_accepted + 1; + elsif public.agentic_rectification_allows_dateless_confirm(v_kind) + and (v_precision = 'unknown' or v_from is null) then + update public.agentic_rectification_evidence + set status = 'confirmed', + confirmed_at = coalesce(confirmed_at, pg_catalog.now()), + updated_at = pg_catalog.now() + where id = v_existing.id + and status is distinct from 'confirmed'; + v_outcome := 'accepted'; + v_status := 'confirmed'; + v_accepted := v_accepted + 1; + else + v_outcome := 'needs_clarification'; + v_status := v_existing.status; + v_clarification := case + when v_existing.date_precision = 'unknown' then '["date"]'::jsonb + else '[]'::jsonb + end; + v_needs := v_needs + 1; + end if; + else + if (v_precision = 'unknown' or v_from is null) + and not public.agentic_rectification_allows_dateless_confirm(v_kind) then + v_outcome := 'needs_clarification'; + v_status := 'draft'; + v_clarification := '["date"]'::jsonb; + v_needs := v_needs + 1; + else + v_outcome := 'accepted'; + v_status := 'confirmed'; + v_accepted := v_accepted + 1; + end if; + + insert into public.agentic_rectification_evidence ( + case_id, source_turn_id, user_quote, subject, event_kind, domain, + occurred_from, occurred_to, date_precision, summary, status, + confirmed_at, idempotency_key + ) values ( + p_case_id, p_source_turn_id, v_quote, v_subject, v_kind, v_domain, + v_from, v_to, v_precision, v_summary, v_status, + case when v_status = 'confirmed' then pg_catalog.now() else null end, + v_item_key + ) returning id into v_evidence_id; + end if; + end if; + end if; + + if p_focus_id is not null + and v_outcome = 'accepted' + and v_evidence_id is not null + and (v_focus.target_domain is null or v_domain = v_focus.target_domain) + and (v_focus.target_kind is null or v_kind = v_focus.target_kind) then + v_focus_match_count := v_focus_match_count + 1; + if v_focus_match_id is null then + v_focus_match_id := v_evidence_id; + end if; + end if; + + v_results := v_results || jsonb_build_array(jsonb_build_object( + 'index', v_index - 1, + 'idempotency_key', nullif(v_item_key, ''), + 'outcome', v_outcome, + 'evidence_id', v_evidence_id, + 'status', v_status, + 'idempotent', v_idempotent, + 'clarification_fields', v_clarification, + 'error_code', v_error + )); + end loop; + + update public.agentic_rectification_cases + set status = case when status = 'draft' and v_accepted > 0 then 'collecting_evidence' else status end, + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = p_case_id; + + if p_focus_id is not null then + if v_focus_replay then + if v_focus_match_count <> 1 + or v_focus_match_id is distinct from v_focus.target_evidence_id then + raise exception 'agentic_rectification_focus_idempotency_conflict' using errcode = 'P0001'; + end if; + v_focus_resolution := 'idempotent'; + elsif v_focus_match_count = 1 then + perform public.resolve_agentic_rectification_conversation_focus( + p_user_id, p_case_id, p_focus_id, 'resolved', v_focus_match_id + ); + v_focus_resolution := 'resolved'; + elsif v_focus_match_count > 1 then + v_focus_resolution := 'ambiguous'; + else + v_focus_resolution := 'unmatched'; + end if; + end if; + + return jsonb_build_object( + 'items', v_results, + 'accepted_count', v_accepted, + 'needs_clarification_count', v_needs, + 'rejected_count', v_rejected, + 'focus_id', p_focus_id, + 'focus_evidence_id', v_focus_match_id, + 'focus_resolution', v_focus_resolution + ); +end; +$$; + +revoke all on function public.record_agentic_rectification_evidence_batch( + uuid, uuid, uuid, uuid, jsonb +) from public, anon, authenticated; +grant execute on function public.record_agentic_rectification_evidence_batch( + uuid, uuid, uuid, uuid, jsonb +) to service_role; + +commit; diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 69fb7ab3..5f7c2883 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -11,6 +11,7 @@ import { parseAgentChoiceCopy, parseChoiceKeyFromUserMessage, parseRectificationChoiceCard, + serverOwnedChoiceCopy, } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts"; @@ -74,6 +75,22 @@ test("choice frames ask one biographical event from a server probe, not competin assert.equal(mergeChoiceCard(frame, null), null); }); +test("server-owned card names the event family, not a generic 有没有这件事", () => { + const frame = buildChoiceFrame( + { + method_id: "d4_home", + ask_theme: "home_change", + domain: "relocation", + user_prompt_hint: "unused", + }, + { probes: [MOVE_PROBE] }, + ); + const copy = serverOwnedChoiceCopy(frame); + assert.ok(copy); + assert.match(copy.prompt, /搬家、离乡或长期异地/); + assert.doesNotMatch(copy.prompt, /有没有这件事/); +}); + test("the visible card uses Agent copy for A/B/C/D, not the server hint", () => { const frame = buildChoiceFrame({ method_id: "d4_home", @@ -580,6 +597,8 @@ test("GET choice_card stays after coverage when remaining minutes still split on propose_allowed: true, window_scan: { scanned: true, + d9_lagna_count: 1, + d10_lagna_count: 3, d10_candidates_differ: true, d10_sign_names: ["巨蟹座", "狮子座", "处女座"], d24_candidates_differ: true, diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index 97e077af..15a5ab38 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + askedKeysFromLedgerEvidence, buildCandidateContrastPacket, selectDiscriminatorProbe, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; @@ -111,7 +112,7 @@ test("missing method coverage stays in fact collection even if scores look separ assert.equal(next.type, "ask_fact_collection"); }); -test("D9/D10 sign differences synthesize a contrast probe when engine probes are empty", () => { +test("whole-window D9/D10 signs do not synthesize a discriminator without remaining-minute splits", () => { const packet = buildCandidateContrastPacket({ candidateSetVersion: "set-a", calculationResultId: "22222222-2222-4222-8222-222222222222", @@ -120,11 +121,56 @@ test("D9/D10 sign differences synthesize a contrast probe when engine probes are { layer: "d10", signs: ["巨蟹", "狮子", "处女"] }, ], }); + assert.equal(selectDiscriminatorProbe(packet), null); +}); + +test("exam quality and occupation notes skip remaining D24/D10 and keep D4", () => { + const evidence = [ + { domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" }, + { domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员 / 前端", status: "confirmed" }, + ]; + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + calculationResultId: "22222222-2222-4222-8222-222222222222", + candidateTimes: ["05:00", "05:03", "05:04"], + transitions: [ + { layer: "d4", at: "05:00" }, + { layer: "d4", at: "05:03" }, + { layer: "d10", at: "05:00" }, + { layer: "d10", at: "05:03" }, + { layer: "d24", at: "05:00" }, + { layer: "d24", at: "05:03" }, + { layer: "d9", at: "04:52" }, + { layer: "d9", at: "05:08" }, + { layer: "d5", at: "05:15" }, + ], + askedKeys: askedKeysFromLedgerEvidence(evidence), + }); const probe = selectDiscriminatorProbe(packet); assert.ok(probe); - assert.ok(probe.expectedOutcomes.length >= 2); - assert.equal(probe.sourceFeatures[0]?.calculationResultId, "22222222-2222-4222-8222-222222222222"); - assert.match(probe.question, /职业前事|事业盘/); + assert.match(probe.semanticKey, /varga\.d4/); + assert.equal(probe.domain, "relocation"); + assert.match(probe.question, /搬家|离乡/); +}); + +test("encoded D24/D10/D4 remaining splits leave no discriminator", () => { + const evidence = [ + { domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" }, + { domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员", status: "confirmed" }, + { domain: "relocation", eventKind: "home_change", summary: "搬家离乡", status: "confirmed" }, + ]; + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:03", "05:04"], + transitions: [ + { layer: "d4", at: "05:00" }, + { layer: "d4", at: "05:03" }, + { layer: "d10", at: "05:00" }, + { layer: "d24", at: "05:00" }, + ], + askedKeys: askedKeysFromLedgerEvidence(evidence), + }); + assert.equal(selectDiscriminatorProbe(packet), null); }); test("remaining-candidate D24 split beats window D10 signs", () => { diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 70c27c54..3be76df0 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -3,6 +3,10 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, latestUserStoppedCollecting } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + askedKeysFromLedgerEvidence, + buildCandidateContrastPacket, +} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { internalObservationsFromWindowScan, parseWindowScan, @@ -814,7 +818,7 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY); }); -test("lagna_frame after classic coverage still discriminates when candidates are tied", () => { +test("lagna_frame after classic coverage does not keep a tie in discrimination without a remaining split", () => { const plan = buildMethodFollowupPlan({ evidence: CLASSIC_COVERAGE, precisionStage: "lagna_frame", @@ -829,12 +833,13 @@ test("lagna_frame after classic coverage still discriminates when candidates are confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, + discriminatorProbe: null, candidateScores: [ { time: "05:00", score: 34 }, { time: "05:01", score: 33 }, { time: "05:02", score: 33 }, ], - }), "discriminate_candidates"); + }), "provisional_range"); }); test("precision stage d4 asks home change not family, and d5 asks education", () => { @@ -1188,6 +1193,181 @@ test("answered duty language skips window D10 and uses remaining D24", () => { assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/); }); +const DUMP_COVERAGE = [ + { + status: "confirmed" as const, + domain: "education", + datePrecision: "month" as const, + occurredFrom: "2016-09-01", + occurredTo: null, + eventKind: "education_start", + summary: "上大学", + }, + { + status: "confirmed" as const, + domain: "education", + datePrecision: "month" as const, + occurredFrom: "2015-06-01", + occurredTo: null, + eventKind: "education_interruption", + summary: "高考失利复读", + }, + { + status: "confirmed" as const, + domain: "relationship", + datePrecision: "day" as const, + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + summary: "开始一段感情", + }, + { + status: "confirmed" as const, + domain: "relationship", + datePrecision: "day" as const, + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + summary: "感情结束", + }, + { + status: "confirmed" as const, + domain: "career", + datePrecision: "day" as const, + occurredFrom: "2024-04-07", + occurredTo: null, + eventKind: "career_entry", + summary: "入职", + }, + { + status: "confirmed" as const, + domain: "family", + datePrecision: "month" as const, + occurredFrom: "2016-05-01", + occurredTo: null, + eventKind: "family_event", + summary: "家人变化", + }, + { + status: "confirmed" as const, + domain: "occupation", + datePrecision: "unknown" as const, + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + summary: "互联网程序员 / 前端 / Agent 开发", + }, +]; + +const DUMP_TRANSITIONS = [ + { layer: "d4", at: "05:00" }, + { layer: "d4", at: "05:03" }, + { layer: "d10", at: "05:00" }, + { layer: "d10", at: "05:03" }, + { layer: "d24", at: "05:00" }, + { layer: "d24", at: "05:03" }, + { layer: "d9", at: "04:52" }, + { layer: "d9", at: "05:08" }, + { layer: "d5", at: "05:15" }, +]; + +const DUMP_SCORES = [ + { time: "05:00", score: 34 }, + { time: "05:03", score: 33 }, + { time: "05:04", score: 33 }, +]; + +test("coverage-complete tie with encoded D24/D10 asks remaining D4, not enrollment or breakup", () => { + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: DUMP_SCORES.map((item) => item.time), + transitions: DUMP_TRANSITIONS, + askedKeys: askedKeysFromLedgerEvidence(DUMP_COVERAGE), + }); + const plan = buildMethodFollowupPlan({ + evidence: DUMP_COVERAGE, + contrastPacket: packet, + askedProbeKeys: askedKeysFromLedgerEvidence(DUMP_COVERAGE), + }); + assert.equal(plan.next_followup?.domain, "relocation"); + assert.equal(plan.next_followup?.kind_hint, "home_change"); + assert.match(plan.next_followup?.semantic_key ?? "", /varga\.d4/); + assert.doesNotMatch(plan.next_followup?.kind_hint ?? "", /education_start|relationship_end/); + assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /大学哪年入学|高考是 \d{4}|哪年毕业/); + assert.equal(conversationalSessionOutcome({ + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: false, + nextFollowup: plan.next_followup, + methods: plan.methods, + discriminatorProbe: packet.probes[0] ?? null, + candidateScores: DUMP_SCORES, + }), "discriminate_candidates"); +}); + +test("coverage-complete tie with no remaining split offers a provisional range", () => { + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: DUMP_SCORES.map((item) => item.time), + transitions: DUMP_TRANSITIONS, + askedKeys: askedKeysFromLedgerEvidence([ + ...DUMP_COVERAGE, + { + status: "confirmed", + domain: "relocation", + eventKind: "home_change", + summary: "搬家离乡", + }, + ]), + }); + const plan = buildMethodFollowupPlan({ + evidence: [ + ...DUMP_COVERAGE, + { + status: "confirmed" as const, + domain: "relocation", + datePrecision: "year" as const, + occurredFrom: "2016-09-01", + occurredTo: null, + eventKind: "home_change", + summary: "搬家离乡", + }, + ], + contrastPacket: packet, + sessionOutcome: "provisional_range", + }); + assert.equal(plan.next_followup, null); + assert.equal(conversationalSessionOutcome({ + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: false, + nextFollowup: { + method_id: "d9_relationship", + intent: "distinguish_candidates", + ask_theme: "relationship_style", + domain: "relationship", + kind_hint: "relationship_change", + user_prompt_hint: "当前候选在关系主题上仍分不开。", + must_not_label: false, + choice_frame: null, + source: "varga_observation", + }, + methods: plan.methods, + discriminatorProbe: null, + candidateScores: DUMP_SCORES, + }), "provisional_range"); + const action = buildNextUserAction({ + scorableCount: 6, + evidenceCount: 8, + hasLatestResult: true, + selectionAllowed: true, + sessionOutcome: "provisional_range", + nextFollowup: null, + workingTime: "05:00", + }); + assert.equal(action.id, "offer_provisional_range"); +}); + test("没有了 is a user stop", () => { assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有了" }]), true); assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没了" }]), true); @@ -1239,6 +1419,49 @@ test("same domain different year still asks a conflict probe", () => { assert.equal(plan.next_followup?.domain, "career"); }); +test("adjacent education year does not re-ask enrollment after a recorded start", () => { + const plan = buildMethodFollowupPlan({ + evidence: [{ + status: "confirmed", + domain: "education", + eventKind: "education_start", + datePrecision: "month", + occurredFrom: "2016-09-01", + occurredTo: null, + summary: "2016年9月上大学", + }, { + status: "confirmed", + domain: "relationship", + eventKind: "relationship_end", + datePrecision: "day", + occurredFrom: "2024-08-08", + occurredTo: "2024-08-08", + summary: "一段感情结束", + }], + eventProbes: [{ + year: 2015, + year_label: "2015 年前后", + domain: "education", + event_family: "升学、高考、转学或学习环境变化", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "年份锁定 2015 年前后。事件家族:升学、高考、转学或学习环境变化。请写成一句自然语言是/否题。不得改年份。", + role: "reverse_verify", + information_gain: 0.21, + semantic_key: "education.2015.dasha_activation", + }], + }); + assert.notEqual(plan.next_followup?.domain, "education"); + assert.notEqual(plan.next_followup?.source, "event_probe"); + assert.equal(plan.next_followup?.method_id, "d10_career"); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /2016 年入学/); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /2024 年感情结束/); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /不要再问这些事发生在哪一年/); + assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /高考是 2015/); +}); + test("high information_gain leftover probe still blocks offering after coverage", () => { const plan = buildMethodFollowupPlan({ evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"), @@ -1466,7 +1689,7 @@ test("user stop with selection_allowed may offer the escape hatch", async () => ); }); -test("offer-candidates refuses a 34/33/33 tie after method coverage", async () => { +test("offer-candidates allows a 34/33/33 tie after method coverage when remaining minutes do not split", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ @@ -1559,11 +1782,16 @@ test("offer-candidates refuses a 34/33/33 tie after method coverage", async () = turnId: TURN_ID, accounting: accounting.client as never, }); - await assert.rejects( - () => (tools["rectification-offer-candidates"] as unknown as { - execute(input: unknown): Promise; - }).execute({ caseId: CASE_ID }), - (error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed", + const projection = await (tools["rectification-offer-candidates"] as unknown as { + execute(input: unknown): Promise<{ session_outcome: { kind: string } }>; + }).execute({ caseId: CASE_ID }); + assert.equal(projection.session_outcome.kind, "provisional_range"); + assert.equal( + accounting.calls.some((call) => + call.fn === "transition_agentic_rectification_case_status" + && call.args.p_status === "candidate_ready" + ), + true, ); }); diff --git a/frontend/tests/rectification-inference-machine.test.ts b/frontend/tests/rectification-inference-machine.test.ts index 0542b311..0626004a 100644 --- a/frontend/tests/rectification-inference-machine.test.ts +++ b/frontend/tests/rectification-inference-machine.test.ts @@ -70,6 +70,29 @@ function candidates(scores: Readonly>): InferenceCandidat })); } +test("discriminating result does not stay in event_collection", () => { + const state = buildInferenceState({ + range_start: "04:50", + range_end: "05:10", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 34 }, + { id: "05:03", time: "05:03", relative_support: 33 }, + { id: "05:04", time: "05:04", relative_support: 33 }, + ], + events: [ + { id: "e1", domain: "education", year: 2016, precision: "month" }, + { id: "e2", domain: "career", year: 2018, precision: "year" }, + { id: "e3", domain: "relationship", year: 2021, precision: "year" }, + { id: "e4", domain: "family", year: 2023, precision: "year" }, + { id: "e5", domain: "relocation", year: 2017, precision: "year" }, + ], + probes: [probe({ id: "open", gain: 0.3, yesSupports: ["05:00"], yesConflicts: ["05:03"] })], + phase: "event_collection", + }); + assert.equal(state.result_status, "discriminating"); + assert.equal(state.phase, "discrimination"); +}); + test("an informative answer lowers entropy and cannot revive an eliminated candidate", () => { const conflict = probe({ id: "p1", diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index db7dc9c3..5e329172 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -73,8 +73,8 @@ test("system prompt carries only high-priority boundaries, never the method copy assert.match(prompt, /对用户说的话必须自己写在正文里,不要只写规划等服务器代写/); assert.match(prompt, /skill_verification_report/); assert.match(prompt, /ask_candidate_discriminator/); - assert.match(prompt, /方法覆盖已齐只进入候选区分/); - assert.doesNotMatch(prompt, /方法覆盖已齐且 propose_allowed 时本轮 adopt/); + assert.match(prompt, /offer_provisional_range/); + assert.match(prompt, /不要再问整窗 D9\/D24/); assert.match(prompt, /不得询问外貌、体质、胎记或疤痕/); assert.match(prompt, /不要调用 rectification-set-focus/); assert.match(prompt, /open_question\.prompt/); @@ -83,6 +83,8 @@ test("system prompt carries only high-priority boundaries, never the method copy assert.match(prompt, /verify_adopted_time/); assert.match(prompt, /event_probe/); assert.match(prompt, /不得发明年份/); + assert.match(prompt, /不得根据出生年推算高考或入学年份/); + assert.match(prompt, /不要再问那一件发生在哪一年/); assert.doesNotMatch(prompt, /两套盘各自的前事/); assert.doesNotMatch(prompt, /外貌、体质、胎记或疤痕可以问/); assert.doesNotMatch(prompt, /分盘句和宫位表由界面展示/); diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index df47c3d7..badbe089 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -1254,6 +1254,31 @@ test("PR-4 exposes only the explicit V2 service-role RPC signatures", () => { ); }); +test("kind-date evidence dedup stays out of the identity foundation", () => { + const migration = readFileSync( + new URL("../supabase/migrations/20260825010000_rectification_evidence_kind_date_dedup.sql", import.meta.url), + "utf8", + ); + const dbCopy = fileURLToPath( + new URL("../db/migrations/20260825010000_rectification_evidence_kind_date_dedup.sql", import.meta.url), + ); + assert.equal( + existsSync(dbCopy), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); + assert.match(migration, /^begin;[\s\S]*^commit;$/m); + assert.match( + migration, + /Same dated event restated with a new quote must not insert a second row/, + ); + assert.match( + migration, + /e\.event_kind = v_kind[\s\S]*e\.occurred_from is not distinct from v_from/, + ); + assert.match(migration, /grant execute on function public\.record_agentic_rectification_evidence_batch/); +}); + test("turn origin migration stays out of the identity foundation", () => { const turnOriginMigration = readFileSync( new URL("../supabase/migrations/20260824030000_rectification_turn_origin.sql", import.meta.url), diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 32117596..2f122222 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -104,6 +104,11 @@ DOMAIN_CATALOG: dict[str, dict[str, Any]] = { }, } +# 入学年与高考通常同一学年或前后一年;存在性探针跳过邻近年,质量探针仍按精确年。 +EXISTENCE_NEARBY_YEARS = { + "education": 1, +} + def _clock(value: str) -> int: return int(value[:2]) * 60 + int(value[3:5]) @@ -505,6 +510,16 @@ def _event_years(events: Sequence[dict[str, Any]], domain: str) -> set[int]: return years +def _existence_blocked_years(domain: str, known_years: set[int]) -> set[int]: + blocked = set(known_years) + span = int(EXISTENCE_NEARBY_YEARS.get(domain, 0)) + if span <= 0: + return blocked + for year in known_years: + blocked.update(range(year - span, year + span + 1)) + return blocked + + def _quality_probes( events: Sequence[dict[str, Any]], domains: Sequence[str], @@ -644,10 +659,11 @@ def discriminating_event_probes( if domain not in DOMAIN_CATALOG: continue known_years = _event_years(events, domain) + blocked_years = _existence_blocked_years(domain, known_years) boundary = sorted((vim_years | narayana_years) & set(range(lo, hi + 1))) found = None for year in boundary: - if year in known_years: + if year in blocked_years: continue found = _evaluate_year( left, right, birth_date=birth_date, domain=domain, year=year, source="dasha_boundary", @@ -656,7 +672,7 @@ def discriminating_event_probes( break if found is None: midpoint = _age_band_year(birth_year, domain, now) - if midpoint is not None and midpoint not in known_years: + if midpoint is not None and midpoint not in blocked_years: found = _evaluate_year( left, right, birth_date=birth_date, domain=domain, year=midpoint, source="dasha_activation", ) @@ -675,7 +691,7 @@ def discriminating_event_probes( continue known_years = _event_years(events, domain) year = _age_band_year(birth_year, domain, now) - if year is None or year in known_years: + if year is None or year in _existence_blocked_years(domain, known_years): continue probes.append(_public_probe( year=year, diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 797ecb0b..9bc9b94f 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -288,6 +288,38 @@ class EventProbesTest(unittest.TestCase): self.assertTrue(any(item["source"] in {"dasha_activation", "dasha_boundary"} for item in probes)) self.assertTrue(any(item["domain"] == "relocation" for item in probes)) + def test_enrollment_skips_adjacent_education_existence_year(self) -> None: + built = { + "static_contexts": [ + {"feature": {"time": "05:13", "varga_ascendants": {"D4": 1, "D9": 1, "D10": 1, "D5": 1}}}, + {"feature": {"time": "05:14", "varga_ascendants": {"D4": 2, "D9": 1, "D10": 1, "D5": 2}}}, + ] + } + probes = discriminating_event_probes( + _request( + birth_date="1998-08-08", + events=[{ + "id": "00000000-0000-4000-8000-000000000001", + "domain": "education", + "summary": "2016年9月进入大学", + "date": "2016-09-01", + "precision": "month", + }], + ), + built, + scan=window_scan(built), + candidate_times=["05:13", "05:14"], + representative_time="05:13", + precision_current="d5_refine", + today=date(2026, 8, 22), + ) + existence = [ + item for item in probes + if item["domain"] == "education" and item["source"] != "known_event_quality" + ] + self.assertFalse(any(item["year"] in {2015, 2016, 2017} for item in existence)) + self.assertFalse(any("高考是 2015" in str(item.get("user_meaning") or "") for item in probes)) + def test_representatives_prefer_remaining_candidate_times(self) -> None: built = { "static_contexts": [