diff --git a/CHANGELOG.md b/CHANGELOG.md index ef423314..875c4e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-13 — 健康与职业别名在比较时归并成同一领域 + +生时校正里,账本记的「健康」和题库里的「健康压力」是同一条线:跳过或已经报过健康后,不会再问同一年的健康核对题,盘外提示也不会再说「还没用过健康」。职业题虽然落库写成「其他」,拒答后也不会在事业还没覆盖时再问一遍职业。比较只走一处归并,数据库字段不改。Skill 版本不变。 + + ## 2026-09-13 — 参考题点一次连出两道,问完入口消失 生时校正「目前范围」卡下,若还有未答的 D9/D10 参考题,可以点「再答两道参考题微调排序」。点一次会先出相处方式、答完自动出做事风格;两道答完后入口消失,回到目前范围,不会出现红字「现在没有可答的参考题」。这两道仍只微调排序,不改能不能采用。Skill 10.0.26(定向补事写明第四选项「这条先跳过」;旧案仍绑定 10.0.25)。 diff --git a/CONTEXT.md b/CONTEXT.md index e34f2c1c..5580b3b0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -47,3 +47,7 @@ _避免使用_:聊天日志、客服工单 **处理状态**: 对话质量记录的内部处理进度,取值为待处理、处理中、已解决或忽略。 _避免使用_:用户反馈状态、通知状态 + +**领域别名**: +生时校正里,健康在证据账本和焦点层记为 `health`,计划层、引擎探针和盘外提示用 `health_pressure`;职业焦点落库为 `other`(题号仍是 `collect:occupation:`),计划层与账本用 `occupation`。比较时两侧都要先归并,不能按字面相等。数据库 `target_domain` 约束不改。 +_避免使用_:把 `health` 和 `health_pressure` 当成两个领域、把职业拒答当成 `other` 领域 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1f8ad3c4..3d09fadb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10452,3 +10452,19 @@ - 相关记录:BUG-669、BUG-627、BUG-652、BUG-510 - 复发自:BUG-669 - 修复版本:待发布 + +## BUG-672 | 健康与职业别名在十处比较里各走各的,拒答或已报过仍再问 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-13 +- 影响面:`canonicalCollectDomain` / `canonical_domain`、`holdoutFollowupFor`、`remainingReverseVerifyProbes`、`remainingConflictProbes`、`probeYearAlreadyCovered`、`oos_blind_prompts`、`_event_years`、`volunteeredDomainsFromEvidence`、`declinedDomains` +- 用户现象:跳过或报过健康后,核对题、区分题或盘外提示仍问同一条健康线;职业题落库成「其他」后,事业还没覆盖时会再问职业。 +- 触发条件:账本或焦点是 `health`,计划/引擎探针是 `health_pressure`;或职业焦点 `target_domain=other` 且题号 `collect:occupation:`。 +- 根因:三套词汇并存。BUG-590 只归并 holdout occupied,BUG-626 只补 holdout declined。其余比较仍用字面相等。任务书写 BUG-628,该号已被口述采集按钮占用。 +- 修复:TS/Python 各一个归并函数,比较两侧都走它。DB `target_domain` 与 `persistableFocusDomain` 不改。源码合同禁止 `method-followup.ts` 再写 `"health"` 比较。 +- 验证:`frontend/tests/rectification-domain-alias-audit-20260913.test.ts`;`tests/test_rectification_event_probes.py` DomainAliasTests。 +- 防复发:健康/职业比较必须经归并函数。不得在 `method-followup.ts` 新增 `"health"` 字面量比较。不得改 DB 约束来迁就别名。 +- 相关记录:BUG-590、BUG-626、BUG-586、BUG-641 +- 复发自:BUG-590 决策 4(occupied 已归并,其余站点未归并) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-domain-alias-audit-20260913.md b/docs/tasks/PROGRESS-rectification-domain-alias-audit-20260913.md new file mode 100644 index 00000000..e6395fd5 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-domain-alias-audit-20260913.md @@ -0,0 +1,32 @@ +# PROGRESS · 领域别名归并(2026-09-13) + +工作树:`.worktrees/rectification-domain-alias-audit-20260913` +分支:`codex/rectification-domain-alias-audit-20260913` +任务书:`docs/tasks/TASK-rectification-domain-alias-audit-20260909.md` +基线:开工时 `origin/staging` @ `99127601` + +## 开工回执 + +- 目标:健康 `health` ≡ `health_pressure`、职业焦点 `other` + `collect:occupation:` ≡ `occupation`,比较只走一处归并。 +- 编号:任务书写 BUG-628,但该号已被「口述采集没有/记不清按钮」占用;当前最大 BUG-671,本单用 **BUG-672**。 +- 串行:BUG-626 决策 1(holdout declined 归并)已在 staging。本单补 H3/H4/H5/H6/H7/H8/H9/O1,H1/H2/H10 改走同一函数。 +- H8 口径:BUG-641 已删 Python `VOLUNTEER_ONLY`;D30 是否进入剩余分盘对照仍看 TS `remainingVargaSplits` 的 volunteered 集合。归并后账本 `health` 会让 D30 进入该集合。这是原设计,不是新行为。Skill 不 bump。 + +## 已完成 + +- **5.1** `v9/domain-alias.ts`:`canonicalCollectDomain` / `sameCollectDomain` / `declinedHasDomain`。`method-followup.ts` 的 declined、reverse-verify、conflict、probeYearAlreadyCovered、holdout occupied/declined、`hasConfirmedHealth` / `declinedHealth` 薄封装都经它。`candidate-contrast-packet.ts` 的 D30 提及、账本 year 键、dated/volunteered 域同样归并。职业拒答(`other` + `collect:occupation:`)进入 `declined.has("occupation")`,事业未覆盖时职业方法记为已覆盖、不再派职业题。 +- **5.2** Python `canonical_domain`;`_event_years`、`oos_blind_prompts.covered` 经它。`_probe_domains` 在 d30 剩余层时仍给出 `health_pressure`;账本 `health` 2024-10 阻断该年健康探针。 +- **5.3** `frontend/tests/rectification-domain-alias-audit-20260913.test.ts` 锁 `method-followup.ts` 不得出现字面量 `"health"`。 +- **5.4** BUG-672、CHANGELOG、CONTEXT 术语表、本文件、任务板改待验收。 + +## 验收命令 + +- `./node_modules/.bin/tsc --noEmit`:0 错 +- `npm run lint`:0 error(120 warning,既有) +- 相关 TAP:`tests/rectification-*.test.ts`(排除 database)**1186 / 1186**;新文件 `rectification-domain-alias-audit-20260913.test.ts` 10/10 +- Python:`tests/test_rectification_event_probes.py` 全文件通过(含 DomainAliasTests);`tests/test_rectification_v5_services.py` 40/40 +- `next build --webpack`:`/` 仍 Static;`rootMainFiles` gzip-9 **130934 B**(与 `99127601` 相同,0.000%) +- 未改 Skill、未改 DB、未改采用/确认门 +- 未做登录态真机走查 + +本单状态:**待验收**。未 commit / 未 push。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8f1f6402..4024bdb4 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -91,7 +91,7 @@ | `TASK-rectification-history-open-20260909.md` | `PROGRESS-rectification-history-open-20260909.md` | 历史生时校正打不开:`open_agentic_rectification_case_v2` 把 Case 绑定 Skill 身份与当前注册版本做相等比较,两天内 10.0.15→10.0.19 五次 bump 让所有旧 Case 被拒;错误码未映射成 500;错误只画在首页卡下 → 表现为点了没反应。决策:session 打开用绑定身份(v1 取 Case → 读身份 → 注册表核验 → v2),不改迁移 | 已验收通过 | `a31a5e24`(BUG-621) | | `TASK-rectification-window-cluster-cap-20260909.md` | `PROGRESS-rectification-window-cluster-cap-20260909.md` | **P0** 真实用户:一小时窗口分 17 个签名簇,`select_signature_representatives` 按时间取前 12 个,14:40 之后整簇丢弃(本机复现表);区间按代表分钟跨度而非簇覆盖;中途说出真实时段时助手口头答应却未改 → 改为固定回复"范围开始时按资料定、中途不改"(产品否决口头改范围);intake 自定义范围等产品答复 | 已验收通过(申报拦截误伤经历 → BUG-631;intake 自定义范围等产品) | `6008c07c`(BUG-623~625,Skill 10.0.20) | | `TASK-rectification-skipped-health-deadend-20260909.md` | `PROGRESS-rectification-skipped-health-deadend-20260909.md` | 真实用户:健康题「记不清」后职业答完即断(『没有拿到下一个问题』):holdout 的 `declined` 未归并 health/health_pressure,把跳过的健康线再问一次,撞同 id 焦点 `duplicate_focus` 静默;出口闸门把 `exhausted` 当已交付不修复;『重新加载』只重取快照 | 待验收 | `codex/rectification-skipped-health-deadend-20260909`(BUG-626~627) | -| `TASK-rectification-domain-alias-audit-20260909.md` | `PROGRESS-rectification-domain-alias-audit-20260909.md` | 领域命名审计:健康线 `health`(账本/焦点)与 `health_pressure`(计划/引擎)在十处比较里六处未归并(holdout declined、reverse-verify、conflict probes、probeYearAlreadyCovered、引擎 oos_blind_prompts / _event_years / volunteered);职业线焦点存为 `other` 只靠 questionId 兜底。其余六领域三层同名无问题。决策:TS/Python 各一个归并函数 + 源码合同禁字面量比较 | 待执行 | `codex/rectification-domain-alias-audit-20260909`(BUG-628) | +| `TASK-rectification-domain-alias-audit-20260909.md` | `PROGRESS-rectification-domain-alias-audit-20260913.md` | 领域命名审计:健康线 `health`(账本/焦点)与 `health_pressure`(计划/引擎)在十处比较里六处未归并(holdout declined、reverse-verify、conflict probes、probeYearAlreadyCovered、引擎 oos_blind_prompts / _event_years / volunteered);职业线焦点存为 `other` 只靠 questionId 兜底。其余六领域三层同名无问题。决策:TS/Python 各一个归并函数 + 源码合同禁字面量比较 | 待验收 | `codex/rectification-domain-alias-audit-20260913`(BUG-672;任务书写 BUG-628,该号已被口述采集按钮占用) | | `TASK-rectification-yearless-probe-downgrade-20260909.md` | `PROGRESS-rectification-yearless-probe-downgrade-20260909.md` | 产品拍板:无年月性格题(D9/D10 风格、月宿边界)降级为平局裁决——带年月题问完且候选仍分不开才问,分值减半(±1)、不计淘汰,报告标「参考」;三列卡性格描述不动;新增离线命中率测量脚本 + 导出 SQL(有出生证用户,聚合);Skill 10.0.21 | 已验收通过 | `fef54b6e`(BUG-629,Skill 10.0.21) | | `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待验收 | `codex/rectification-followups-20260909`(BUG-631~632) | | `TASK-rectification-evidence-turn-empty-answer-20260910.md` | `PROGRESS-rectification-evidence-turn-empty-answer-20260910.md` | 证据轮模型无正文被判整轮失败:证据、评分、下一问都已落库却只剩『没有拿到下一个问题』(BUG-633);答题旁白只说『范围没变』、时间线写死『还在收窄』(BUG-634) | 待验收 | 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 f1709337..808ce3a8 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -23,6 +23,7 @@ import { SEMANTIC_YEAR_KEY, } from "../v9/probe-question-contract.ts"; import { probeBelowAdultFloor } from "../v9/adult-floor.ts"; +import { canonicalCollectDomain, sameCollectDomain } from "../v9/domain-alias.ts"; export type ContrastChoiceKind = "existence" | "varga_style" | "event_quality"; @@ -235,7 +236,7 @@ export function mentionedVargaKeysFromLedgerEvidence( keys.add("varga.d2"); keys.add("varga.d11"); } - if (item.domain === "health_pressure" || HEALTH_RE.test(summary)) keys.add("varga.d30"); + if (sameCollectDomain(item.domain, "health_pressure") || HEALTH_RE.test(summary)) keys.add("varga.d30"); } return [...keys]; } @@ -254,7 +255,7 @@ export function askedEventProbeKeysFromLedgerEvidence( if (!item.domain) continue; for (const date of [item.occurredFrom, item.occurredTo]) { const year = date?.match(/^(\d{4})/)?.[1]; - if (year) keys.add(`${item.domain}.${year}`); + if (year) keys.add(`${canonicalCollectDomain(item.domain)}.${year}`); } } return [...keys]; @@ -273,7 +274,7 @@ export function datedDomainsFromEvidence( if (item.status && !LIVE_EVIDENCE.has(item.status)) continue; if (!item.domain) continue; const dated = [item.occurredFrom, item.occurredTo].some((value) => /^\d{4}/.test(value ?? "")); - if (dated) domains.add(item.domain); + if (dated) domains.add(canonicalCollectDomain(item.domain)); } return [...domains]; } @@ -287,7 +288,7 @@ export function volunteeredDomainsFromEvidence( const domains = new Set(); for (const item of evidence) { if (item.status && !LIVE_EVIDENCE.has(item.status)) continue; - if (item.domain) domains.add(item.domain); + if (item.domain) domains.add(canonicalCollectDomain(item.domain)); } return [...domains]; } @@ -329,18 +330,20 @@ export function buildCandidateContrastPacket(input: { }): CandidateContrastPacket { const asked = new Set(input.askedKeys ?? []); // mentionedKeys are ranking-only; passing them here must not skip remaining varga probes. - const provided = new Set(input.providedDomains ?? []); + const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item))); const fromEngine = (input.engineProbes ?? []).flatMap((probe) => { const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null); if (!built) return []; - const eventKey = built.domain && built.year ? `${built.domain}.${built.year}` : null; + const eventKey = built.domain && built.year + ? `${canonicalCollectDomain(built.domain)}.${built.year}` + : null; const isStructured = isStructuredDiscriminator(built); if ( asked.has(built.semanticKey) || asked.has(built.candidateSplitHash) || asked.has(built.probeId) || (!isStructured && eventKey && asked.has(eventKey)) - || (!isStructured && !(built.year && built.year > 0) && built.domain && provided.has(built.domain)) + || (!isStructured && !(built.year && built.year > 0) && built.domain && provided.has(canonicalCollectDomain(built.domain))) ) { return []; } diff --git a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts index bb72a580..c36d2c19 100644 --- a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts +++ b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts @@ -5,6 +5,7 @@ */ import { USER_COLLECT_QUESTION } from "../user-copy.ts"; +import { canonicalCollectDomain } from "./domain-alias.ts"; export const COLLECT_KIND_ORDER = [ "education", @@ -83,9 +84,9 @@ const INVITE_VALUE = 2; const GENERIC_VALUE = 0.15; export function normalizeCollectKind(domain: string | null | undefined): CollectKind | null { - if (domain === "health" || domain === "health_pressure") return "health_pressure"; - if ((COLLECT_KIND_ORDER as readonly string[]).includes(domain ?? "")) { - return domain as CollectKind; + const canonical = canonicalCollectDomain(domain); + if ((COLLECT_KIND_ORDER as readonly string[]).includes(canonical)) { + return canonical as CollectKind; } return null; } diff --git a/frontend/src/lib/rectification-agentic/v9/domain-alias.ts b/frontend/src/lib/rectification-agentic/v9/domain-alias.ts new file mode 100644 index 00000000..22a2b6b8 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/domain-alias.ts @@ -0,0 +1,42 @@ +/** + * Read-side aliases for collect domains. + * + * Ledger and DB focus store `health`; the planner / engine catalog use + * `health_pressure`. Occupation focuses persist as `other` under a + * `collect:occupation:` question id. Comparisons go through here so the + * two spellings are one domain. Persistable focus domains stay unchanged. + */ + +export function canonicalCollectDomain( + raw: string | null | undefined, + questionId?: string | null, +): string { + const value = typeof raw === "string" ? raw.trim() : ""; + if (value === "health") return "health_pressure"; + const id = typeof questionId === "string" ? questionId : ""; + if (value === "other" && id.startsWith("collect:occupation:")) return "occupation"; + return value; +} + +export function sameCollectDomain( + left: string | null | undefined, + right: string | null | undefined, + questionId?: string | null, +): boolean { + const a = canonicalCollectDomain(left); + const b = canonicalCollectDomain(right, questionId); + return Boolean(a) && a === b; +} + +export function declinedHasDomain( + declined: ReadonlySet, + domain: string | null | undefined, + questionId?: string | null, +): boolean { + const wanted = canonicalCollectDomain(domain, questionId); + if (!wanted) return false; + for (const item of declined) { + if (canonicalCollectDomain(item) === wanted) return true; + } + return false; +} diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index c212eff7..d2f43f73 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -105,6 +105,11 @@ import { type CollectKind, type CollectionPoolItem, } from "./collection-question-pool.ts"; +import { + canonicalCollectDomain, + declinedHasDomain, + sameCollectDomain, +} from "./domain-alias.ts"; export { GENERIC_COLLECT_QUESTION }; import { @@ -275,7 +280,7 @@ function isConfirmedDated( } function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain: string): boolean { - return evidence.some((item) => item.status === "confirmed" && item.domain === domain); + return evidence.some((item) => item.status === "confirmed" && sameCollectDomain(item.domain, domain)); } export type AnsweredProbeCoverageRow = Readonly<{ @@ -314,7 +319,7 @@ export function domainsAnsweredYes( 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); + if (domain) domains.add(canonicalCollectDomain(domain)); } return domains; } @@ -367,7 +372,7 @@ export function existenceProbeAsked( if (nearby <= 0) return false; for (const key of asked) { const match = key.match(SEMANTIC_YEAR_KEY); - if (!match || match[1] !== domain) continue; + if (!match || !sameCollectDomain(match[1], domain)) continue; const askedYear = Number(match[2]); if (Number.isInteger(askedYear) && Math.abs(askedYear - year) <= nearby) return true; } @@ -381,7 +386,7 @@ export function datedLedgerAnchor( if (!domain) return null; let best: { year: number; month: number | null; label: string } | null = null; for (const item of evidence ?? []) { - if (!isConfirmedDated(item) || item.domain !== domain) continue; + if (!isConfirmedDated(item) || !sameCollectDomain(item.domain, domain)) continue; const year = evidenceYear(item); if (year === null) continue; const raw = item.occurredFrom || item.occurredTo || ""; @@ -408,7 +413,7 @@ function nearbyLedgerHint( const probeIndex = month && month >= 1 && month <= 12 ? year * 12 + month : null; let best: { label: string; family: string; delta: number } | null = null; for (const item of evidence ?? []) { - if (!isConfirmedDated(item) || item.domain === domain) continue; + if (!isConfirmedDated(item) || sameCollectDomain(item.domain, domain)) continue; const otherYear = evidenceYear(item); if (otherYear === null) continue; const raw = item.occurredFrom || item.occurredTo || ""; @@ -447,7 +452,7 @@ function probeYearAlreadyCovered( if (item.status !== "confirmed" && item.status !== "draft" && item.status !== "pending_confirmation") { return false; } - if (item.domain !== domain) return false; + if (!sameCollectDomain(item.domain, domain)) return false; const itemYear = evidenceYear(item); if (itemYear === null) return false; return Math.abs(itemYear - year) <= nearby; @@ -503,11 +508,11 @@ export function datedMethodCollectOpen( } function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolean { - return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health"); + return hasConfirmedDomain(evidence, "health_pressure"); } function declinedHealth(declined: ReadonlySet): boolean { - return declined.has("health") || declined.has("health_pressure"); + return declinedHasDomain(declined, "health_pressure"); } function topicDomain(topic: Readonly>): string | null { @@ -553,7 +558,8 @@ function declinedDomains( if (intent && intent !== "collect_method_evidence") continue; if (!includeSkipped && topicStatus(topic) === "skipped") continue; const domain = topicDomain(topic); - if (domain) domains.add(domain); + const canonical = canonicalCollectDomain(domain, topicQuestionId(topic)); + if (canonical) domains.add(canonical); } return domains; } @@ -766,7 +772,7 @@ export function remainingReverseVerifyProbes( && probe.role === "distinguish"; if ((probe.source === "known_event_quality" && !anchoredQuality) || probe.role === "clarify" || probe.phase === "event_clarification") continue; if (probe.role === "collect" || probe.phase === "evidence_collection") continue; - if (declined.has(probe.domain)) continue; + if (declinedHasDomain(declined, probe.domain)) continue; if (!anchoredQuality && probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; if (probeBelowAdultFloor(probe, birthDate)) continue; if (reverseVerifyProbeAsked(probe, askedKeys)) continue; @@ -809,7 +815,7 @@ function datedCollectionProbe( probes: readonly DiscriminatingEventProbe[] | undefined, domain: string, ): DiscriminatingEventProbe | null { - const rows = (probes ?? []).filter((item) => item.domain === domain && Number(item.year) > 0); + const rows = (probes ?? []).filter((item) => sameCollectDomain(item.domain, domain) && Number(item.year) > 0); return rows.sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0))[0] ?? null; } @@ -825,7 +831,7 @@ function collectionYearFields(probe: DiscriminatingEventProbe | null): Pick< }; } -function remainingConflictProbes( +export function remainingConflictProbes( probes: readonly DiscriminatingEventProbe[] | undefined, evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, @@ -837,7 +843,7 @@ function remainingConflictProbes( if (!CONFLICT_PROBE_SOURCES.has(probe.source)) continue; if (probe.source === "known_event_quality" || probe.role === "clarify") continue; if (!isValidDistinguishProbe({ ...probe, role: "distinguish" })) continue; - if (declined.has(probe.domain)) continue; + if (declinedHasDomain(declined, probe.domain)) continue; if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; if (probeBelowAdultFloor(probe, birthDate)) continue; const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`; @@ -1179,7 +1185,7 @@ function rankRenderableDiscriminators(input: { birthDate?: string | null; }): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } { const top = input.topCandidateTimes ?? []; - const provided = new Set(input.providedDomains ?? []); + const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item))); const mentioned = input.mentionedKeys ?? new Set(); const rows: RankedDiscriminator[] = []; const dropped: DroppedProbe[] = []; @@ -1213,7 +1219,7 @@ function rankRenderableDiscriminators(input: { continue; } } - if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) { + if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(canonicalCollectDomain(probe.domain))) { const year = probe.year ?? 0; if (year <= 0) continue; if (input.evidence && probeYearAlreadyCovered(input.evidence, probe.domain, year)) continue; @@ -1320,15 +1326,9 @@ function datedCollectDomainBlocked( declined: ReadonlySet, answeredYes: ReadonlySet = new Set(), ): boolean { - if (domain === "health_pressure") { - return declinedHealth(declined) - || hasConfirmedHealth(evidence) - || answeredYes.has("health_pressure") - || answeredYes.has("health"); - } - return declined.has(domain) + return declinedHasDomain(declined, domain) || hasConfirmedDomain(evidence, domain) - || answeredYes.has(domain); + || [...answeredYes].some((item) => sameCollectDomain(item, domain)); } export function datedCollectAlreadyAsked( @@ -1535,16 +1535,11 @@ export function remainingEvidenceCollectStillOpen( } function holdoutOccupiedDomains(evidence: readonly MethodFollowupEvidence[]): Set { - const occupied = new Set( + return new Set( evidence .filter((item) => isConfirmedDated(item) && evidenceYear(item) != null) - .map((item) => item.domain), + .map((item) => canonicalCollectDomain(item.domain)), ); - if (hasConfirmedHealth(evidence) || occupied.has("health") || occupied.has("health_pressure")) { - occupied.add("health"); - occupied.add("health_pressure"); - } - return occupied; } export function exhaustionSpokenCollectFollowup(input: { @@ -1924,12 +1919,7 @@ function holdoutAskFields( } function declinedForHoldout(declined: ReadonlySet): Set { - const next = new Set(declined); - if (declinedHealth(declined)) { - next.add("health"); - next.add("health_pressure"); - } - return next; + return new Set([...declined].map((item) => canonicalCollectDomain(item))); } export function holdoutFollowupFor( @@ -1943,12 +1933,14 @@ export function holdoutFollowupFor( if (!meetsAcceptanceEventQuality(input.evidence)) return null; const occupied = holdoutOccupiedDomains(input.evidence); const blocked = declinedForHoldout(declined); - const prompt = (input.oosBlindPrompts ?? []).find((item) => ( - Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain) - )) ?? null; - const reserved = (input.holdoutEvents ?? []).find((item) => ( - item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain) - )) ?? null; + const prompt = (input.oosBlindPrompts ?? []).find((item) => { + const domain = canonicalCollectDomain(item.domain); + return Boolean(domain) && !occupied.has(domain) && !blocked.has(domain); + }) ?? null; + const reserved = (input.holdoutEvents ?? []).find((item) => { + const domain = canonicalCollectDomain(item.domain); + return item.year != null && Boolean(domain) && !occupied.has(domain) && !blocked.has(domain); + }) ?? null; return holdoutAskFields(prompt, reserved); } diff --git a/frontend/tests/rectification-domain-alias-audit-20260913.test.ts b/frontend/tests/rectification-domain-alias-audit-20260913.test.ts new file mode 100644 index 00000000..780d7922 --- /dev/null +++ b/frontend/tests/rectification-domain-alias-audit-20260913.test.ts @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + mentionedVargaKeysFromLedgerEvidence, + remainingVargaSplits, + volunteeredDomainsFromEvidence, +} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import { + canonicalCollectDomain, + declinedHasDomain, +} from "../src/lib/rectification-agentic/v9/domain-alias.ts"; +import { + buildMethodFollowupPlan, + declinedCollectDomains, + holdoutFollowupFor, + remainingConflictProbes, + remainingReverseVerifyProbes, + type MethodFollowupEvidence, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; + +function existenceProbe( + domain: DiscriminatingEventProbe["domain"], + year: number, +): DiscriminatingEventProbe { + const key = `${domain}.${year}.dasha_boundary`; + return { + year, + year_label: `${year} 年前后`, + domain, + event_family: "生病、受伤或压力特别大", + source: "dasha_boundary", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: `时间范围锁定 ${year} 年。`, + role: "distinguish", + information_gain: 1.1, + semantic_key: key, + candidate_split_hash: key, + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + choice_kind: "existence", + }; +} + +function datedHealth(): MethodFollowupEvidence { + return { + id: "e-health", + status: "confirmed", + domain: "health", + datePrecision: "month", + occurredFrom: "2024-10-01", + occurredTo: null, + eventKind: "self_health_event", + summary: "那年秋天身体垮过一截", + }; +} + +test("canonicalCollectDomain maps health and occupation-as-other", () => { + assert.equal(canonicalCollectDomain("health"), "health_pressure"); + assert.equal(canonicalCollectDomain("health_pressure"), "health_pressure"); + assert.equal( + canonicalCollectDomain("other", "collect:occupation:collect_method_evidence"), + "occupation", + ); + assert.equal(canonicalCollectDomain("other", "collect:other:opening"), "other"); + assert.equal(canonicalCollectDomain("career"), "career"); +}); + +test("H1 holdout skips health_pressure after a skipped health focus", () => { + const declined = declinedCollectDomains([{ + target_domain: "health", + status: "skipped", + intent: "collect_method_evidence", + question_id: "collect:health_pressure:collect_method_evidence", + }]); + assert.equal(declinedHasDomain(declined, "health_pressure"), true); + const next = holdoutFollowupFor({ + evidence: [ + { + id: "e-edu", + status: "confirmed", + domain: "education", + datePrecision: "month", + occurredFrom: "2016-09-01", + occurredTo: null, + eventKind: "education_start", + }, + { + id: "e-career", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2018-04-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-rel", + status: "confirmed", + domain: "relationship", + datePrecision: "month", + occurredFrom: "2021-05-01", + occurredTo: null, + eventKind: "relationship_start", + }, + ], + oosBlindPrompts: [{ + domain: "health_pressure", + user_meaning: "校时还没用过健康压力这条线。", + used_for_scoring: false, + }], + }, declined); + assert.equal(next, null); +}); + +test("H3 reverse-verify drops a declined health_pressure probe", () => { + const declined = declinedCollectDomains([{ + target_domain: "health", + status: "declined", + intent: "collect_method_evidence", + question_id: "collect:health_pressure:collect_method_evidence", + }]); + const remaining = remainingReverseVerifyProbes( + [existenceProbe("health_pressure", 2024)], + [], + declined, + new Set(), + "1997-08-08", + ); + assert.equal(remaining.length, 0); +}); + +test("H4 conflict probes drop a declined health_pressure probe", () => { + const declined = declinedCollectDomains([{ + target_domain: "health", + status: "declined", + intent: "collect_method_evidence", + question_id: "collect:health_pressure:collect_method_evidence", + }]); + const remaining = remainingConflictProbes( + [existenceProbe("health_pressure", 2024)], + [], + declined, + new Set(), + "1997-08-08", + ); + assert.equal(remaining.length, 0); +}); + +test("H5 ledger health 2024-10 covers a health_pressure 2024 probe", () => { + const remaining = remainingReverseVerifyProbes( + [existenceProbe("health_pressure", 2024)], + [datedHealth()], + new Set(), + new Set(), + "1997-08-08", + ); + assert.equal(remaining.length, 0); + const conflicts = remainingConflictProbes( + [existenceProbe("health_pressure", 2024)], + [datedHealth()], + new Set(), + new Set(), + "1997-08-08", + ); + assert.equal(conflicts.length, 0); +}); + +test("H9 ledger health maps to D30 without relying on summary keywords", () => { + const mentioned = mentionedVargaKeysFromLedgerEvidence([{ + status: "confirmed", + domain: "health", + summary: "普通摘要不含关键词", + }]); + assert.equal(mentioned.includes("varga.d30"), true); +}); + +test("H8 volunteered health aliases health_pressure so D30 remaining splits enter", () => { + const hidden = remainingVargaSplits( + ["05:00", "05:04"], + [{ layer: "d30", at: "05:04" }], + ); + assert.equal(hidden.length, 0); + const shown = remainingVargaSplits( + ["05:00", "05:04"], + [{ layer: "d30", at: "05:04" }], + volunteeredDomainsFromEvidence([{ status: "confirmed", domain: "health" }]), + ); + assert.equal(shown[0]?.layer, "d30"); +}); + +test("O1 occupation declined as other plus collect:occupation id covers occupation without career", () => { + const declined = declinedCollectDomains([{ + target_domain: "other", + status: "declined", + intent: "collect_method_evidence", + question_id: "collect:occupation:collect_method_evidence", + }]); + assert.equal(declined.has("occupation"), true); + assert.equal(declined.has("other"), false); + const plan = buildMethodFollowupPlan({ + evidence: [{ + id: "e-edu", + status: "confirmed", + domain: "education", + datePrecision: "month", + occurredFrom: "2016-09-01", + occurredTo: null, + eventKind: "education_start", + }], + declinedTopics: [{ + target_domain: "other", + status: "declined", + intent: "collect_method_evidence", + question_id: "collect:occupation:collect_method_evidence", + }], + sessionOutcome: "collect_evidence", + birthDate: "1997-08-08", + }); + assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); + assert.notEqual(plan.next_followup?.domain, "occupation"); +}); + +test("method-followup health compares only through domain-alias", () => { + const followup = readFileSync( + new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), + "utf8", + ); + const alias = readFileSync( + new URL("../src/lib/rectification-agentic/v9/domain-alias.ts", import.meta.url), + "utf8", + ); + assert.match(alias, /export function canonicalCollectDomain/); + assert.match(alias, /"health"/); + assert.doesNotMatch(followup, /"health"/); +}); diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 92e1d328..4a889628 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -228,6 +228,13 @@ EXISTENCE_NEARBY_YEARS = { _SEMANTIC_YEAR = re.compile(r"^(?P[a-z_]+)\.(?P(?:19|20)\d{2})(?:\.|$)") +def canonical_domain(raw: object) -> str: + value = str(raw or "").strip() + if value == "health": + return "health_pressure" + return value + + def asked_years_for_domain(asked_probe_keys: Sequence[str] | None, domain: str) -> set[int]: years: set[int] = set() prefix = f"{domain}." @@ -1248,17 +1255,18 @@ def _quality_encoded(event: dict[str, Any], domain: str) -> bool: def _year_quality_encoded(events: Sequence[dict[str, Any]], domain: str, year: int) -> bool: return any( isinstance(event, dict) - and str(event.get("domain") or "") == domain + and canonical_domain(event.get("domain")) == canonical_domain(domain) and _event_year(event) == year - and _quality_encoded(event, domain) + and _quality_encoded(event, canonical_domain(domain)) for event in events ) def _event_years(events: Sequence[dict[str, Any]], domain: str) -> set[int]: years: set[int] = set() + wanted = canonical_domain(domain) for event in events: - if not isinstance(event, dict) or str(event.get("domain") or "") != domain: + if not isinstance(event, dict) or canonical_domain(event.get("domain")) != wanted: continue year = _event_year(event) if year is not None: diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index 6190d10c..afde0756 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -588,8 +588,10 @@ def precision_stage(scan: dict[str, Any], event_count: int) -> dict[str, Any]: def oos_blind_prompts(request: dict[str, Any]) -> list[dict[str, Any]]: + from scripts.rectification.event_probes import canonical_domain + covered = { - str(event.get("domain")) + canonical_domain(event.get("domain")) for event in request.get("events") or [] if isinstance(event, dict) and event.get("domain") } diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 551168b1..e7a92bd9 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -1146,5 +1146,42 @@ class QualityDistinguishDedupeTests(unittest.TestCase): self.assertFalse(any(item["year"] in {2017, 2018, 2019} for item in career), career) +class DomainAliasTests(unittest.TestCase): + def test_health_ledger_aliases_health_pressure_for_years_oos_and_d30_order(self) -> None: + from scripts.rectification.event_probes import ( + _event_years, + _existence_blocked_years, + _probe_domains, + canonical_domain, + ) + from scripts.rectification.refinement_packet import oos_blind_prompts + + health_event = { + "id": "00000000-0000-4000-8000-000000000099", + "domain": "health", + "summary": "2024年10月受伤", + "date": "2024-10-01", + "precision": "month", + } + years = _event_years([health_event], "health_pressure") + self.assertIn(2024, years) + self.assertIn(2024, _existence_blocked_years("health_pressure", years)) + self.assertEqual(canonical_domain("health"), "health_pressure") + volunteered = {canonical_domain(health_event["domain"])} + self.assertIn("health_pressure", volunteered) + self.assertIn("health_pressure", _probe_domains({"d30"}, [health_event])) + + covered_catalog = [ + {"domain": "relationship", "date": "2016-03-01"}, + {"domain": "career", "date": "2018-04-01"}, + {"domain": "family", "date": "2019-05-01"}, + {"domain": "education", "date": "2015-06-01"}, + {"domain": "finance", "date": "2020-07-01"}, + health_event, + ] + prompts = oos_blind_prompts({"events": covered_catalog}) + self.assertFalse(any(item["domain"] == "health_pressure" for item in prompts), prompts) + + if __name__ == "__main__": unittest.main()