From 40c623edf654935d7de66061e8881bf3205c0abc Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 4 Sep 2026 23:47:47 +0800 Subject: [PATCH] fix(rectification): bind quality cards to the followup probe Graduation no longer gets the college-experience question, and identical D24 splits only ask once. Co-authored-by: Cursor --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 32 ++++ ...ification-quality-probe-dedupe-20260904.md | 44 +++++ docs/tasks/README.md | 2 +- ...ification-quality-probe-dedupe-20260904.md | 28 +++ .../rectification-agentic/v9/choice-card.ts | 25 ++- .../tests/rectification-choice-card.test.ts | 127 ++++++++++++++ scripts/rectification/event_probes.py | 48 +++++- tests/test_rectification_event_probes.py | 161 ++++++++++++++++++ 9 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md create mode 100644 docs/testing/rectification-quality-probe-dedupe-20260904.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e6336a9b..5534e2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-04 — 入学毕业不再连问两道相同的上大学发挥题 + +候选区分阶段,同一套发挥选项只问一次。入学和毕业如果切分相同,只问入学那年;毕业不再套「调剂 / 发挥失常」这道题。Skill 版本仍是 10.0.14。 + ## 2026-09-04 — 采用后核对不再重复已问题,跳过只跳本题 采用代表分钟后,核对题会跳过采集阶段已经问过的前事。核对卡上的「这题跳过」只关掉这一题;没有下一题时收一行「前事核对到这里…」,不再念「选一个先用着」,也不再出现「没有拿到下一个问题」。采用旁白只承诺服务端真会问的核对;采集口语不再加「某年前后,」前缀。Skill 版本仍是 10.0.14。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 47be120e..7099fad1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8340,3 +8340,35 @@ - 相关记录:BUG-414、BUG-418 - 复发自:无 - 修复版本:待发布 + +## BUG-540 | 候选区分阶段同一道「上大学」发挥题问两次,第二次绑错证据 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`choice-card.ts::pickProbe`、`ChoiceCardFollowup.probe_id` +- 用户现象:候选区分阶段连续两张选择卡题干、选项完全相同(「某年某月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清」)。用户两次都作答,第二次答完范围继续收窄。 +- 触发条件:学业域已核实入学与毕业两条事件;引擎对两条各发一条 `known_event_quality`;第二张卡的 followup 指向毕业探针。 +- 根因:`pickProbe` 在 `choice_kind === "event_quality"` 时取池里第一条质量探针就返回,`semantic_key` 匹配写在后面,永远轮不到。`periodFor` 与 `eventQuestionPrompt` 都从这条错探针取日期和题干。用户第二次是对着入学题给毕业探针打分。 +- 修复:匹配顺序改为 `probe_id` → `semantic_key` → 无键时才按种类兜底。有键但找不到对应探针时返回 `null`,不出卡,不再退回同种类第一条。`question_id` 在有键时带上该键,避免两张质量卡共用一个 id。 +- 验证:同域两条质量探针、followup 指向第二条时,`buildChoiceFrame` 的 `question_id`、日期标签、`user_meaning` 全部来自第二条;指向第一条不受影响。有键找不到探针时 `buildChoiceFrame` 为 `null`。`rectification-choice-card` 既有 varga_style / existence 断言仍过。 +- 防复发:不得先按 `choice_kind` 取第一条质量探针再匹配 `semantic_key`。有 `probe_id` / `semantic_key` 时不得退回同种类第一条。 +- 相关记录:BUG-390、BUG-541 +- 复发自:无 +- 修复版本:待发布 + +## BUG-541 | 毕业等学业 kind 套高考发挥题,同切分质量探针占满名额 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`event_probes.py::_quality_distinguish_probes` +- 用户现象:毕业事件也被写成「上大学 / 调剂 / 发挥失常」。即便卡面绑对了,第二道仍不带来新切分信息。 +- 触发条件:学业域同时有入学与毕业;两条 `known_event_quality` 的 yes/no 分钟集合相同(同一 D24 星座切分),只有 `semantic_key` / `target_evidence_id` / `candidate_split_hash` 不同。 +- 根因:(1) `_quality_user_meaning` 与 `QUALITY_DISTINGUISH_OPTIONS["education"]` 只看 `domain == "education"`,不看 `event_kind`。采集线会产生 `education_completion`,模板仍是 BUG-390 的高考发挥题。(2) `candidate_split_hash` 掺了年份,同分组拦不住;`MAX_QUALITY_DISTINGUISH_PROBES = 2` 被两条信息相同的探针占满。 +- 修复:质量探针只对 `education_start` / `education_change` / `education_interruption` 发出;`education_completion` 与其它 kind、以及非学业域不发。同域同 yes/no 集合只保留信息增益最高、并列取时间最早的一条;被去掉的不计入名额。不改四选项文案,不改 `candidate_split_hash`,不新造毕业体验模板。 +- 验证:入学 + 毕业 → 一条质量探针且 `target_evidence_id` 指向入学。仅毕业 → 零条质量探针;训练门仍开时仍有 dasha 存在性探针。两条可发事件同分组 → 一条;`_select_quality_distinguish_rows` 不同分组 → 两条,第三条不超过上限 2。`test_probe_question_contract` 四选项合同仍过。 +- 防复发:学业质量探针必须看 `event_kind`(或 `kind`),不得对 `education_completion` 发。同域同 yes/no 集合不得发第二条。不得改 `QUALITY_DISTINGUISH_OPTIONS` 文案或 `candidate_split_hash` 算法来「修」去重。 +- 相关记录:BUG-390、BUG-540 +- 复发自:BUG-390(质量探针只对学业发出,但学业内 kind 未收紧) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md b/docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md new file mode 100644 index 00000000..baba9d10 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md @@ -0,0 +1,44 @@ +# PROGRESS · 学业质量探针去重(2026-09-04) + +工作树:`.worktrees/rectification-quality-probe-dedupe-20260904` +分支:`codex/rectification-quality-probe-dedupe-20260904` +基线:任务书写 `origin/staging` `a75929c1`;开工时 `origin/staging` 已是 `a68fd01f`(含本任务书)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`(2041 行)。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 5.1 `pickProbe` 按键选探针 | 完成 | BUG-540 | +| 5.2 质量探针按事件 kind 发 | 完成 | BUG-541 | +| 5.3 同域同分组去重 | 完成 | BUG-541 | +| 5.4 记录 | 完成 | — | + +## 编号 + +开工时 `docs/BUG_HISTORY.md` 最大号 BUG-539。本单 **BUG-540、BUG-541**。未占用并行任务书预留的 BUG-542。 + +## 实现要点 + +- `pickProbe`:`probe_id` → `semantic_key` → 无键才按 `choice_kind` 兜底。有键找不到 → `null`,`buildChoiceFrame` 不出卡。`ChoiceCardFollowup` 补 `probe_id`;`makeFollowup` 已有字段,透传即可。 +- `question_id` 在 followup 带 `semantic_key` / `probe_id` 时拼进该键,避免两张质量卡共用 `d5_education:education_style:score`。 +- `_quality_distinguish_probes` 只对 `event_kind ∈ {education_start, education_change, education_interruption}` 的学业事件发;`education_completion` 与其它 kind、非学业域不发。字段读 `event_kind` 或 `kind`。 +- 同域同 yes/no 分钟集合只留信息增益最高、并列取时间最早的一条;被去掉的不计入 `MAX_QUALITY_DISTINGUISH_PROBES`。`candidate_split_hash` 与四选项文案未改。 + +## 偏离 + +- 任务书验收「不同分组 → 两条」在 `_quality_distinguish_probes` 里同域事件共用同一套 D24 yes/no,单次调用不会出现不同分组。不同分组与上限 2 的断言写在 `_select_quality_distinguish_rows`(5.3 实际去重函数)。 +- 「仅毕业仍参与普通时间探针」:3 条事件会被 holdout 抽走 1 条,训练门关、整表空。测试补了第四条家人事件让训练门仍开,断言仍有 dasha 存在性探针、零条质量探针。 +- 有键找不到探针 → 不出卡:新测试覆盖;既有 `rectification-choice-card` 夹具没有出现过这种情况。 +- 非学业域的区分质量探针一并停发(与 BUG-390「质量探针只对学业发出」对齐)。既有「事业不发质量卡」测试仍过。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `.venv/bin/python -m pytest tests/test_rectification_event_probes.py tests/test_probe_question_contract.py -q` | 40 passed | +| `npx tsx --test tests/rectification-choice-card.test.ts tests/rectification-probe-question-contract.test.ts` | 47/47 | +| 上两项 + `rectification-eight-method` + `rectification-answer-choice` | 140/140 | +| `tsc --noEmit` | 0 错 | +| `npx eslint . --quiet` | 0 error | +| `page.tsx` | 2041 行,未改 | +| `run_quality_gate.py --profile quick` 内 `npm test` | 2682 tests / 2673 pass / **9 fail**,全部是 Docker 迁移 `database migration failed`(`database-foundation` / `database-local-business` / redeem / identity / secret-refs / v9×3 / Skill migration)。与本单无关,属既有环境缺口。 | + +未跑全量 `npm test`(时间);未做浏览器真人走查(无登录态)。清单:`docs/testing/rectification-quality-probe-dedupe-20260904.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 0ad2bb39..38dcbbea 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -50,7 +50,7 @@ | `TASK-rectification-collect-direction-20260904.md` | `PROGRESS-rectification-collect-direction-20260904.md` | 可评分事件 2 条时盘外核对抢跑到刚拒答的家人领域,Agent 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 已验收:5.1/5.3/5.4 通过;5.2 顺序错位(任务书责任)见修复单 | `codex/rectification-collect-direction-20260904`(BUG-527~530) | | `TASK-rectification-collect-direction-fix-20260904.md` | `PROGRESS-rectification-collect-direction-fix-20260904.md` | dated 补采集分支插在方法轮转之前,新案例第二问被家人抢占感情/事业;`7ee7f825` CSS 与合同测试脱节挡住门禁 | 已验收通过(0be51e65),待 staging 部署核对 | `codex/rectification-collect-direction-fix-20260904`(BUG-531~532) | | `TASK-rectification-post-adopt-verify-20260904.md` | `PROGRESS-rectification-post-adopt-verify-20260904.md` | 采用后核对题重复已问题;核对卡「这题跳过」等于整案停止、已采用仍念"选一个先用着"、前端"没有拿到下一个问题";采用旁白承诺的 holdout/OOS 核对与实际计划不同源(原任务书 §4 错误);家庭采集题带年份前缀自相矛盾 | 待验收 | `codex/rectification-post-adopt-verify-20260904`(BUG-536~539) | -| `TASK-rectification-quality-probe-dedupe-20260904.md` | — | 候选区分阶段同一道"上大学"题问两次:`pickProbe` 按种类取第一条质量探针无视 `semantic_key`(第二次答案绑到错的证据);引擎对毕业等学业 kind 也套"高考发挥"模板;同域同分组质量探针不去重 | 待执行 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-540 起) | +| `TASK-rectification-quality-probe-dedupe-20260904.md` | `PROGRESS-rectification-quality-probe-dedupe-20260904.md` | 候选区分阶段同一道"上大学"题问两次:`pickProbe` 按种类取第一条质量探针无视 `semantic_key`(第二次答案绑到错的证据);引擎对毕业等学业 kind 也套"高考发挥"模板;同域同分组质量探针不去重 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-540~541) | | `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-quality-probe-dedupe-20260904.md b/docs/testing/rectification-quality-probe-dedupe-20260904.md new file mode 100644 index 00000000..06bd8ab8 --- /dev/null +++ b/docs/testing/rectification-quality-probe-dedupe-20260904.md @@ -0,0 +1,28 @@ +# Staging 人肉复核 · 学业质量探针去重(2026-09-04) + +给产品负责人。实现者无法在真实校正会话里点选。测之前先做第 0 条。 + +本单对应 BUG-540、BUG-541。不要把真实案例里的年份、事件或候选分钟写进任何记录。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 入学 + 毕业最多一道「上大学」发挥题(P0 · BUG-540 / BUG-541) + +1. 新建生时校正。 +2. 采集阶段录入两条学业事件:一条入学、一条毕业(相隔几年即可),并补齐其它领域,走到**候选区分**(出现发挥/体验点选卡的那一段,不是采用后核对)。 +- ✅ 预期:最多出现一次「那次上大学,更接近如愿、将就调剂、发挥失常还是说不清」。题干年份 = **入学**年份,不是毕业年份。 +- ❌ 改造前:同一张入学发挥卡连出两次;第二次计分其实绑在毕业事件上。 + +## 2. 题干与选项对得上当前这一问(P0 · BUG-540) + +若第 1 条出现了发挥卡: +- ✅ 预期:日期标签、题干、「上大学」这句都指向同一年(入学那年)。不会一边写入学年、一边打分到毕业。 +- ❌ 改造前:卡面是入学题,后台 `probe_id` 却是毕业。 + +## 3. 只有毕业、没有入学时(P1 · BUG-541) + +新案例只记毕业、不记入学,走到区分阶段: +- ✅ 预期:不会出现套在毕业上的「上大学 / 调剂 / 发挥失常」题。仍可问其它带年份的存在性区分题。 +- ❌ 改造前:毕业也会被写成高考发挥题。 diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index be163c28..b4895c82 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -100,6 +100,7 @@ export type ChoiceCardFollowup = Readonly<{ choice_kind?: EventProbeChoiceKind; style_options?: readonly EventProbeStyleOption[]; semantic_key?: string; + probe_id?: string; }>; export type ChoiceCardEvidence = Readonly<{ @@ -230,12 +231,28 @@ function followupDomain(followup: ChoiceCardFollowup): string | null { return THEME_DOMAIN[followup.ask_theme] ?? null; } +function probeMatchesId(item: DiscriminatingEventProbe, probeId: string): boolean { + return item.semantic_key === probeId; +} + function pickProbe( probes: readonly DiscriminatingEventProbe[] | undefined, domain: string | null, followup?: ChoiceCardFollowup, ): DiscriminatingEventProbe | null { if (!probes?.length) return null; + const probeId = followup?.probe_id?.trim() ?? ""; + const semanticKey = followup?.semantic_key?.trim() ?? ""; + const hasKey = Boolean(probeId || semanticKey); + if (probeId) { + const byId = probes.find((item) => probeMatchesId(item, probeId)); + if (byId) return byId; + } + if (semanticKey) { + const keyed = probes.find((item) => item.semantic_key === semanticKey); + if (keyed) return keyed; + } + if (hasKey) return null; const inDomain = domain ? probes.filter((item) => item.domain === domain) : [...probes]; const pool = inDomain.length > 0 ? inDomain : probes; if (followup?.choice_kind === "event_quality") { @@ -244,10 +261,6 @@ function pickProbe( ); if (quality) return quality; } - if (followup?.semantic_key) { - const keyed = pool.find((item) => item.semantic_key === followup.semantic_key); - if (keyed) return keyed; - } return pool[0] ?? probes[0] ?? null; } @@ -386,8 +399,10 @@ export function buildChoiceFrame( const skipQuestion = skipThisProbe || followup.intent === "out_of_sample_check" || followup.source === "oos_blind"; + const probeKey = followup.semantic_key?.trim() || followup.probe_id?.trim() || ""; + const questionBase = `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`; return { - question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`, + question_id: probeKey ? `${questionBase}:${probeKey}` : questionBase, method_id: followup.method_id, period: periodFor(input.evidence, domain, input.probes, input.birthDate, followup), prompt: hypothesis.prompt, diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 04294ac9..dc351e81 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -1666,3 +1666,130 @@ test("GET does not mint a tap card from an out-of-sample spoken collect focus", }); assert.equal(card, null); }); + +const EDUCATION_QUALITY_OPTIONS = [ + { label: "发挥明显失常", answer_class: "yes" }, + { label: "只是将就调剂", answer_class: "weak_yes" }, + { label: "基本如愿录取", answer_class: "no" }, + { label: "当时说不清楚", answer_class: "unsure" }, +] as const; + +const QUALITY_SPLIT_OUTCOMES = [ + { answer_class: "yes", supports: ["05:00", "05:01"], conflicts: ["05:10", "05:11"] }, + { answer_class: "weak_yes", supports: ["05:00", "05:01"], conflicts: ["05:10", "05:11"] }, + { answer_class: "no", supports: ["05:10", "05:11"], conflicts: ["05:00", "05:01"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, +] as const; + +function educationQualityProbe(input: { + semanticKey: string; + evidenceId: string; + year: number; + month: number; + meaning: string; + dateLabel: string; +}): DiscriminatingEventProbe { + return { + year: input.year, + month: input.month, + year_label: `${input.year} 年 ${input.month} 月前后`, + domain: "education", + event_family: "学业或考试发挥失常、压力特别大", + source: "known_event_quality", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: input.meaning, + role: "distinguish", + phase: "candidate_discriminator", + information_gain: 0.5, + semantic_key: input.semanticKey, + candidate_split_hash: `${input.semanticKey}:split`, + target_evidence_id: input.evidenceId, + display_date_label: input.dateLabel, + choice_kind: "event_quality", + style_options: EDUCATION_QUALITY_OPTIONS, + expected_outcomes: QUALITY_SPLIT_OUTCOMES, + candidate_ids: ["05:00", "05:01", "05:10", "05:11"], + }; +} + +test("event_quality cards bind to the followup probe, not the first same-kind probe", () => { + const startProbe = educationQualityProbe({ + semanticKey: "education.2014.known_event_quality", + evidenceId: "11111111-1111-4111-8111-111111111111", + year: 2014, + month: 9, + dateLabel: "2014 年 9 月", + meaning: "2014 年 9 月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。", + }); + const laterProbe = educationQualityProbe({ + semanticKey: "education.2018.known_event_quality", + evidenceId: "22222222-2222-4222-8222-222222222222", + year: 2018, + month: 6, + dateLabel: "2018 年 6 月", + meaning: "2018 年 6 月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。", + }); + const probes = [startProbe, laterProbe]; + const laterFrame = buildChoiceFrame({ + method_id: "d5_education", + ask_theme: "education_style", + domain: "education", + user_prompt_hint: "unused", + intent: "distinguish_candidates", + choice_kind: "event_quality", + semantic_key: laterProbe.semantic_key, + probe_id: laterProbe.semantic_key, + style_options: EDUCATION_QUALITY_OPTIONS, + }, { probes }); + assert.ok(laterFrame); + assert.match(laterFrame.question_id, /education\.2018\.known_event_quality/); + assert.doesNotMatch(laterFrame.question_id, /education\.2014\.known_event_quality/); + assert.match(laterFrame.period, /2018 年 6 月/); + assert.doesNotMatch(laterFrame.period, /2014/); + assert.match(laterFrame.prompt, /2018 年 6 月那次上大学/); + assert.doesNotMatch(laterFrame.prompt, /2014/); + assert.match(laterFrame.why, /2018 年 6 月那次上大学/); + assert.doesNotMatch(laterFrame.why, /2014/); + + const startFrame = buildChoiceFrame({ + method_id: "d5_education", + ask_theme: "education_style", + domain: "education", + user_prompt_hint: "unused", + intent: "distinguish_candidates", + choice_kind: "event_quality", + semantic_key: startProbe.semantic_key, + probe_id: startProbe.semantic_key, + style_options: EDUCATION_QUALITY_OPTIONS, + }, { probes }); + assert.ok(startFrame); + assert.match(startFrame.question_id, /education\.2014\.known_event_quality/); + assert.match(startFrame.period, /2014 年 9 月/); + assert.match(startFrame.prompt, /2014 年 9 月那次上大学/); + assert.match(startFrame.why, /2014 年 9 月那次上大学/); + assert.doesNotMatch(startFrame.prompt, /2018/); +}); + +test("event_quality followup with a missing probe key does not fall back to another quality probe", () => { + const startProbe = educationQualityProbe({ + semanticKey: "education.2014.known_event_quality", + evidenceId: "11111111-1111-4111-8111-111111111111", + year: 2014, + month: 9, + dateLabel: "2014 年 9 月", + meaning: "2014 年 9 月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。", + }); + const frame = buildChoiceFrame({ + method_id: "d5_education", + ask_theme: "education_style", + domain: "education", + user_prompt_hint: "unused", + choice_kind: "event_quality", + semantic_key: "education.2018.known_event_quality", + probe_id: "education.2018.known_event_quality", + style_options: EDUCATION_QUALITY_OPTIONS, + }, { probes: [startProbe] }); + assert.equal(frame, null); +}); diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index ba4a0b8a..2635d1b3 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -62,6 +62,11 @@ MAX_PROBES_PER_DOMAIN = 3 MAX_BOUNDARY_CANDIDATES_PER_DOMAIN = 8 MIN_BOUNDARY_DAYS = 45 MAX_QUALITY_DISTINGUISH_PROBES = 2 +QUALITY_DISTINGUISH_EVENT_KINDS = frozenset({ + "education_start", + "education_change", + "education_interruption", +}) ANSWER_PRIOR_TABLE_VERSION = "rectification-answer-priors-v1" DOMINANT_ANSWER_PRIOR = 0.8 # Conservative population rates, not fitted from product users. @@ -884,6 +889,43 @@ def _display_date_label(event: dict[str, Any]) -> str: return f"{year} 年" +def _event_kind_name(event: dict[str, Any]) -> str: + return str(event.get("event_kind") or event.get("kind") or "") + + +def _quality_event_allowed(event: dict[str, Any], domain: str) -> bool: + if domain != "education": + return False + return _event_kind_name(event) in QUALITY_DISTINGUISH_EVENT_KINDS + + +def _quality_split_sets(probe: dict[str, Any]) -> tuple[frozenset[str], frozenset[str]]: + outcomes = probe.get("expected_outcomes") or [] + yes = next((row for row in outcomes if isinstance(row, dict) and row.get("answer_class") == "yes"), {}) + no = next((row for row in outcomes if isinstance(row, dict) and row.get("answer_class") == "no"), {}) + yes_times = yes.get("supports") if isinstance(yes, dict) else () + no_times = no.get("supports") if isinstance(no, dict) else () + return frozenset(str(item) for item in (yes_times or ())), frozenset(str(item) for item in (no_times or ())) + + +def _quality_probe_rank(probe: dict[str, Any]) -> tuple[float, int, int]: + year = int(probe["year"]) if isinstance(probe.get("year"), int) else 9999 + month = int(probe["month"]) if isinstance(probe.get("month"), int) else 12 + return (-float(probe.get("information_gain") or 0), year, month) + + +def _select_quality_distinguish_rows(candidates: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: + best: dict[tuple[str, frozenset[str], frozenset[str]], dict[str, Any]] = {} + for probe in candidates: + key = (str(probe.get("domain") or ""), *_quality_split_sets(probe)) + current = best.get(key) + if current is None or _quality_probe_rank(probe) < _quality_probe_rank(current): + best[key] = probe + selected = list(best.values()) + selected.sort(key=_quality_probe_rank) + return selected[:MAX_QUALITY_DISTINGUISH_PROBES] + + def _quality_user_meaning(event: dict[str, Any], domain: str) -> str: label = _display_date_label(event) if domain == "education": @@ -918,6 +960,8 @@ def _quality_distinguish_probes( layer = DOMAIN_QUALITY_LAYER.get(domain) if not event_id or year is None or layer is None or domain not in DOMAIN_CATALOG: continue + if not _quality_event_allowed(event, domain): + continue if event_id in holdout_ids or f"{domain}:{year}" in holdout_keys: continue if _quality_encoded(event, domain): @@ -989,9 +1033,7 @@ def _quality_distinguish_probes( if distinguish_contract_errors(probe): continue rows.append(_apply_prior_ranking(probe)) - if len(rows) >= MAX_QUALITY_DISTINGUISH_PROBES: - break - return rows + return _select_quality_distinguish_rows(rows) def _year_activated(rule_ids: Sequence[str]) -> bool: diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 0dee4d0e..1d387ddd 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -9,7 +9,10 @@ from scripts.rectification.event_probes import ( MAX_COLLECTION_PROBES, MAX_PROBES, MAX_PROBES_PER_DOMAIN, + MAX_QUALITY_DISTINGUISH_PROBES, _agent_brief, + _quality_distinguish_probes, + _select_quality_distinguish_rows, discriminating_event_probes, event_clarification_probes, evidence_collection_probes, @@ -900,5 +903,163 @@ class EventProbesTest(unittest.TestCase): self.assertEqual({int(item["year"]) for item in relocation}, {2016, 2019, 2022}) +def _education_event(event_id: str, kind: str, date: str, summary: str) -> dict: + return { + "id": event_id, + "domain": "education", + "event_kind": kind, + "summary": summary, + "date": date, + "precision": "month", + } + + +def _d24_split_clusters() -> list[dict]: + return [ + { + "representative": {"feature": {"varga_ascendants": {"D24": 1}}}, + "times": ["05:00", "05:01"], + }, + { + "representative": {"feature": {"varga_ascendants": {"D24": 2}}}, + "times": ["05:10", "05:11"], + }, + ] + + +def _quality_rows(*events: dict) -> list[dict]: + return _quality_distinguish_probes( + list(events), + _d24_split_clusters(), + set_version="quality-dedupe-set", + holdout_ids=set(), + holdout_keys=set(), + ) + + +class QualityDistinguishDedupeTests(unittest.TestCase): + def test_start_and_completion_emit_one_quality_probe_for_start(self) -> None: + start = _education_event( + "00000000-0000-4000-8000-000000000021", + "education_start", + "2014-09-01", + "入学", + ) + completion = _education_event( + "00000000-0000-4000-8000-000000000022", + "education_completion", + "2018-06-01", + "毕业", + ) + rows = _quality_rows(start, completion) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["target_evidence_id"], start["id"]) + self.assertEqual(rows[0]["year"], 2014) + self.assertNotIn(completion["id"], [item["target_evidence_id"] for item in rows]) + + def test_completion_alone_emits_no_quality_probe(self) -> None: + completion = _education_event( + "00000000-0000-4000-8000-000000000022", + "education_completion", + "2018-06-01", + "毕业", + ) + self.assertEqual(_quality_rows(completion), []) + + def test_completion_still_participates_in_ordinary_time_probes(self) -> None: + request = _request(events=[ + _education_event( + "00000000-0000-4000-8000-000000000022", + "education_completion", + "2018-06-01", + "毕业", + ), + { + "id": "00000000-0000-4000-8000-000000000013", + "domain": "career", + "event_kind": "career_entry", + "summary": "入职", + "date": "2018-07-01", + "precision": "month", + }, + { + "id": "00000000-0000-4000-8000-000000000014", + "domain": "relationship", + "event_kind": "relationship_start", + "summary": "相识", + "date": "2021-08-01", + "precision": "month", + }, + { + "id": "00000000-0000-4000-8000-000000000015", + "domain": "family", + "event_kind": "family_event", + "summary": "家里添丁", + "date": "2020-01-01", + "precision": "year", + }, + ]) + probes = _probes(request, _multi_layer_window_built(), ["05:00", "05:06", "05:07"], "05:00") + quality = [item for item in probes if item["source"] == "known_event_quality"] + self.assertEqual(quality, []) + self.assertTrue(probes) + self.assertTrue(any(item["source"] in {"dasha_boundary", "dasha_activation"} for item in probes)) + + def test_same_split_keeps_earliest_eligible_education_event(self) -> None: + start = _education_event( + "00000000-0000-4000-8000-000000000021", + "education_start", + "2014-09-01", + "入学", + ) + change = _education_event( + "00000000-0000-4000-8000-000000000023", + "education_change", + "2016-03-01", + "转学", + ) + rows = _quality_rows(start, change) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["target_evidence_id"], start["id"]) + + def test_different_quality_groups_keep_two_up_to_cap(self) -> None: + def stub(year: int, month: int, supports: list[str], conflicts: list[str], evidence_id: str) -> dict: + return { + "domain": "education", + "year": year, + "month": month, + "information_gain": 0.5, + "target_evidence_id": evidence_id, + "expected_outcomes": [ + {"answer_class": "yes", "supports": supports, "conflicts": conflicts}, + {"answer_class": "no", "supports": conflicts, "conflicts": supports}, + ], + } + + same_split = _select_quality_distinguish_rows([ + stub(2014, 9, ["05:00"], ["05:10"], "start"), + stub(2016, 3, ["05:00"], ["05:10"], "change"), + ]) + self.assertEqual(len(same_split), 1) + self.assertEqual(same_split[0]["target_evidence_id"], "start") + + different = _select_quality_distinguish_rows([ + stub(2014, 9, ["05:00"], ["05:10"], "start"), + stub(2016, 3, ["05:02"], ["05:12"], "change"), + ]) + self.assertEqual(len(different), 2) + self.assertEqual( + {item["target_evidence_id"] for item in different}, + {"start", "change"}, + ) + + over_cap = _select_quality_distinguish_rows([ + stub(2014, 9, ["05:00"], ["05:10"], "a"), + stub(2015, 9, ["05:02"], ["05:12"], "b"), + stub(2016, 9, ["05:04"], ["05:14"], "c"), + ]) + self.assertEqual(len(over_cap), MAX_QUALITY_DISTINGUISH_PROBES) + + if __name__ == "__main__": unittest.main()