fix(rectification): compare health and occupation aliases through one merge (BUG-672)
Independent Staging Quality Gate / validate (push) Successful in 12m12s
Independent Staging Quality Gate / publish (push) Successful in 24m21s

Skipped or already-reported health must not be asked again as health_pressure, and occupation declined as other must still close the occupation line.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-13 23:08:55 +08:00
co-authored by Cursor
parent 9912760104
commit 14d199e694
13 changed files with 442 additions and 57 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-13 — 健康与职业别名在比较时归并成同一领域
生时校正里,账本记的「健康」和题库里的「健康压力」是同一条线:跳过或已经报过健康后,不会再问同一年的健康核对题,盘外提示也不会再说「还没用过健康」。职业题虽然落库写成「其他」,拒答后也不会在事业还没覆盖时再问一遍职业。比较只走一处归并,数据库字段不改。Skill 版本不变。
## 2026-09-13 — 参考题点一次连出两道,问完入口消失
生时校正「目前范围」卡下,若还有未答的 D9/D10 参考题,可以点「再答两道参考题微调排序」。点一次会先出相处方式、答完自动出做事风格;两道答完后入口消失,回到目前范围,不会出现红字「现在没有可答的参考题」。这两道仍只微调排序,不改能不能采用。Skill 10.0.26(定向补事写明第四选项「这条先跳过」;旧案仍绑定 10.0.25)。
+4
View File
@@ -47,3 +47,7 @@ _避免使用_:聊天日志、客服工单
**处理状态**
对话质量记录的内部处理进度,取值为待处理、处理中、已解决或忽略。
_避免使用_:用户反馈状态、通知状态
**领域别名**
生时校正里,健康在证据账本和焦点层记为 `health`,计划层、引擎探针和盘外提示用 `health_pressure`;职业焦点落库为 `other`(题号仍是 `collect:occupation:`),计划层与账本用 `occupation`。比较时两侧都要先归并,不能按字面相等。数据库 `target_domain` 约束不改。
_避免使用_:把 `health``health_pressure` 当成两个领域、把职业拒答当成 `other` 领域
+16
View File
@@ -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 occupiedBUG-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 已归并,其余站点未归并)
- 修复版本:待发布
@@ -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 决策 1holdout declined 归并)已在 staging。本单补 H3/H4/H5/H6/H7/H8/H9/O1H1/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 error120 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。
+1 -1
View File
@@ -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-623625Skill 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-626627 |
| `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-629Skill 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-631632 |
| `TASK-rectification-evidence-turn-empty-answer-20260910.md` | `PROGRESS-rectification-evidence-turn-empty-answer-20260910.md` | 证据轮模型无正文被判整轮失败:证据、评分、下一问都已落库却只剩『没有拿到下一个问题』(BUG-633);答题旁白只说『范围没变』、时间线写死『还在收窄』(BUG-634) | 待验收 |
@@ -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<string>();
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 [];
}
@@ -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;
}
@@ -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<string>,
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;
}
@@ -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<string>): boolean {
return declined.has("health") || declined.has("health_pressure");
return declinedHasDomain(declined, "health_pressure");
}
function topicDomain(topic: Readonly<Record<string, unknown>>): 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<string>,
@@ -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<string>,
answeredYes: ReadonlySet<string> = 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<string> {
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<string>): Set<string> {
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);
}
@@ -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"/);
});
+11 -3
View File
@@ -228,6 +228,13 @@ EXISTENCE_NEARBY_YEARS = {
_SEMANTIC_YEAR = re.compile(r"^(?P<domain>[a-z_]+)\.(?P<year>(?: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:
+3 -1
View File
@@ -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")
}
+37
View File
@@ -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()