diff --git a/CHANGELOG.md b/CHANGELOG.md index bb32961c..b214e5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 正文念出的判别题必有卡;性格描述不再自相矛盾 + +感情判别题下面会有可点的 A/B/C/D,不再只剩一句题干。这类题按关系盘自己的分组计分,不会拿学业盘的对照题来打分。2026-09-16 前如果分盘风格题被盖上了别的盘的探针,当时记下的答案可能计错分,不要采用那个区间,补一件带年月的事或新开一段即可。交付卡每一列性格只留一句,卡头共同点不会把一对相反的话写在一起。Skill 版本不变。 + ## 2026-09-16 — 年月阶段改打字回答,不再出录入卡,也不再追问是不是本人 答「有」之后只剩一句「大概哪年几月?」,在输入框打字即可;「哪一类事 + 发生年月」那张卡删掉了。助手不得把这句换成「是你本人做的吗」,也不得因为你回了一句短句就把本轮烧到步骤上限。独立问题块和助手正文对齐。Skill 升到 10.0.29。旧的校正会话仍按当时绑定的版本打开。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 44cee401..5bf6903a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5894,8 +5894,8 @@ - 根因:(1) `stampChoiceSchemaWithProbe` 用引擎最高增益探针盖 schema,把对比探针换成已答教育题;`event_probe` 缺 `probe_id` 时被当成已答,不建焦点。GET 投影不传 `contrastPacket` / `userStopped`。(2) 对比探针按整窗 D10 星座建题,`supportsCandidateIds` 是星座名不是分钟;`askedKeys` 不含自然语言答过的职责倾向。(3) 质量探针先占领域,挡住 dasha 年界;代表对取整窗第一次换升。`answersFromEvidence` 把同年入学当成考试失常。(4) 停问词匹配不到「没有了」。 - 修复:对比探针用自己的 `semantic_key` 盖戳,缺引擎 `probe_id` 时仍建焦点;GET 与工具侧同一套 plan 输入。剩余候选按 D24/D10 分钟切开;职责倾向记入 `askedKeys`。质量探针不得挡住 dasha;代表对取当前候选集。入学不再自动回答质量探针。停问词加上「没有了」等,停问且可出牌时出并列区间。不把 `occupation_note` 改成主评分事件,不打开 `confirmation_allowed`,不改 Skill `10.0.11`。 - 验证:`rectification-spoken-answer`、`rectification-server-focus`、`rectification-choice-card`、`rectification-decide-next-action`、`rectification-eight-method`、`rectification-inference-machine`、`tests/test_rectification_event_probes.py`。 -- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。 -- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374 +- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。2026-09-16 复发(BUG-912):无探针的分盘风格题借最高增益探针。 +- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374、BUG-912 - 复发自:BUG-348 / BUG-366(区分卡与覆盖≠收敛已写过,焦点持久化和剩余候选切开未接到这条会话) - 修复版本:d7afe5b50de488d98a2ca666763784158e7c3be2 @@ -11843,4 +11843,52 @@ - 复发自:无 - 修复版本:待发布 +## BUG-912 | D9 感情判别题借 D24 对照探针:错分 + 无卡 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`stampChoiceSchemaWithProbe`、`varga-distinguish-probe.ts`、`projectRectificationChoiceCard`、`persistNextInterviewIfIdle` +- 用户现象:正文念了感情判别题,下面没有 A/B/C/D;打字答「没有」后区间被收窄。 +- 触发条件:D9 风格 followup 没有自己的 `semantic_key`,inference_state 里另有高增益 D24 对照探针。 +- 根因:`stampChoiceSchemaWithProbe` 在无 preferred key 时退到 `selectHighestGainProbe`。BUG-375 只堵住了对比探针被已答教育题盖掉,没堵住「无探针的分盘风格题借最高增益探针」。 +- 修复:不再退到最高增益探针。D9/D10 等风格题按剩余候选的分盘上升自建探针。GET 从落库副本投影;过期焦点 `superseded` 后落下一问。 +- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`;BUG-375 原断言仍绿。 +- 防复发:无 `semantic_key` 的计分题不得盖上别的分盘探针。宁可不落库(走 BUG-674)也不错分。 +- 相关记录:BUG-375、BUG-578、BUG-582、BUG-674 +- 复发自:BUG-375 +- 修复版本:待发布 + +## BUG-913 | 答「没有」后平局直接出交付卡 + +- 状态:closed_by_design +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`yearlessPersonalityCanAsk`、`shouldHoldForTieBreak`、`tieBreakPersonalityFollowup` +- 用户现象:感情判别题打字答「没有」后出现三列交付卡。 +- 触发条件:已有多件带年月经历、候选并列、采集进度 `missing:0`。 +- 根因:回放。`yearlessPersonalityCanAsk` 在 `datedCount > 0` 时为假;平局参考题只认 `varga.d9` / `varga.d10` 性格探针。线问完后的并列出卡是 BUG-689 设计出口,不是 BUG-751 / BUG-688 复发。 +- 修复:不改出卡门槛。 +- 验证:`yearlessPersonalityCanAsk({ datedCount: 5 }) === false`。 +- 防复发:有带年月经历时不得把平局交付改成强制性格题。 +- 相关记录:BUG-689、BUG-688、BUG-751、BUG-912 +- 复发自:无 +- 修复版本:待发布 + +## BUG-914 | 交付卡把一对相反性格都列成共同点 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`scripts/rectification/refinement_packet.py` `NAKSHATRA_TRAITS` 选项、`buildRangeDelivery` `shared_traits` +- 用户现象:三列卡头同时出现「希望两边都能说得过去」和「必要时会直接选边」。 +- 触发条件:升点靠近月宿交界,代表分钟是最晚一列,三列都拿 earlier 那一对。 +- 根因:每个 option 把相反两极整对塞进 `traits`;`splitSharedTraits` 把两句都判成共同点。 +- 修复:每个 option 只取第一极;`user_meaning` 写成「A:{earlier[0]}。B:{later[0]}。」 +- 验证:Python `test_rectification_refinement_packet` 断言 `traits` 长度为 1 且两 option 不同句;前端断言两极不得同时出现在 `shared_traits`。 +- 防复发:一列只写一句,不得自相矛盾。 +- 相关记录:BUG-912 +- 复发自:无 +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-rectification-dead-d9-choice-20260916.md b/docs/tasks/PROGRESS-rectification-dead-d9-choice-20260916.md new file mode 100644 index 00000000..3a71f2a8 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-dead-d9-choice-20260916.md @@ -0,0 +1,28 @@ +# 进度 · D9 判别题借探针 + 性格两极(2026-09-16) + +基线:`origin/staging` @ `daacaab9`(任务书含 BUG-375 复发定性)。 +分支:`codex/rectification-dead-d9-choice-20260916`。 +BUG:912~914。Skill 未 bump。 + +## 做了什么 + +- **T1 / BUG-912**:`stampChoiceSchemaWithProbe` 不再退到最高增益探针。`varga-distinguish-probe.ts` 按剩余候选的分盘上升给 D9/D10 等风格题自建 `semantic_key = method:ask_theme`。GET `persistedDistinguishCard` 从落库副本投影。过期焦点在 idle persist 里 `superseded` 后落下一问。 +- **T2 / BUG-913**:回放为 **closed_by_design**。已有带年月经历时 `yearlessPersonalityCanAsk` 为假;平局参考题只认 `varga.d9` / `varga.d10`。线问完后的并列出卡是 BUG-689 出口,不是 BUG-751 / BUG-688 复发。未改门槛。 +- **T3 / BUG-914**:`NAKSHATRA_TRAITS` 每个 option 只取第一极。 +- **T4**:BUG_HISTORY / CHANGELOG / 真机清单 / 本进度。CHANGELOG 写明 2026-09-16 前分盘风格题答案可能计错分。 + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| 改动文件 eslint | **0 error** / 5 warning(既有 unused) | +| `rectification-dead-d9-choice-20260916` + BUG-375 相关套件 | **62 passed / 0 fail** | +| `pytest tests/test_rectification_refinement_packet.py tests/test_rectification_event_probes.py` | 全绿 | +| Python 快速门 | 本机缺 `mcp` 模块,compile 段之后失败。定向 pytest 已绿。 | +| `next build` | worktree `node_modules` junction,与上一单相同环境缺口,留给 Gitea。 | + +## 环境缺口 + +- 事发 Case 的 04:48–04:59 建立在错分上,不要采用;部署后补一件带年月的事或新开 Case。 +- 真机:`docs/testing/rectification-dead-d9-choice-20260916.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 11de66b9..269cbb82 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -102,7 +102,7 @@ | `TASK-rectification-exhausted-gate-exit-20260910.md` | `PROGRESS-rectification-exhausted-gate-exit-20260910.md` | 五领域全拒答 + 三件带月经历:门槛句后无载体、客户端误报「没有拿到下一个问题」并循环「接着问」(BUG-646);第三件被留作 holdout 导致永远没有选择题(BUG-647)。含产品建议:一次性开放追问 + 终态标记 + 穷尽后释放 holdout | 已被取代(并入采集重设计单) | — | | `TASK-rectification-collection-redesign-20260910.md` | `PROGRESS-rectification-collection-redesign-20260910.md` | 产品口径:用户先说、系统从已说的事锚定追问、不得用生日推年份、不得说「领域」、永远交付结果。无固定题数:收集问题池按信息价值排序问到训练门开;选择题问到收敛门开或增益见底;门关不出结果只出精确缺口句并保持开放;≥4 件才留 holdout;撤回 BUG-642 年份线索、BUG-646 单终态与「暂定采用」;Skill 10.0.23(BUG-646~648) | 待验收 | `dd8f35f7` | | `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84)**。主体默认本人不追问,Skill 10.0.29 | 待验收 | `codex/rectification-year-focus-overlay-20260916` | -| `TASK-rectification-dead-d9-choice-20260916.md` | `PROGRESS-rectification-dead-d9-choice-20260916.md` | 真机(6aabbe38):删卡流程已通,但**P0** D9 感情题没有自己的探针,`stampChoiceSchemaWithProbe` 退到最高增益探针,盖上了 **D24 学业六星座对照探针**(BUG-375 (1) 复发):答「没有」按 D24 计分,04:48–04:59 收窄不可信;GET 投影又匹配不上 → 无卡(BUG-912);打字答「没有」后平局直接出交付卡待回放定性(BUG-913);`NAKSHATRA_TRAITS` 整对塞进一个 option,卡头把相反两极列成共同点(BUG-914)。**串行在 year-focus-overlay 验收之后** | 待领取 | — | +| `TASK-rectification-dead-d9-choice-20260916.md` | `PROGRESS-rectification-dead-d9-choice-20260916.md` | 真机(6aabbe38):删卡流程已通,但**P0** D9 感情题没有自己的探针,`stampChoiceSchemaWithProbe` 退到最高增益探针,盖上了 **D24 学业六星座对照探针**(BUG-375 (1) 复发):答「没有」按 D24 计分,04:48–04:59 收窄不可信;GET 投影又匹配不上 → 无卡(BUG-912);打字答「没有」后平局直接出交付卡待回放定性(BUG-913);`NAKSHATRA_TRAITS` 整对塞进一个 option,卡头把相反两极列成共同点(BUG-914)。**串行在 year-focus-overlay 验收之后** | 待验收 | `codex/rectification-dead-d9-choice-20260916` | ### 聊天主链路与首页 diff --git a/docs/testing/rectification-dead-d9-choice-20260916.md b/docs/testing/rectification-dead-d9-choice-20260916.md new file mode 100644 index 00000000..076d44d0 --- /dev/null +++ b/docs/testing/rectification-dead-d9-choice-20260916.md @@ -0,0 +1,8 @@ +# 真机清单 · D9 判别题有卡、性格不自相矛盾(2026-09-16) + +在 staging 已登录账号上走。不要用真实生产用户。事发那段 04:48–04:59 **不要采用**——它建立在错分上;补一件带年月的事或新开一段。 + +1. 开一段新的生时校正,走到能出现「有没有…」判别题(感情/事业一类)。 +2. 题干下面必须有 A/B/C/D,不能只剩一句题干和「没有拿到下一个问题」。 +3. 点一个选项或打字回答后,助手只承接本题,不把学业盘的对照拿来打感情题的分。 +4. 若出三列交付卡:每一列性格只有一句;卡头共同点里不能同时出现一对相反的话(例如「希望两边都能说得过去」和「必要时会直接选边」)。 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index cadcb0a7..d4868cd4 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -37,6 +37,8 @@ import { previousInferenceFromReceipt, withNakshatraBoundaryProbe, } from "./inference-adapter"; +import { liveDistinguishProbe } from "./varga-distinguish-probe"; +import { isSameCandidateSplit } from "../core/candidate-contrast-packet.ts"; import { datedEventCount } from "./divergence-panel.ts"; import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts"; import { @@ -1602,17 +1604,43 @@ export async function persistNextInterviewIfIdle(input: { }; const staleFocus = dossier.conversationSummary.activeFocus; const staleFocusId = staleFocus?.id; + const inferenceForStale = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const distinguishSchema = staleFocus?.expectedAnswerSchema + && typeof staleFocus.expectedAnswerSchema === "object" + && !Array.isArray(staleFocus.expectedAnswerSchema) + ? staleFocus.expectedAnswerSchema as Record + : null; + const liveDistinguish = liveDistinguishProbe(inferenceForStale, distinguishSchema); + const distinguishSplit = typeof distinguishSchema?.candidate_split_hash === "string" + ? distinguishSchema.candidate_split_hash + : ""; + const staleDistinguish = Boolean( + staleFocus?.intent === "distinguish_candidates" + && distinguishSchema + && ( + !liveDistinguish + || ( + distinguishSplit + && !isSameCandidateSplit( + distinguishSplit, + liveDistinguish.candidate_split_hash, + liveDistinguish.semantic_key, + ) + ) + ), + ); if ( isPersistedFocusId(staleFocusId) && ( isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus) || isOrphanOtherCollectFocus(staleFocus, dossier.evidence) + || staleDistinguish ) ) { try { await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, { focusId: staleFocusId, - status: "skipped", + status: staleDistinguish ? "superseded" : "skipped", }); dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); } catch (error) { @@ -1709,6 +1737,7 @@ export async function persistNextInterviewIfIdle(input: { } } } + const planInference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, activeFocus: null, @@ -1719,6 +1748,12 @@ export async function persistNextInterviewIfIdle(input: { birthDate, accepted: Boolean(dossier.case.acceptedTime), holdoutValidation: decision.holdoutValidation, + inferenceState: planInference, + candidateRanges: planInference?.candidates.map((item) => ({ + id: item.id, + time: item.time, + cluster_range: item.cluster_range, + })), ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index e09e3a50..2eacfb85 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -684,6 +684,41 @@ export function choiceCardFromPersistedVerifyCopy(input: { }; } +export function choiceCardFromPersistedDistinguishCopy(input: { + copy: AgentChoiceCopy; + questionId: string; + methodId: string; + scoring: boolean; + probeId: string | null; + caseRevision: number | null; + focusId: string; + choiceKind?: ChoiceCardKind | null; +}): RectificationChoiceCard | null { + if (!input.copy.prompt.trim() || !input.questionId.trim()) return null; + if (!isPersistedFocusId(input.focusId)) return null; + return { + question_id: input.questionId, + method_id: input.methodId, + prompt: input.copy.prompt, + why: "", + varga: null, + choice_mode: CHOICE_MODE, + options: input.copy.options.map((option) => ({ + ...option, + role: "primary" as const, + })), + stop_label: CHOICE_STOP_LABEL, + stop_message: CHOICE_STOP_MESSAGE, + scoring: input.scoring, + probe_id: input.probeId, + case_revision: input.caseRevision, + focus_id: input.focusId, + why_user: "", + answer_impact: emptyAnswerImpact(), + choice_kind: input.choiceKind === "varga_style" ? "varga_style" : "existence", + }; +} + export function choiceCardFromPersistedTargetedCopy(input: { copy: AgentChoiceCopy; questionId: string; diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 86c914d6..8aabd3cd 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -405,6 +405,7 @@ export function rectificationFollowupCatalog( remainingCandidateCount: remainingCandidateCount(activeTimes.length ? activeTimes : topCandidateTimes), remainingCredibleRange: inference?.credible_range ?? null, guidedCollectWindows: refinement.guided_collect_windows as readonly GuidedCollectWindow[], + windowScan, }; } diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index 1eb9527e..e0db9e95 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -550,7 +550,9 @@ export function stampChoiceSchemaWithProbe( scoring, }; } - const next = matched ?? (state ? selectHighestGainProbe(state.probes, state.answered_probes) : null); + // BUG-912 / BUG-375: never stamp an unrelated highest-gain probe onto a + // followup that did not name one. Unstamped scoring questions fail persist. + const next = matched; if (!next) { if (!preferredKey) return { ...schema }; return { diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index 9f24f5a3..a9606ee5 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -108,6 +108,7 @@ export function choiceCardFromCaseDossier(dossier: { })), userStopped: dossier.case.status === "paused", latestAssistantText, + inferenceState: inference, candidatesSeparated: decision.separation.sufficient, holdoutValidation: decision.holdoutValidation, candidateRanges: inference?.candidates.map((item) => ({ diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 4888cd54..abf41bd2 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -78,6 +78,7 @@ import { mergeChoiceCard, choiceCardFromPersistedVerifyCopy, choiceCardFromPersistedTargetedCopy, + choiceCardFromPersistedDistinguishCopy, serverOwnedChoiceCopy, buildTargetedCollectExistenceFrame, TARGETED_COLLECT_KEEP_HINT, @@ -171,7 +172,9 @@ import type { PrecisionStageId, } from "./refinement-packet"; import { EVENT_PROBE_DOMAINS } from "./refinement-packet"; -import type { InternalVargaObservation } from "./varga-observations"; +import type { InternalVargaObservation, WindowScan } from "./varga-observations"; +import { attachVargaDistinguishIdentity, liveDistinguishProbe as liveInferenceDistinguishProbe } from "./varga-distinguish-probe"; +import type { InferenceState } from "../core/types.ts"; import { probeBelowAdultFloor } from "./adult-floor.ts"; import { BLOCK_CHOICE_INTENT, @@ -2180,6 +2183,8 @@ export function buildMethodFollowupPlan(input: { activeFocus?: MethodFollowupFocus | null; declinedTopics?: readonly Readonly>[]; observations?: readonly InternalVargaObservation[]; + windowScan?: WindowScan | null; + inferenceState?: InferenceState | null; sessionOutcome?: SessionOutcomeKind; precisionStage?: PrecisionStageId | null; nakshatraProbe?: ConflictProbe | null; @@ -2214,7 +2219,12 @@ export function buildMethodFollowupPlan(input: { scoring = true, forceChoice?: boolean, ): MethodFollowup => { - const base = { ...item, must_not_label: false as const }; + const distinguished = attachVargaDistinguishIdentity(item, { + windowScan: input.windowScan ?? null, + candidates: input.candidateRanges?.map((row) => ({ id: row.id || row.time, time: row.time })) + ?? (input.topCandidateTimes ?? []).map((time) => ({ id: time, time })), + }); + const base = { ...distinguished, must_not_label: false as const }; const attach = forceChoice ?? shouldAttachChoiceFrame(base, input.evidence); const keyed = Boolean(base.semantic_key) && [ ...(input.eventProbes ?? []), @@ -3306,6 +3316,41 @@ export function projectRectificationChoiceCard( }; const targetedCard = persistedTargetedCard(); if (targetedCard) return targetedCard; + const persistedDistinguishCard = (): RectificationChoiceCard | null => { + if (intent !== "distinguish_candidates" || !schemaCopy || !schemaProbeId) return null; + const row = schema && typeof schema === "object" && !Array.isArray(schema) + ? schema as Record + : null; + const live = liveInferenceDistinguishProbe(input.inferenceState ?? null, row); + if (!live) return null; + const schemaSplit = typeof row?.candidate_split_hash === "string" + ? row.candidate_split_hash + : null; + if ( + schemaSplit + && !isSameCandidateSplit(schemaSplit, live.candidate_split_hash, live.semantic_key) + ) return null; + const parsed = parsePersistedFollowupQuestionId(input.activeFocus?.questionId); + const questionId = input.activeFocus?.questionId?.trim() ?? ""; + const schemaKind = row && typeof row.choice_kind === "string" + ? row.choice_kind + : live.choice_kind; + return choiceCardFromPersistedDistinguishCopy({ + copy: { + ...schemaCopy, + prompt: overlayChoicePromptFromSpoken(schemaCopy.prompt, input.latestAssistantText), + }, + questionId, + methodId: parsed?.method_id ?? live.domain ?? "d9_relationship", + scoring: parsed?.scoring !== false && row?.scoring !== false, + probeId: schemaProbeId, + caseRevision: input.caseRevision ?? null, + focusId, + choiceKind: schemaKind === "varga_style" ? "varga_style" : "existence", + }); + }; + const distinguishCard = persistedDistinguishCard(); + if (distinguishCard) return distinguishCard; const followup = plan.next_followup ?? plan.deferred_followup ?? null; if (!followup) return persistedVerifyCard(); const frame = followup.choice_frame; diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 3e227d8f..e4944dd4 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -10,6 +10,7 @@ import { previousInferenceFromReceipt, withNakshatraBoundaryProbe, } from "./inference-adapter"; +import { withFollowupOwnedProbe } from "./varga-distinguish-probe"; import { spokenFollowupForUser, spokenCollectFallbackFollowup, @@ -132,7 +133,7 @@ export function expectedAnswerSchemaFor( if (!targetedCollect && decisionReceipt?.inference_state !== undefined && !state) return null; const stamped = stampChoiceSchemaWithProbe( schema, - verifyOnly || targetedCollect ? null : state, + verifyOnly || targetedCollect ? null : withFollowupOwnedProbe(state, followup), questionId, { semantic_key: followup.semantic_key, diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index c3f13c79..60d33f3b 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -1159,7 +1159,7 @@ export async function resolveV10ConversationFocus( caseId: string, input: { focusId: string; - status: "resolved" | "declined" | "skipped"; + status: "resolved" | "declined" | "skipped" | "superseded"; evidenceId?: string | null; answerOption?: "A" | "B" | "C" | "D" | "stop" | null; }, diff --git a/frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts b/frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts new file mode 100644 index 00000000..fd82e14a --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts @@ -0,0 +1,235 @@ +/** + * D9/D10 (and sibling varga) distinguish followups must own their probe. + * Borrowing the highest-gain contrast probe (BUG-375 / BUG-912) scores the + * wrong chart and leaves GET without a card. + */ +import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts"; +import { signFromTransitions } from "../core/sign-from-transitions.ts"; +import { completeStyleOptions } from "./probe-question-contract.ts"; +import { d9StyleLabel, d10StyleLabel, signKey } from "./varga-type-tables.ts"; +import type { WindowScan } from "./varga-observations.ts"; + +const METHOD_LAYERS: Readonly> = { + d9_relationship: ["d9"], + d10_career: ["d10"], + d4_home: ["d4"], + d5_education: ["d5"], + relatives: ["d12", "d7"], + d2_finance: ["d11", "d2"], + d30_health: ["d30"], + dasha_events: ["d1"], +}; + +const SCORING_CLASSES = ["yes", "weak_yes", "no"] as const; + +export type VargaDistinguishCandidate = Readonly<{ + id: string; + time: string; +}>; + +export type VargaDistinguishFields = Readonly<{ + semantic_key: string; + candidate_split_hash: string; + candidate_ids: readonly string[]; + expected_outcomes: ConflictProbe["expected_outcomes"]; + style_options: ConflictProbe["style_options"]; + choice_kind: "varga_style" | "existence"; + information_gain: number; +}>; + +type DistinguishFollowup = { + method_id: string; + ask_theme: string; + domain: string | null; + intent: string; + source: string; + semantic_key?: string; + user_prompt_hint?: string; + candidate_split_hash?: string; + candidate_ids?: readonly string[]; + expected_outcomes?: readonly Readonly<{ + answer_class: string; + supports: readonly string[]; + conflicts: readonly string[]; + }>[]; + style_options?: readonly Readonly<{ + label: string; + answer_class: string; + sign?: string; + }>[]; + choice_kind?: string; + information_gain?: number; +}; + +function asAnswerClass(value: string): AnswerClass | null { + if (value === "yes" || value === "weak_yes" || value === "no" || value === "unsure") return value; + return null; +} + +function clock(value: string): string { + return value.trim().slice(0, 5); +} + +function labelFor(layer: string, sign: string): string { + if (layer === "d10") return d10StyleLabel(sign); + if (layer === "d9") return d9StyleLabel(sign); + const keyed = signKey(sign); + return keyed ? `${keyed}这段` : "这段"; +} + +function choiceKindFor(layer: string): "varga_style" | "existence" { + return layer === "d9" || layer === "d10" ? "varga_style" : "existence"; +} + +function groupsForLayer( + candidates: readonly VargaDistinguishCandidate[], + transitions: WindowScan["transitions"], + layer: string, +): { sign: string; ids: string[] }[] { + const bySign = new Map(); + for (const candidate of candidates) { + const time = clock(candidate.time); + if (!time) continue; + const raw = signFromTransitions(transitions, layer, time); + const sign = raw ? signKey(raw) : ""; + if (!sign) continue; + const ids = bySign.get(sign) ?? []; + ids.push(candidate.id || time); + bySign.set(sign, ids); + } + return [...bySign.entries()] + .map(([sign, ids]) => ({ sign, ids: [...new Set(ids)].sort() })) + .filter((row) => row.ids.length > 0) + .sort((left, right) => left.ids[0]!.localeCompare(right.ids[0]!)); +} + +export function buildVargaDistinguishFields(input: { + methodId: string; + askTheme: string; + transitions: WindowScan["transitions"] | undefined; + candidates: readonly VargaDistinguishCandidate[]; +}): VargaDistinguishFields | null { + const layers = METHOD_LAYERS[input.methodId]; + if (!layers || input.candidates.length < 2) return null; + const transitions = input.transitions ?? []; + let groups: { sign: string; ids: string[] }[] = []; + let layer = layers[0] ?? ""; + for (const candidate of layers) { + const next = groupsForLayer(input.candidates, transitions, candidate); + if (next.length >= 2) { + groups = next; + layer = candidate; + break; + } + } + if (groups.length < 2) return null; + const kind = choiceKindFor(layer); + const scoring = groups.slice(0, 3); + const incoming = scoring.map((group, index) => ({ + label: labelFor(layer, group.sign), + answer_class: SCORING_CLASSES[index] ?? "unsure", + sign: group.sign, + })); + const completed = completeStyleOptions({ choiceKind: kind, styleOptions: incoming }); + if (!completed.ok) return null; + const allIds = [...new Set(groups.flatMap((row) => row.ids))].sort(); + const expected = completed.options.map((option) => { + const match = scoring.find((_, index) => SCORING_CLASSES[index] === option.answer_class); + const supports = match?.ids ?? []; + return { + answer_class: option.answer_class as AnswerClass, + supports, + conflicts: allIds.filter((id) => !supports.includes(id)), + }; + }); + const semanticKey = `${input.methodId}:${input.askTheme}`; + const split = scoring.map((row) => row.ids.join(",")).join("|"); + return { + semantic_key: semanticKey, + candidate_split_hash: `${semanticKey}:${split}`, + candidate_ids: allIds, + expected_outcomes: expected, + style_options: completed.options, + choice_kind: kind, + information_gain: 0.4, + }; +} + +export function attachVargaDistinguishIdentity( + item: T, + input: { + windowScan?: WindowScan | null; + candidates?: readonly VargaDistinguishCandidate[]; + }, +): T { + const row = item as T & DistinguishFollowup; + if (row.semantic_key) return item; + if (row.intent !== "distinguish_candidates") return item; + if (row.source !== "varga_observation" && row.source !== "precision_stage") return item; + const fields = buildVargaDistinguishFields({ + methodId: row.method_id, + askTheme: row.ask_theme, + transitions: input.windowScan?.transitions, + candidates: input.candidates ?? [], + }); + if (!fields) return item; + return { ...item, ...fields }; +} + +export function conflictProbeFromFollowup(followup: DistinguishFollowup): ConflictProbe | null { + const semanticKey = followup.semantic_key?.trim() ?? ""; + const outcomes = (followup.expected_outcomes ?? []).flatMap((row) => { + const answer = asAnswerClass(row.answer_class); + if (!answer) return []; + return [{ answer_class: answer, supports: row.supports, conflicts: row.conflicts }]; + }); + const styles = (followup.style_options ?? []).flatMap((row) => { + const answer = asAnswerClass(row.answer_class); + if (!answer || !row.label.trim()) return []; + return [{ label: row.label, answer_class: answer, ...(row.sign ? { sign: row.sign } : {}) }]; + }); + if (!semanticKey || outcomes.length === 0 || styles.length === 0) return null; + const ids = followup.candidate_ids ?? [...new Set(outcomes.flatMap((row) => [...row.supports, ...row.conflicts]))]; + if (ids.length < 2) return null; + return { + id: `probe:${semanticKey}`, + semantic_key: semanticKey, + candidate_split_hash: followup.candidate_split_hash?.trim() || semanticKey, + domain: followup.domain ?? "relationship", + year: 0, + question: followup.user_prompt_hint?.trim() || semanticKey, + candidate_ids: ids, + expected_outcomes: outcomes, + information_gain: followup.information_gain ?? 0.4, + source: followup.source || "varga_observation", + choice_kind: followup.choice_kind === "existence" ? "existence" : "varga_style", + style_options: styles, + }; +} + +export function withFollowupOwnedProbe( + state: InferenceState | null, + followup: DistinguishFollowup | null | undefined, +): InferenceState | null { + if (!state || !followup) return state; + const probe = conflictProbeFromFollowup(followup); + if (!probe) return state; + if (state.probes.some((item) => item.id === probe.id || item.semantic_key === probe.semantic_key)) { + return state; + } + return { ...state, probes: [...state.probes, probe] }; +} + +export function liveDistinguishProbe( + state: InferenceState | null | undefined, + schema: Readonly> | null | undefined, +): ConflictProbe | null { + if (!state || !schema) return null; + const probeId = typeof schema.probe_id === "string" ? schema.probe_id.trim() : ""; + const semanticKey = typeof schema.semantic_key === "string" ? schema.semantic_key.trim() : ""; + if (!probeId && !semanticKey) return null; + return state.probes.find((probe) => ( + (probeId && (probe.id === probeId || probe.semantic_key === probeId)) + || (semanticKey && probe.semantic_key === semanticKey) + )) ?? null; +} diff --git a/frontend/tests/rectification-dead-d9-choice-20260916.test.ts b/frontend/tests/rectification-dead-d9-choice-20260916.test.ts new file mode 100644 index 00000000..9ebba421 --- /dev/null +++ b/frontend/tests/rectification-dead-d9-choice-20260916.test.ts @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { + applyChoiceWithoutEvidence, + stampChoiceSchemaWithProbe, +} from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; + +import { + projectRectificationChoiceCard, + yearlessPersonalityCanAsk, + type MethodFollowup, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { expectedAnswerSchemaFor } from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; +import { parseWindowScan } from "../src/lib/rectification-agentic/v9/varga-observations.ts"; +import { + attachVargaDistinguishIdentity, + withFollowupOwnedProbe, +} from "../src/lib/rectification-agentic/v9/varga-distinguish-probe.ts"; +import { FOCUS_ID } from "./rectification-v9-test-support.ts"; + +const DATED = [ + { + status: "confirmed", + domain: "health_pressure", + datePrecision: "month", + occurredFrom: "2019-03-01", + occurredTo: "2019-03-31", + }, + { + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2018-01-01", + occurredTo: "2018-12-31", + }, + { + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: "2016-12-31", + }, + { + status: "confirmed", + domain: "occupation", + datePrecision: "unknown", + occurredFrom: null, + occurredTo: null, + }, +]; + +const D24 = { + id: "contrast:varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座", + semantic_key: "varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座", + candidate_split_hash: "set:varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座", + domain: "education", + year: 0, + question: "学业对照", + candidate_ids: ["04:48", "04:59"], + expected_outcomes: [ + { answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:59"] }, + { answer_class: "weak_yes" as const, supports: ["04:59"], conflicts: ["04:48"] }, + { answer_class: "no" as const, supports: [], conflicts: [] }, + { answer_class: "unsure" as const, supports: [], conflicts: [] }, + ], + information_gain: 0.9, + source: "engine", + choice_kind: "event_quality" as const, +}; + +const WINDOW = parseWindowScan({ + scanned: true, + d9_lagna_count: 2, + d10_lagna_count: 1, + d9_candidates_differ: true, + d10_candidates_differ: false, + d9_sign_names: ["天蝎座", "天秤座"], + d10_sign_names: ["巨蟹座"], + transitions: [ + { layer: "d9", at: "04:53", from_sign: "天蝎座", to_sign: "天秤座" }, + { layer: "d10", at: "04:40", from_sign: "巨蟹座", to_sign: "巨蟹座" }, + ], +}); + +function state() { + return buildInferenceState({ + range_start: "04:48", + range_end: "05:07", + candidates: [ + { id: "04:48", time: "04:48", relative_support: 44 }, + { id: "04:53", time: "04:53", relative_support: 35 }, + { id: "04:59", time: "04:59", relative_support: 21 }, + ], + events: Array.from({ length: 5 }, (_, index) => ({ + id: `e${index}`, + domain: "health", + year: 2019, + precision: "month" as const, + })), + probes: [D24], + }); +} + +function d9Followup() { + const attached = attachVargaDistinguishIdentity({ + method_id: "d9_relationship", + intent: "distinguish_candidates", + ask_theme: "relationship_style", + domain: "relationship", + kind_hint: "relationship_change", + user_prompt_hint: "当前候选在关系主题上仍分不开。", + source: "varga_observation", + must_not_label: false, + choice_frame: null, + }, { + windowScan: WINDOW, + candidates: [ + { id: "04:48", time: "04:48" }, + { id: "04:53", time: "04:53" }, + { id: "04:59", time: "04:59" }, + ], + }) as MethodFollowup; + const frame = buildChoiceFrame(attached, { + evidence: DATED, + probes: attached.style_options ? [{ + year: 0, + year_label: "当前这几个候选", + domain: "relationship", + event_family: "开始认真关系、分手或结婚", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: attached.user_prompt_hint, + role: "distinguish", + phase: "candidate_discriminator", + information_gain: attached.information_gain, + semantic_key: attached.semantic_key, + candidate_split_hash: attached.candidate_split_hash, + candidate_ids: attached.candidate_ids, + expected_outcomes: attached.expected_outcomes, + choice_kind: "varga_style" as const, + style_options: attached.style_options, + }] : [] as never, + }); + return { ...attached, choice_frame: frame } as MethodFollowup; +} + +test("year-stage D9 followup does not borrow the D24 contrast probe (BUG-912)", () => { + const inference = state(); + const borrowed = stampChoiceSchemaWithProbe({ + choice: { + options: [ + { key: "A", answer_class: "yes" }, + { key: "B", answer_class: "weak_yes" }, + { key: "C", answer_class: "no" }, + { key: "D", answer_class: "unsure" }, + ], + }, + }, inference, "d9_relationship:relationship_style:score"); + assert.notEqual(borrowed.probe_id, D24.id); + + const followup = d9Followup(); + assert.equal(followup.semantic_key, "d9_relationship:relationship_style"); + assert.ok(followup.choice_frame); + const schema = expectedAnswerSchemaFor( + followup.choice_frame!, + followup.choice_frame!.question_id, + { inference_state: inference, window_scan: WINDOW }, + followup, + ); + assert.ok(schema); + assert.equal(schema.semantic_key, "d9_relationship:relationship_style"); + assert.notEqual(String(schema.probe_id ?? ""), D24.id); + assert.match(String(schema.probe_id ?? ""), /d9_relationship:relationship_style/); + + const applied = applyChoiceWithoutEvidence(withFollowupOwnedProbe(inference, followup)!, { + choiceKey: "C", + schema, + questionId: "d9_relationship:relationship_style:score", + domain: "relationship", + classifiedFrom: "choice", + }); + assert.equal(applied.applied, true); + assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, "d9_relationship:relationship_style"); + assert.ok(!applied.state.answered_probes.some((row) => row.probe_id === D24.id)); +}); + +test("GET projects the persisted distinguish copy when its probe is still live (BUG-912)", () => { + const inference = state(); + const followup = d9Followup(); + const schema = expectedAnswerSchemaFor( + followup.choice_frame!, + "d9_relationship:relationship_style:score", + { inference_state: inference, window_scan: WINDOW }, + followup, + ); + const withOwned = buildInferenceState({ + range_start: "04:48", + range_end: "05:07", + candidates: [ + { id: "04:48", time: "04:48", relative_support: 44 }, + { id: "04:53", time: "04:53", relative_support: 35 }, + { id: "04:59", time: "04:59", relative_support: 21 }, + ], + events: inference.events, + probes: [ + D24, + { + id: String(schema?.probe_id), + semantic_key: String(schema?.semantic_key), + candidate_split_hash: String(schema?.candidate_split_hash), + domain: "relationship", + year: 0, + question: "D9", + candidate_ids: ["04:48", "04:59"], + expected_outcomes: D24.expected_outcomes, + information_gain: 0.4, + source: "varga_observation", + choice_kind: "varga_style" as const, + style_options: followup.style_options as never, + }, + ], + }); + const card = projectRectificationChoiceCard({ + evidence: DATED, + activeFocus: { + id: FOCUS_ID, + questionId: "d9_relationship:relationship_style:score", + intent: "distinguish_candidates", + targetDomain: "relationship", + targetKind: null, + expectedAnswerSchema: schema, + }, + observations: [{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" }], + windowScan: WINDOW, + inferenceState: withOwned, + sessionOutcome: "collect_evidence", + caseRevision: 1, + }); + assert.equal(card?.question_id, "d9_relationship:relationship_style:score"); + assert.equal(card?.options.length, 4); +}); + +test("five dated events do not hold for yearless personality (BUG-913 closed_by_design)", () => { + assert.equal(yearlessPersonalityCanAsk({ datedCount: 5, candidatesSeparated: false }), false); + assert.equal(yearlessPersonalityCanAsk({ datedCount: 0, candidatesSeparated: false, topCandidateTimes: ["04:48", "04:59"] }), true); +}); + +test("nakshatra opposite poles are not both lifted as shared traits (BUG-914)", () => { + const earlier = "希望两边都能说得过去"; + const later = "必要时会直接选边"; + const delivery = buildRangeDelivery({ + inference: state(), + publicCandidates: [ + { candidateId: "a", time: "04:48" }, + { candidateId: "b", time: "04:53" }, + { candidateId: "c", time: "04:59" }, + ], + credibleRange: ["04:48", "04:59"], + representativeTime: "04:59", + nakshatraBoundary: { + near_boundary: true, + user_meaning: `A:${earlier}。B:${later}。`, + options: [ + { key: "A", time_bias: "earlier", traits: [earlier] }, + { key: "B", time_bias: "later", traits: [later] }, + ], + }, + }); + const shared = delivery.shared_traits.join(" "); + assert.equal(shared.includes(earlier) && shared.includes(later), false); +}); diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index f36ac2c9..80d7b041 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -540,18 +540,18 @@ def nakshatra_boundary(built: dict[str, Any], representative_time: str | None) - { "key": "A", "time_bias": "earlier", - "traits": list(earlier), + "traits": [earlier[0]], }, { "key": "B", "time_bias": "later", - "traits": list(later), + "traits": [later[0]], }, ], "user_meaning": ( "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?" - f"A:{earlier[0]};{earlier[1]}。" - f"B:{later[0]};{later[1]}。" + f"A:{earlier[0]}。" + f"B:{later[0]}。" "这只用来偏置时间窗,不能确认唯一分钟。" ), } diff --git a/tests/test_rectification_refinement_packet.py b/tests/test_rectification_refinement_packet.py index fd2c6266..bfe1f2c0 100644 --- a/tests/test_rectification_refinement_packet.py +++ b/tests/test_rectification_refinement_packet.py @@ -322,6 +322,14 @@ class RefinementPacketTest(unittest.TestCase): self.assertNotIn("points", str(packet["event_dasha_ledger"])) self.assertEqual(packet["lagna_contrast"]["intervals"][0]["lagna"], "金牛座") self.assertTrue(packet["nakshatra_boundary"]["near_boundary"]) + self.assertEqual(len(packet["nakshatra_boundary"]["options"][0]["traits"]), 1) + self.assertEqual(len(packet["nakshatra_boundary"]["options"][1]["traits"]), 1) + self.assertNotEqual( + packet["nakshatra_boundary"]["options"][0]["traits"][0], + packet["nakshatra_boundary"]["options"][1]["traits"][0], + ) + self.assertIn("A:", packet["nakshatra_boundary"]["user_meaning"]) + self.assertNotIn(";", packet["nakshatra_boundary"]["user_meaning"]) self.assertEqual(packet["oos_blind_prompts"][0]["domain"], "finance") self.assertFalse(packet["oos_blind_prompts"][0]["used_for_scoring"]) self.assertFalse(packet["confirmation_allowed"])