diff --git a/CHANGELOG.md b/CHANGELOG.md index 383de736..b972ced6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-05 — 区分卡答发生了之后不再问同一领域哪年 + +生时校正里,如果刚在选择卡上确认某类经历发生过,不会紧接着再问「还记得哪年…」同一领域的采集题。点选仍不记入证据账本。那年没有或记不清时,仍可以问别的年份。Skill 版本仍是 10.0.14。 + ## 2026-09-05 — 报告页可下载全量数据附录 主报告仍是五章叙事。ready 之后可以另下一份 Markdown 全量数据附录(三年年度、KP 月度、功能吉凶、校时敏感度等)。附录失败只显示暂不可用,不会把主报告打回失败,也不退款。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e1e5dfa7..cac325b2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8470,3 +8470,19 @@ - 复发自:无 - 修复版本:`52a4570c` +## BUG-549 | 区分卡答「发生了」后仍问同一领域哪年采集 + +- 状态:resolved +- 首次发现:2026-09-05 +- 最近更新:2026-09-05 +- 影响面:`buildMethodFollowupPlan`、`datedCollectDomainBlocked`、`rectificationFollowupCatalog` +- 用户现象:刚在事业区分卡上点选「发生了」,下一问又是「工作上呢,还记得哪年入职、换工作…」。 +- 触发条件:账本没有该领域带年份证据;区分卡答案只进推断层;本轮已无剩余区分卡,计划层按账本判断领域未覆盖。 +- 根因:点选答案不写证据账本(BUG-389–393 分层,本单不推翻)。计划层 `careerCovered` / `datedCollectDomainBlocked` 只看账本。BUG-547 之后区分卡问完才进采集链,刚答过的领域会被再问一次。 +- 修复:`yes` / `weak_yes` 的领域视为采集已覆盖(用 `eventProbes` 按 `semantic_key` / `probe_id` 回查 domain,回查不到则忽略)。不写账本,不改 `meetsAcceptanceEventQuality`,不改确认门。`no` / `unsure` 不算覆盖。 +- 验证:`frontend/tests/rectification-collect-direction-20260904.test.ts`:账本无事业证据、事业探针答 yes 后下一问不是事业采集;答 no 仍问。BUG-546/547 既有用例仍过。 +- 防复发:不得把点选答案写入证据账本来「修」重复采集。不得用 `semantic_key` 前缀硬拆领域。不得加「除了刚才那次以外」这类补丁文案。 +- 相关记录:BUG-389、BUG-546、BUG-547 +- 复发自:无 +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-rectification-probe-answer-covers-domain-20260905.md b/docs/tasks/PROGRESS-rectification-probe-answer-covers-domain-20260905.md new file mode 100644 index 00000000..1770f6c7 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-probe-answer-covers-domain-20260905.md @@ -0,0 +1,37 @@ +# PROGRESS · 区分卡答「发生了」视为该领域采集已覆盖(2026-09-05) + +工作树:`.worktrees/rectification-probe-answer-covers-domain-20260905` +分支:`codex/rectification-probe-answer-covers-domain-20260905` +基线:`origin/staging` @ `2dc61946`(任务书)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 点选 A/B 覆盖该领域采集,不写账本、不动门 | 完成 | BUG-549 | + +## 编号 + +开工时最大号 BUG-548。本单 **BUG-549**。未占用预留的 BUG-542。 + +## 产品取舍 + +任务书决策 2 允许否决「答 C/D 后仍可马上问同领域采集」。本单**保留原文**:C/D 不算覆盖。区分卡谈的是那一个年份窗口;采集问的是别的年份。隔一轮只是把同一问推迟,会把调度混进覆盖判定,也不减少信息差。未加「除了刚才那次以外」文案。 + +`decideFromDossier` 里只用来算 `methodCoverageAll` / `datedMethodCollectOpen` 的轻量 `buildMethodFollowupPlan` 仍只看账本,训练门与确认门输入不变。出题计划通过 `rectificationFollowupCatalog.answeredProbes` 透传。`adopt-narration.ts` 不调用 `buildMethodFollowupPlan`,无需改。 + +## 实现要点 + +- `domainsAnsweredYes`:用 `eventProbes`(外加同类型的 clarification / collection 探针)按 `semantic_key` / `probe_id` 回查 `domain`;回查不到忽略,不从前缀拆领域。 +- `relationshipCovered` / `careerCovered` / `familyCovered` 与 `datedCollectDomainBlocked` 叠加 `answeredYes`。 +- `classified_from: "evidence"` 不算覆盖。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| collect-direction + choice-card | 52/52 | +| `npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts` | 898 项;pass 898;fail 0;cancelled 0;skipped 0 | +| `npx tsc --noEmit` | 0 错 | +| 改动文件 eslint `--quiet` | 0 error | +| `page.tsx` | 未改 | + +未做浏览器真人走查。清单:`docs/testing/rectification-probe-answer-covers-domain-20260905.md`。旧会话在部署前仍会按旧逻辑再问一遍。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index da91702f..aa7165d5 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -54,7 +54,7 @@ | — | `PROGRESS-rectification-adopt-card-anchor-20260905.md` | 采用卡钉在历史采集题下,最新旁白没有卡;单分钟交付旁白机器腔 | 已验收通过(浏览器走查欠) | `codex/rectification-quality-probe-dedupe-20260904`(BUG-544~545) | | — | `PROGRESS-rectification-family-collect-continue-20260905.md` | 家人答「没有」后直接给结果,未继续收能区分分钟的经历 | 已由 cd704a92 修正并验收通过 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-546) | | `TASK-rectification-family-collect-continue-fix-20260905.md` | `PROGRESS-rectification-family-collect-continue-20260905.md` | `998406ce` 修复单:家人之后的 dated 补采集无条件抢在现成区分卡之前(choice-card A/B 卡用例回归),另两条顺序断言缺三栏说明 | 已验收通过(浏览器走查欠) | `cd704a92`(BUG-547) | -| `TASK-rectification-probe-answer-covers-domain-20260905.md` | — | 区分卡答 A/B 后紧接着又问同领域"哪年…"采集题:点选答案只进推断层不写账本,计划层领域覆盖只看账本;改为 yes/weak_yes 视为该领域采集已覆盖(不写账本、不动门) | 待执行 | `codex/rectification-probe-answer-covers-domain-20260905`(BUG-549 起) | +| `TASK-rectification-probe-answer-covers-domain-20260905.md` | `PROGRESS-rectification-probe-answer-covers-domain-20260905.md` | 区分卡答 A/B 后紧接着又问同领域"哪年…"采集题:点选答案只进推断层不写账本,计划层领域覆盖只看账本;改为 yes/weak_yes 视为该领域采集已覆盖(不写账本、不动门) | 待验收 | `codex/rectification-probe-answer-covers-domain-20260905`(BUG-549) | | `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-probe-answer-covers-domain-20260905.md b/docs/testing/rectification-probe-answer-covers-domain-20260905.md new file mode 100644 index 00000000..d3d968e8 --- /dev/null +++ b/docs/testing/rectification-probe-answer-covers-domain-20260905.md @@ -0,0 +1,31 @@ +# Staging 人肉复核 · 区分卡答发生了之后不再问同一领域哪年(2026-09-05) + +给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。 + +对应 BUG-549。测之前先做第 0 条。本单部署前,旧会话仍可能在区分卡答「发生了」后马上问同一领域「哪年…」。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 事业区分卡答「发生了」后不要再问哪年入职(P0) + +1. 新建生时校正。 +2. 先补齐能把对话推到区分卡的带年份经历(不要口述补一条事业入职)。 +3. 出现事业区分卡:「某年某月前后,有没有入职、换工作或职责加重?」点 A 或 B(发生了 / 比较像发生了)。 +4. 把其余区分卡答完,直到下一问变成口述采集。 + +- ✅ 预期:下一句**不是**「工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?」可以是别的还没问过的领域(钱、搬家、身体、家人、学业)或职业口述。 +- ❌ 失败:紧接着又问「工作上呢,还记得哪年入职…」。 + +## 2. 事业区分卡答「没有」后仍可问哪年(P0) + +同一路径,事业区分卡点 C(那年没发生)或 D(记不清): + +- ✅ 预期:后面仍可以出现事业采集「工作上呢,还记得哪年入职…」。那年没有,别的年份仍可能有。 +- ❌ 失败:因为点了 C/D,事业采集整条线被关掉。 + +## 3. 不要出现补丁口吻(P1) + +- ✅ 预期:采集题仍是原来的「工作上呢,还记得哪年…」,没有「除了刚才那次以外」这类句子。 +- ❌ 失败:为了回避重复而改写了采集原文。 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 2fd81587..f2029287 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -1048,9 +1048,15 @@ async function persistExhaustionCollect(input: { hostNarration: string; focus?: ConversationFocus | null; }> { + const catalog = rectificationFollowupCatalog( + input.dossier.latestResult ?? null, + input.dossier.evidence, + ); const followup = exhaustionSpokenCollectFollowup({ evidence: input.dossier.evidence, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, }); const range = nonConvergingRangeNarration({ ...input.decision, 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 9575ca01..7c43f9f5 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -282,6 +282,7 @@ export function rectificationFollowupCatalog( contrastPacket: contrastPacketFromLatestResult(latest ?? null, evidence), topCandidateTimes, askedProbeKeys: askedKeys, + answeredProbes: inference?.answered_probes ?? [], eventProbes: refinement.discriminating_event_probes, eventClarificationProbes: refinement.event_clarification_probes, evidenceCollectionProbes: refinement.evidence_collection_probes, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 4805668b..4df95e9e 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -33,6 +33,8 @@ * Known-event quality probes (exam went badly for a year already * in the ledger) are not reverse-inference cards. Dasha existence * probes skip a year already in the ledger, not the whole domain. + * A yes/weak_yes discriminator answer covers that domain's spoken + * collect without writing the ledger. no/unsure does not. * Contrast-packet ranking must use the same year rule: a dated * unstructured probe stays eligible when the domain already has a * different year. Information gain is recomputed on the current @@ -223,6 +225,47 @@ function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain: return evidence.some((item) => item.status === "confirmed" && item.domain === domain); } +export type AnsweredProbeCoverageRow = Readonly<{ + semantic_key: string; + probe_id?: string; + answer_class: string; + classified_from?: string; +}>; + +function domainForAnsweredProbe( + answer: AnsweredProbeCoverageRow, + eventProbes: readonly DiscriminatingEventProbe[], +): string | null { + const keys = new Set( + [answer.semantic_key, answer.probe_id].filter((key): key is string => Boolean(key?.trim())), + ); + if (keys.size === 0) return null; + for (const probe of eventProbes) { + const semantic = probe.semantic_key?.trim() ?? ""; + if (!semantic || !keys.has(semantic)) continue; + const domain = probe.domain?.trim(); + if (domain) return domain; + } + return null; +} + +/** Domains where a discriminator was answered yes/weak_yes. Lookup is by probe identity, not key prefix. */ +export function domainsAnsweredYes( + answeredProbes: readonly AnsweredProbeCoverageRow[] | undefined, + eventProbes: readonly DiscriminatingEventProbe[] | undefined, +): Set { + const domains = new Set(); + if (!answeredProbes?.length) return domains; + const catalog = eventProbes ?? []; + for (const answer of answeredProbes) { + if (answer.answer_class !== "yes" && answer.answer_class !== "weak_yes") continue; + if (answer.classified_from && answer.classified_from !== "choice") continue; + const domain = domainForAnsweredProbe(answer, catalog); + if (domain) domains.add(domain); + } + return domains; +} + function evidenceYear(item: MethodFollowupEvidence): number | null { const raw = item.occurredFrom || item.occurredTo; if (!raw || raw.length < 4 || !/^\d{4}/.test(raw)) return null; @@ -1070,19 +1113,26 @@ function datedCollectDomainBlocked( domain: (typeof DATED_COLLECT_ORDER)[number], evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, + answeredYes: ReadonlySet = new Set(), ): boolean { if (domain === "health_pressure") { - return declinedHealth(declined) || hasConfirmedHealth(evidence); + return declinedHealth(declined) + || hasConfirmedHealth(evidence) + || answeredYes.has("health_pressure") + || answeredYes.has("health"); } - return declined.has(domain) || hasConfirmedDomain(evidence, domain); + return declined.has(domain) + || hasConfirmedDomain(evidence, domain) + || answeredYes.has(domain); } export function nextDatedCollectFollowup( evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, + answeredYes: ReadonlySet = new Set(), ): MethodFollowup | null { for (const domain of DATED_COLLECT_ORDER) { - if (datedCollectDomainBlocked(domain, evidence, declined)) continue; + if (datedCollectDomainBlocked(domain, evidence, declined, answeredYes)) continue; const next = datedCollectFollowup(domain, evidence); if (next) return next; } @@ -1125,9 +1175,12 @@ function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): Meth export function exhaustionSpokenCollectFollowup(input: { evidence: readonly MethodFollowupEvidence[]; declinedTopics?: readonly Readonly>[]; + answeredProbes?: readonly AnsweredProbeCoverageRow[]; + eventProbes?: readonly DiscriminatingEventProbe[]; }): MethodFollowup | null { const declined = declinedDomains(input.declinedTopics ?? []); - const dated = nextDatedCollectFollowup(input.evidence, declined); + const answeredYes = domainsAnsweredYes(input.answeredProbes, input.eventProbes); + const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes); if (dated) return dated; if ( !declined.has("occupation") @@ -1506,6 +1559,7 @@ export function buildMethodFollowupPlan(input: { eventClarificationProbes?: readonly DiscriminatingEventProbe[]; evidenceCollectionProbes?: readonly DiscriminatingEventProbe[]; askedProbeKeys?: readonly string[]; + answeredProbes?: readonly AnsweredProbeCoverageRow[]; closedCollectFocuses?: readonly Readonly>[]; birthDate?: string | null; accepted?: boolean; @@ -1559,10 +1613,18 @@ export function buildMethodFollowupPlan(input: { const ask = (why: string, varga: string, extra = "") => agentHint(why, varga, extra, input.evidence); const declined = declinedDomains(input.declinedTopics ?? []); + const answeredYes = domainsAnsweredYes(input.answeredProbes, [ + ...(input.eventProbes ?? []), + ...(input.eventClarificationProbes ?? []), + ...(input.evidenceCollectionProbes ?? []), + ]); const dashaCovered = input.evidence.some(isConfirmedDated); - const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship"); - const careerCovered = hasConfirmedDomain(input.evidence, "career"); - const familyCovered = hasConfirmedDomain(input.evidence, "family"); + const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship") + || answeredYes.has("relationship"); + const careerCovered = hasConfirmedDomain(input.evidence, "career") + || answeredYes.has("career"); + const familyCovered = hasConfirmedDomain(input.evidence, "family") + || answeredYes.has("family"); const financeCovered = hasConfirmedDomain(input.evidence, "finance"); const healthCovered = hasConfirmedHealth(input.evidence); const occupationCovered = hasConfirmedDomain(input.evidence, "occupation") @@ -2120,7 +2182,7 @@ export function buildMethodFollowupPlan(input: { && precisionCard?.choice_frame ) { next = precisionCard; - } else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined))) { + } else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes))) { next = makeFollowup(datedCollect); } else if (!occupationCovered) { if (meetsAcceptanceEventQuality(input.evidence)) { diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index f3e4145d..5ed5ad82 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -14,6 +14,7 @@ import { spokenFollowupForUser, type MethodFollowupEvidence, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; @@ -472,3 +473,99 @@ test("turn decision and GET interview expose collection_progress 2/3/1 or null", ); assert.match(tools, /采集题必须写出服务端给你的领域/); }); + +const CAREER_EXISTENCE_PROBE: DiscriminatingEventProbe = { + year: 2011, + year_label: "2011 年前后", + domain: "career", + event_family: "入职、换工作或职责加重", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "年份锁定 2011 年前后。事件家族:入职、换工作或职责加重。", + role: "distinguish", + information_gain: 0.4, + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + semantic_key: "career.2011", + candidate_split_hash: "career.2011:05:00|05:20", + choice_kind: "existence", +}; + +const RELATIONSHIP_AND_FAMILY = [ + dated("relationship", "2014", { eventKind: "relationship_start" }), + dated("family", "2016", { eventKind: "family_event" }), +] as const; + +function careerAnswerPlan(answerClass: string, extra: Partial[0]> = {}) { + return buildMethodFollowupPlan({ + evidence: RELATIONSHIP_AND_FAMILY, + eventProbes: [CAREER_EXISTENCE_PROBE], + askedProbeKeys: ["career.2011", "probe:career.2011"], + answeredProbes: [{ + semantic_key: "career.2011", + probe_id: "probe:career.2011", + answer_class: answerClass, + classified_from: "choice", + }], + holdoutValidation: "not_started", + ...extra, + }); +} + +test("career discriminator yes covers career collect without ledger career evidence", () => { + const unanswered = buildMethodFollowupPlan({ + evidence: RELATIONSHIP_AND_FAMILY, + eventProbes: [CAREER_EXISTENCE_PROBE], + askedProbeKeys: ["career.2011", "probe:career.2011"], + holdoutValidation: "not_started", + }); + assert.equal(unanswered.next_followup?.domain, "career"); + assert.equal(unanswered.next_followup?.intent, "collect_method_evidence"); + assert.equal(spokenFollowupForUser(unanswered.next_followup), USER_COLLECT_QUESTION.career); + + const yes = careerAnswerPlan("yes"); + assert.notEqual(yes.next_followup?.domain, "career"); + assert.equal(yes.next_followup?.domain, "education"); + assert.equal(yes.next_followup?.intent, "collect_method_evidence"); + assert.equal(spokenFollowupForUser(yes.next_followup), USER_COLLECT_QUESTION.education); + assert.equal( + yes.methods.find((item) => item.method_id === "d10_career")?.status, + "covered", + ); + + const weakYes = careerAnswerPlan("weak_yes"); + assert.notEqual(weakYes.next_followup?.domain, "career"); + assert.equal(weakYes.next_followup?.domain, "education"); + + const no = careerAnswerPlan("no"); + assert.equal(no.next_followup?.domain, "career"); + assert.equal(spokenFollowupForUser(no.next_followup), USER_COLLECT_QUESTION.career); + + const unsure = careerAnswerPlan("unsure"); + assert.equal(unsure.next_followup?.domain, "career"); + assert.equal(spokenFollowupForUser(unsure.next_followup), USER_COLLECT_QUESTION.career); +}); + +test("career discriminator yes does not invent coverage from a semantic_key prefix", () => { + const plan = careerAnswerPlan("yes", { eventProbes: [] }); + assert.equal(plan.next_followup?.domain, "career"); + assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career); +}); + +test("ledger-classified career probe answers do not cover career collect", () => { + const plan = careerAnswerPlan("yes", { + answeredProbes: [{ + semantic_key: "career.2011", + probe_id: "probe:career.2011", + answer_class: "yes", + classified_from: "evidence", + }], + }); + assert.equal(plan.next_followup?.domain, "career"); + assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career); +});