fix(rectification): compare health and occupation aliases through one merge (BUG-672)
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:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-13 — 健康与职业别名在比较时归并成同一领域
|
||||
|
||||
生时校正里,账本记的「健康」和题库里的「健康压力」是同一条线:跳过或已经报过健康后,不会再问同一年的健康核对题,盘外提示也不会再说「还没用过健康」。职业题虽然落库写成「其他」,拒答后也不会在事业还没覆盖时再问一遍职业。比较只走一处归并,数据库字段不改。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-13 — 参考题点一次连出两道,问完入口消失
|
||||
|
||||
生时校正「目前范围」卡下,若还有未答的 D9/D10 参考题,可以点「再答两道参考题微调排序」。点一次会先出相处方式、答完自动出做事风格;两道答完后入口消失,回到目前范围,不会出现红字「现在没有可答的参考题」。这两道仍只微调排序,不改能不能采用。Skill 10.0.26(定向补事写明第四选项「这条先跳过」;旧案仍绑定 10.0.25)。
|
||||
|
||||
@@ -47,3 +47,7 @@ _避免使用_:聊天日志、客服工单
|
||||
**处理状态**:
|
||||
对话质量记录的内部处理进度,取值为待处理、处理中、已解决或忽略。
|
||||
_避免使用_:用户反馈状态、通知状态
|
||||
|
||||
**领域别名**:
|
||||
生时校正里,健康在证据账本和焦点层记为 `health`,计划层、引擎探针和盘外提示用 `health_pressure`;职业焦点落库为 `other`(题号仍是 `collect:occupation:`),计划层与账本用 `occupation`。比较时两侧都要先归并,不能按字面相等。数据库 `target_domain` 约束不改。
|
||||
_避免使用_:把 `health` 和 `health_pressure` 当成两个领域、把职业拒答当成 `other` 领域
|
||||
|
||||
@@ -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 已归并,其余站点未归并)
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -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。
|
||||
@@ -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) | 待验收 |
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user