diff --git a/CHANGELOG.md b/CHANGELOG.md index f40b6489..1f3e0a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 生时校正出卡加精度门槛,补经历改成系统点名 + +精度不够时不再出交付卡。区间还宽、头名还并列时,系统按大运边界逐条问「这几个月有没有这类事」,用类型芯片和年/月选择器录入,不再邀请随便打字。点「先跳过」的线会换一种问法再问一次;答「这类事都没有过」的不再问。用户明确说「没有了」仍然立刻给目前范围。Skill 升到 10.0.27。旧的校正会话仍按当时绑定的版本打开。 + ## 2026-09-16 — 报告列表改成一行一份,报告正文旁边常驻目录 「我的报告」原来是一格一格的卡片方阵,报告一多就得一格一格扫。现在是一行一份:每行左边一枚带色点的状态标签(已完成、生成中、未完成),后面是报告名、创建时间和主题,右边是操作。失败的那份,失败原因直接写在这一行里,不再挤成右边一小块。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f487a507..5bc93330 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10705,8 +10705,8 @@ - 根因:`rangeNarrowHint` 调 `targetedCollectPool` 时传入空的 `declinedTopics`,有意忽略拒答。 - 修复:传入真实 `declinedTopics`。一条都不剩时写「能问的都问完了」,不再暗示还有没问的线。 - 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`。 -- 防复发:交付文案不得列出已拒答或已回答的线。 -- 相关记录:BUG-661、BUG-662 +- 防复发:交付文案不得列出已答「没有发生过」的线。跳过的线按服务器计划最多再问一次,不在交付文案里当还开着的线列出。2026-09-16 产品修改防复发口径,见 `TASK-rectification-precision-gate-guided-collect-20260916.md` D4。 +- 相关记录:BUG-661、BUG-662、BUG-741 - 复发自:无 - 修复版本:待发布 @@ -10737,8 +10737,8 @@ - 根因:BUG-687 把空池收口写成终局句。采集线关闭不等于校正结束:交付后 Case 仍是 `candidate_ready`,输入框不禁用,新的带年月证据会改账本指纹并让快照过期。缺的是邀请;过期快照若仍带着 `tied_first` 停止原因,缺口状态机会继续停在 `delivered`。 - 修复:空池文案改为不限领域的补充邀请:先请记得确切哪一天的事,再退年月;举七条线之外的例子;不承诺定到分钟。并列且线已关闭时,邀请出现在交付卡正文,不躺在浅色说明里。快照因新证据过期时,决策不再把 `tied_first` 等耗尽停止原因带进采集,缺口才不会停在交付态。 - 验证:`frontend/tests/rectification-open-collect-invite-20260914.test.ts`。 -- 防复发:采集线关闭不等于校正结束;交付卡必须给出不限领域的补充邀请,且补充必须可见地生效。不得把邀请写成「再说一件就能定到分钟」。不得把已经拒答的线再列一遍(BUG-687)。 -- 相关记录:BUG-687、BUG-646、BUG-651、BUG-653、BUG-688、BUG-590 +- 防复发:采集线关闭不等于校正结束。2026-09-16 起自由文本邀请删除,改为系统点名的引导题 + 年/月选择器(BUG-740)。不得把已经答「没有发生过」的线再列一遍(BUG-687)。 +- 相关记录:BUG-687、BUG-646、BUG-651、BUG-653、BUG-688、BUG-590、BUG-740 - 复发自:BUG-687(空池收口写成终局句) - 修复版本:待发布 @@ -11550,3 +11550,68 @@ - 相关记录:BUG-732(引入这条真实库合同但未在有 Docker 的机器上跑) - 复发自:无 - 修复版本:待发布 + +## BUG-740 | 精度未达仍出交付卡,卡下只有自由文本邀请 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`decideRectification`、`guided_collect_windows`、引导采集池、范围交付卡 +- 用户现象:30 分钟窗给了 5 件带年月经历后直接出卡,区间 20 分钟、5 候选并列约 26%/26%/20%。卡下是「你要是还记得确切哪一天的事,不限领域,说出来我接着算」。 +- 触发条件:七条定向线关闭、自动题池空、头名并列。 +- 根因:`tied_first` 耗尽分支没有任何宽度或领先度条件。自动题池空之后没有有方向的题源。 +- 修复:出卡须宽度 ≤10 分钟且前两名相对可能性差 >3 个百分点且不并列;未达时按引擎边界窗口逐条点名,用类型芯片 + 年/月选择器录入。用户说「没有了」仍按现行规则出卡。自由文本邀请删除。 +- 验证:`frontend/tests/rectification-precision-gate-20260916.test.ts`、`frontend/tests/rectification-guided-collect-20260916.test.ts`、`tests/test_event_probes_guided_windows.py`。 +- 防复发:耗尽/offer 出卡必须过 `precisionGateMet`,除非用户停止或引导题源已空。不得再把自由文本邀请当作精度未达时的下一步。 +- 相关记录:BUG-689、BUG-653、BUG-654、BUG-687 +- 复发自:BUG-689(空池邀请没有方向,且没有精度门槛) +- 修复版本:待发布 + +## BUG-741 | 「跳过」和「没有发生过」同样永久关闭整条线 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`targetedDomainClosed`、定向存在性题、时间点题 +- 用户现象:点「这条先跳过」或「记不太清楚」后,那条线再也不会出现。时间点题答「那个时候没发生」后,整条感情线也被当成没有。 +- 触发条件:定向存在性题选 C/D;或 `source=event_probe` 的年月是非题选「明确没有发生」。 +- 根因:`declined` 与 `skipped` 同样写入关闭集合。存在性题干只覆盖一两个例子却按整个领域记「没有」。时间点负答案没有与领域关闭隔离。 +- 修复:`declined` 永久关闭;`skipped` 换一种更具体的问法再问一次,再次跳过才关闭。存在性题问整个领域、任何时间,B 写成「这类事都没有过」。时间点题的负答案不关领域。 +- 验证:`frontend/tests/rectification-guided-collect-20260916.test.ts`。 +- 防复发:关闭集合不得把一次跳过当成拒绝。时间点题不得写入会让 `targetedDomainClosed` 判定领域关闭的状态。七条线一张表,不得按单个领域写死。 +- 相关记录:BUG-687、BUG-643、BUG-740 +- 复发自:无 +- 修复版本:待发布 + +## BUG-742 | 定向存在性题 B/C/D 回执文案与状态对调 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`targetedCollectExistenceAck`、`RECTIFICATION_USER_COPY.collectDeclinedAck` / `collectSkippedAck` +- 用户现象:答「没有发生过」听到「记下了,这方面先跳过」。 +- 触发条件:定向存在性题选 B。 +- 根因:B 落 `declined`、C/D 落 `skipped` 是对的;回执把 declined 写成「先跳过」。不是 D 落成 declined。 +- 修复:B 回「记下了,这条按没有发生过记」;C/D 回「记下了,这题先放着,后面换个问法再问一次」。 +- 验证:`frontend/tests/rectification-guided-collect-20260916.test.ts` 断言两句回执。 +- 防复发:declined 回执不得写「跳过」;skipped 回执不得写「没有发生过」。 +- 相关记录:BUG-741 +- 复发自:无 +- 修复版本:待发布 + +## BUG-743 | 性格题 ±1 写入 posterior_score,可能把卡在 8 分边界的簇推过线 + +- 状态:investigating +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`applyProbeOutcome`、`unionStillValidRange` +- 用户现象:性格题答完后范围从 20 分钟弹到 28 分钟,下一题又缩回。 +- 触发条件:未淘汰簇落后头名刚好 8 分,性格题给其中一侧 ±1。 +- 根因(代码路径已确认,真机未复现):`varga_style` 以 `PROBE_WEIGHT.yearless = 0.5` 把 `SCORE_DELTA` 写进同一份 `posterior_score`。`unionStillValidRange` 用 `peak - posterior_score < MIN_SEPARATION_LEAD`。风格题只该改排序。本轮不改打分,未改并集规则。 +- 修复:无。已排除:性格题不会淘汰(`yearless` 不增加 `strong_conflict_count`)。未排除:并集是否因此扩/缩。 +- 验证:未附回归。不得为收口编根因。 +- 防复发:风格微调若并入范围计算,必须用一份不含风格分的分数做并集。 +- 相关记录:BUG-651、BUG-629、BUG-740 +- 复发自:无 +- 修复版本:无 + diff --git a/docs/research/guided_collect_holdout_2026_09_16.json b/docs/research/guided_collect_holdout_2026_09_16.json new file mode 100644 index 00000000..f8da9059 --- /dev/null +++ b/docs/research/guided_collect_holdout_2026_09_16.json @@ -0,0 +1,1721 @@ +{ + "generated_at": "2026-09-16", + "holdout": "references/real_case_calibration/minute_rectification_holdout_v4.json", + "ask_count": 6, + "delivery_max_width_minutes": 10, + "tie_percent": 3, + "elapsed_s": 550.1, + "summaries": [ + { + "radius": 10, + "n": 20, + "met": 3, + "median_events_to_gate": 0, + "unmet": 17, + "errors": 0 + }, + { + "radius": 30, + "n": 20, + "met": 1, + "median_events_to_gate": 0, + "unmet": 19, + "errors": 0 + }, + { + "radius": 60, + "n": 20, + "met": 0, + "median_events_to_gate": null, + "unmet": 20, + "errors": 0 + } + ], + "rows": [ + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 2, + "percents": [ + 23, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 3, + "percents": [ + 23, + 20, + 19 + ] + } + }, + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 14 + ] + }, + "final": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 0, + "percents": [ + 14, + 14, + 13 + ] + } + }, + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 25, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 25, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 13 + ] + } + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 1, + "percents": [ + 22, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 1, + "percents": [ + 27, + 26, + 25 + ] + } + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 1, + "percents": [ + 11, + 10, + 10 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 13 + ] + } + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "final": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + } + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 1, + "percents": [ + 18, + 17, + 17 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + } + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + }, + "final": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + } + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 8 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + } + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 1, + "percents": [ + 27, + 26, + 26 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 0, + "percents": [ + 26, + 26, + 25 + ] + } + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 30, + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 7, + "tied_for_first": false, + "gap": 10, + "percents": [ + 55, + 45 + ] + } + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + } + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 7, + "tied_for_first": false, + "gap": 0, + "percents": [ + 34, + 34, + 33 + ] + }, + "final": { + "met": false, + "width": 7, + "tied_for_first": false, + "gap": 0, + "percents": [ + 34, + 34, + 32 + ] + } + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 22, + 22, + 19 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + } + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 55, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 10 + ] + }, + "final": { + "met": false, + "width": 55, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 11 + ] + } + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + } + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + } + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 1439, + "tied_for_first": false, + "gap": 0, + "percents": [ + 4, + 4, + 4 + ] + }, + "final": { + "met": false, + "width": 1439, + "tied_for_first": false, + "gap": 0, + "percents": [ + 4, + 4, + 4 + ] + } + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + } + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + } + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 1, + "percents": [ + 5, + 4, + 4 + ] + }, + "final": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 1, + "percents": [ + 5, + 4, + 4 + ] + } + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 10, + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 9, + "tied_for_first": false, + "gap": 7, + "percents": [ + 40, + 33, + 27 + ] + } + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 37, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + } + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 95, + "tied_for_first": false, + "gap": 0, + "percents": [ + 14, + 14, + 12 + ] + }, + "final": { + "met": false, + "width": 93, + "tied_for_first": false, + "gap": 0, + "percents": [ + 13, + 13, + 12 + ] + } + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 4, + "percents": [ + 25, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 2, + "percents": [ + 23, + 21, + 20 + ] + } + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + }, + "final": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + } + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 29, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 29, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + } + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + } + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 4, + "percents": [ + 24, + 20, + 20 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 2, + "percents": [ + 15, + 13, + 13 + ] + } + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 107, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 8 + ] + }, + "final": { + "met": false, + "width": 119, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + } + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 28, + 28, + 25 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 3, + "percents": [ + 36, + 33, + 31 + ] + } + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 1, + "percents": [ + 11, + 10, + 10 + ] + }, + "final": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 9 + ] + } + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 1, + "percents": [ + 10, + 9, + 9 + ] + }, + "final": { + "met": false, + "width": 77, + "tied_for_first": false, + "gap": 1, + "percents": [ + 9, + 8, + 8 + ] + } + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 11, + "percents": [ + 41, + 30, + 29 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 8, + "percents": [ + 39, + 31, + 31 + ] + } + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 14, + "percents": [ + 57, + 43 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 10, + "percents": [ + 40, + 30, + 30 + ] + } + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 57, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 1, + "percents": [ + 13, + 12, + 12 + ] + } + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 10, + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 5, + "tied_for_first": false, + "gap": null, + "percents": [ + 100 + ] + } + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 5, + "percents": [ + 38, + 33, + 29 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 8, + "percents": [ + 54, + 46 + ] + } + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "final": { + "met": false, + "width": 65, + "tied_for_first": false, + "gap": 0, + "percents": [ + 27, + 27, + 23 + ] + } + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 10, + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 5, + "tied_for_first": false, + "gap": 20, + "percents": [ + 60, + 40 + ] + } + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 8, + "percents": [ + 33, + 25, + 22 + ] + }, + "final": { + "met": false, + "width": 25, + "tied_for_first": false, + "gap": 3, + "percents": [ + 23, + 20, + 20 + ] + } + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 23, + 22, + 20 + ] + }, + "final": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + } + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 2, + "percents": [ + 35, + 33, + 33 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 34, + 33, + 32 + ] + } + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 12 + ] + } + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 75, + "tied_for_first": false, + "gap": 1, + "percents": [ + 7, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 1, + "percents": [ + 7, + 6, + 6 + ] + } + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 6, + "percents": [ + 39, + 33, + 28 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 1, + "percents": [ + 35, + 34, + 31 + ] + } + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 13 + ] + }, + "final": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 2, + "percents": [ + 22, + 20, + 20 + ] + } + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + } + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 8, + "percents": [ + 54, + 46 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 2, + "percents": [ + 51, + 49 + ] + } + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 13 + ] + } + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "final": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + } + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 27, + 27, + 23 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 26, + 26, + 24 + ] + } + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 26, + 25, + 25 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 26, + 25, + 24 + ] + } + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 103, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 103, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + } + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": true, + "gap": 0, + "percents": [ + 19, + 19, + 18 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + } + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": true, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 16, + 15, + 15 + ] + } + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 51, + "tied_for_first": true, + "gap": 0, + "percents": [ + 14, + 14, + 14 + ] + }, + "final": { + "met": false, + "width": 51, + "tied_for_first": false, + "gap": 0, + "percents": [ + 13, + 13, + 13 + ] + } + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 10, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "final": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 2, + "percents": [ + 51, + 49 + ] + } + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 30, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 59, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 9 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 9 + ] + } + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 60, + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 0, + "percents": [ + 23, + 23, + 19 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 4, + "percents": [ + 28, + 24, + 24 + ] + } + } + ], + "errors": [] +} diff --git a/docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-20260916.md b/docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-20260916.md new file mode 100644 index 00000000..55303544 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-20260916.md @@ -0,0 +1,57 @@ +# 进度 · 出卡精度门槛 + 引导式补经历(2026-09-16) + +基线:`origin/staging` @ `20435a19`(任务书提交;代码基线其父 `071317f7`)。合入前需 rebase `317e9f18`(顶栏 preventScroll,staging 已快进 1 笔)。 +分支:`codex/rectification-precision-gate-guided-collect-20260916`。 +BUG:740~743(开工时最大号 BUG-739)。 + +相关单已合入 staging,本单在其上实现,未回退。 + +## 做了什么 + +- T1:`deliveryMaxWidthMinutes: 10` 写入 `references/rectification_policy.v1.json`,Python / TS 各读一次。`decideRectification` 的 tied_first / exhausted / offer 出卡都要 `precisionGateMet || userStopped || guidedCollectExhausted`。`user_uncertainty_too_high` 未动。`withholdAdoptOnUnnarrowedRange` 未动。`stillNeedNarrowing` / `datedMethodCollectOpen` 在门槛已达时不再挡住出卡。`decideFromDossier` 的宽度回退到候选分数并集,避免 inference 没有 `credible_range` 时门槛恒为假。 +- T2:新增 `guided_collect_windows`(`min_boundary_days=0`、含小运与 D9/D10 那罗延),挂进 packet。未改 `MIN_BOUNDARY_DAYS` 与自动题生成。离线回放脚本 `scripts/research/guided_collect_holdout_replay.py`。 +- T3:引导题排在自动题之后、定向七条线之前。录入卡 `event-date-picker.tsx` + `event-date-entry-card.tsx`。年份范围出生年到今年。删除自由文本邀请常量。常驻条加「已对照 N 件」。 +- T4 / T4b:skipped 换问法再问一次;declined 不重问。存在性题改问整个领域。时间点负答案不关领域。`remainingTargetedDomains` 不再按分盘层剔除。只有 `target_domain` 的拒答也关闭该线。 +- T5:Skill 10.0.27。hash `7e5417419ffad9bb34b78318f541484821bb22476655092047006b3a05aff020`。 +- T6:回执文案对调已核实并随 T4 修(BUG-742)。性格题范围回弹代码路径已看到,真机未复现,记 BUG-743 investigating。 +- T7:BUG_HISTORY / CHANGELOG / DESIGN / VOICE / 真机清单 / 本进度。 + +## 测试 + +### 前端 + +- `tsc --noEmit`:0 错。 +- `npm run lint`:0 error(既有 warning 未顺手清)。 +- 定向套件(本单相关):`rectification-guided-collect-20260916`、`rectification-precision-gate-20260916`、`rectification-eight-method`、`rectification-occupation-coverage-exit`、`rectification-probe-pool-exhausted-20260911`、`rectification-collect-stall`、`rectification-exhaustion-exit-20260906`、`rectification-tiebreak-before-card-20260914` 等 **fail=0**。 +- `page.tsx`:只加 `birthDate` 一处 prop,`Home()` 的 `useState` / `useRef` 未增长。 +- 全量 `npm test`:本机无 Docker、Windows 无 symlink 权限。与 holdout 并行时还出现过 `spawn UNKNOWN` / OOM,不能当回归清单。无 Docker 的失败与基线同类(compose 起不来、symlink EPERM)。 + +### Python + +- `pytest tests/test_event_probes_guided_windows.py tests/test_rectification_event_probes.py tests/test_candidate_discriminator_contract.py tests/test_rectification_refresh_r3_r4.py`:**56 passed**。 +- `run_quality_gate.py --profile quick`:卡在本机 venv 缺 `mcp` 包(`interpretation_source_inventory_gate`),与既有环境缺口一致,不是本单引入。 + +### T2 离线 holdout(不是合入门槛) + +`minute_rectification_holdout_v4.json` 20 例,六题后按 `guided_collect_windows` 注入真值侧带月事件。550 s,错误 0。原始表 `docs/research/guided_collect_holdout_2026_09_16.json`。 + +| 半径 | 20 例达标 | 达标例所需件数中位 | 未达 | +| --- | ---: | ---: | ---: | +| ±10 | 3 | 0(六题后已经达标,没有靠引导件补上) | 17 | +| ±30 | 1 | 0 | 19 | +| ±60 | 0 | — | 20 | + +没有一例是「六题后未达、再补引导件后达标」。Picasso / Freud 三档窗口数都是 0(年龄带把边界年裁掉)。产品据此决定要不要调 10 分钟门槛。 + +### 未测 + +- `next build` 首屏 gzip ±2%:本机未跑。选择器在校正对话里,不进 `/` 首屏。 +- 真机清单:`docs/testing/rectification-guided-collect-20260916.md`,留给产品负责人。 +- 历史校正会话打开:Skill 合同测了版本绑定;无登录态,浏览器级留给清单第 6 条。 + +## 偏离 + +- T2 离线 20 例三档都跑完,未用让步(只跑 ±10/±30)。 +- T6 性格题回弹不阻塞合入。 +- 选择器年份原先误写成 1950,已改成档案出生年→今年。 +- 既有测试里「训练门开即出卡 / leftover 不再问」与 D1/D5 冲突的断言,已按「原值 / 新值 / 原因」改。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e2f38619..cb30180c 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -251,7 +251,7 @@ | `TASK-home-state-lowering-batch2-20260916.md` | — | **状态下沉第二批(纯前端,与另两单可并行)**:第一批 `f8e607c2` 已验收(useState 66→52、useRef 41→39、散装 `rectification*` 归零、全量套件两侧完全相同)。本单照同一套做法搬剩下三簇:`session*` **10** 个(`useSessionManagement` 仍要解构约 40 个参数)、`profile*` **6** 个、`synastry*` **4** 个(无 hook,散在 `Home()`),目标 useState 52 → **≤36**。另修第一批留下的尾巴:`createRectificationShellSetters` 在 render 体里无记忆化 → 每帧新身份 → 多一条 `exhaustive-deps` warning(119→**120**),**正解是稳住 setter 身份、不是塞进 deps**(塞进去会每帧重拉入口摘要),本单要把 warning 降回 119。零行为变化;先分类再动手。不占 BUG 号 | 待领取 | — | | `TASK-api-server-backdoor-close-20260916.md` | — | **取代 decomposition 单的阶段 1** · **第二版(2026-09-16 改写)**:第一版的「8 方法 / 314 行」**错了一个数量级**——闭包只跟了 `self._x()`,漏掉 `_compute_consultation_workflow` 那 6 行委托转给模块级 `execute_consultation_workflow(self, ...)` 的一跳。执行方重算实测 **115 方法 / 4,104 行(占全类 51% 方法),但碰 HTTP 上下文的仍是 0 个**。产品在 A/B/C 中**选定 B(mixin 抽取)**:方法体原样搬进 `ConsultationComputeMixin`,`JyotishAPIHandler(BaseHTTPRequestHandler, Mixin)` 靠 MRO 解析,离线调用方直接实例化 mixin——四个伪造点全关。**产品同时授权:§4 那条「不得搬 ≥150 行业务方法」按本意解释(本意是防逐行改写的 C 案),mixin 放行。** 必须先过 **spike 闸门**:搬完后 `test_api_server_security.py` 一字不改直接跑,绿则继续、红则退回 A(只搬 7 方法/300 行、关 2 个伪造点)。真风险是循环 import——闭包引用同文件 44 个模块级函数 + 21 个常量。`__new__` 归零只针对 scripts 侧 4→0(tests 侧 29 处含不许改的 security 测试,保持不增长)。阶段 3 仍不做。不占 BUG 号 | **已执行(spike 红 → 退回 A)** | spike 实测:整体 mixin 化后 `test_api_server_security.py` **1 failed / 128 passed**——`test_chart_async_job_executes_in_background` 挂在 `monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', ...)`:调用方法随 mixin 搬走后从新模块 globals 解析,补丁落在旧模块上不生效(已证明把同一 fake 打到 mixin 模块即恢复原行为,非搬坏)。循环 import 不是障碍(移动集不引用 `JyotishAPIHandler`)。按 §6.3 退回 A:`scripts/offline_compute_mixins.py` 收 7 方法 / 300 行 + 3 助手 + `BadRequest`,关掉 `consultation_workflow_service:27` 与 `local_accuracy_report:141` 两处伪造,scripts 侧 `__new__` **4 → 2**;类方法 225 → 218、行数 11,291 → 10,924;security 测试一字未改 **129 passed**。剩余 2 处伪造都在咨询工作流链上,需另案解决 monkeypatch 落点问题 | | `RECONCILE-20260916.md` | — | **对账清单(不是任务书)**:状态板与现实脱节(09-15~16 那 10 份早已合入却仍写「待领取/待验收」,本轮已修正),所以另外 7 份 09-10~14 的单**不能拿 README 当证据**——它们没有 `PROGRESS-*.md`,但引用的 BUG 号都是 `resolved`。要么被别的单顺带修了没留记录,要么压根没做。每行回一个「做了/没做」即可。另附三条我已查到确定没做的证据、两条状态未闭环的、以及 BLK-001 仍红(2026-09-16 在 `4f643aa0` 复跑确认) | 待产品负责人 / 执行方回填 | — | -| `TASK-rectification-precision-gate-guided-collect-20260916.md` | — | **出卡加精度门槛 + 引导式补经历**:真机 30 分钟窗给 5 件事 / 3 领域就出卡,区间 20 分钟、5 候选并列 26/26/20%,卡下只有自由文本邀请。产品拍板 D1 出卡门槛「宽度 ≤10 分钟且前两名差 >3 个百分点且不并列」;D2 用户说没有了仍按现行规则出卡(保留永远给结果);D3 门槛未达改为系统点名逐题问 + 类型芯片 + 年/月选择器录入,删自由文本邀请;D4 跳过的线换问法再问一次(拒绝不重问,改 BUG-687 口径);D5 时间点题答「没发生」不关领域、存在性题问整个领域、七条线一张表不许按领域写死。引擎新增 `guided_collect_windows`(边界不设 45 天闸门、含小运),不改打分与 lead。Skill 10.0.27。附 T6 两条核实:B/C/D 回执文案对调、性格题后范围回弹 8 分钟。BUG 段 740 起 | 待领取 | 与 BUG-661~663 / 626~634 诸单同文件,本单优先;开工先核对它们是否已合入 | +| `TASK-rectification-precision-gate-guided-collect-20260916.md` | `PROGRESS-rectification-precision-gate-guided-collect-20260916.md` | **出卡加精度门槛 + 引导式补经历**:真机 30 分钟窗给 5 件事 / 3 领域就出卡,区间 20 分钟、5 候选并列 26/26/20%,卡下只有自由文本邀请。产品拍板 D1 出卡门槛「宽度 ≤10 分钟且前两名差 >3 个百分点且不并列」;D2 用户说没有了仍按现行规则出卡(保留永远给结果);D3 门槛未达改为系统点名逐题问 + 类型芯片 + 年/月选择器录入,删自由文本邀请;D4 跳过的线换问法再问一次(拒绝不重问,改 BUG-687 口径);D5 时间点题答「没发生」不关领域、存在性题问整个领域、七条线一张表不许按领域写死。引擎新增 `guided_collect_windows`(边界不设 45 天闸门、含小运),不改打分与 lead。Skill 10.0.27。附 T6 两条核实:B/C/D 回执文案对调、性格题后范围回弹 8 分钟。BUG-740~743 | 待验收 | `codex/rectification-precision-gate-guided-collect-20260916` | ## 命名与归档 diff --git a/docs/testing/rectification-guided-collect-20260916.md b/docs/testing/rectification-guided-collect-20260916.md new file mode 100644 index 00000000..4b32cb6d --- /dev/null +++ b/docs/testing/rectification-guided-collect-20260916.md @@ -0,0 +1,10 @@ +# 真机清单 · 出卡精度门槛 + 引导式补经历(2026-09-16) + +在 staging 用受控账号走一遍生时校正。不要把真实出生资料写进任何记录。 + +1. **门槛未达不出卡。** 给两三件带年月的事、几个领域,让自动点选题问完。若目前范围仍宽于 10 分钟或头名并列,不应出现「目前范围」交付卡和采用按钮。 +2. **引导题带年月区间。** 下一问应是「某年某月到某月之间,有没有…」,选项是「有,我来填时间 / 这段没有 / 记不清 / 这条先跳过」。 +3. **选择器录入后范围变化。** 点「有,我来填时间」,用类型芯片和年/月选择器提交一件事。应看到记下了年月,账本更新,范围可能变。输入框始终能打字。 +4. **说「没有了」出卡。** 在引导阶段打「没有了」或「就这些」,应立刻出现目前范围卡和采用按钮,不再追问第二遍。卡上没有「你要是还记得确切哪一天」这类邀请。 +5. **跳过线重问一次。** 某条存在性题点「这条先跳过」,稍后应换一种更具体的问法再问同一条线;再点跳过才不再问。 +6. **历史会话可开。** 打开一条绑定旧 Skill 版本的生时校正,应能进入对话,不得停在「暂时无法打开」。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 7ae0d2ea..489f8c10 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -260,7 +260,8 @@ The birth-time rectification session is the consultation transcript plus a house | `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled | | `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | | `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible | -| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines; when the top two relative likelihoods are within 3 points and collect lines are closed, a body-size invite to add one more event of any kind, asking for an exact day first then falling back to year-month (never “能问的都问完了”). The invite’s subject matches the remaining count in the same paragraph: 2 → “这两分钟”, more than 2 → “这几个候选”, unknown → omit that clause. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered. | enabled | +| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. The card appears only when the credible range is ≤ 10 minutes, the top two relative likelihoods differ by more than 3 points, and first place is not tied — or the reader said there is nothing more to add. No free-text invite. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered, once the precision gate or the “nothing more” stop is met. | enabled | +| `guided-collect` | a named yes/no card (“YYYY 年 M 到 M 月之间,有没有…”) then an entry card: seven type chips (default the asked domain) plus a month-granularity date picker (day optional, years from birth year to this year). Composer stays enabled. | enabled (typing still records), stop visible | | `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible | | `confirmed` | “已确认校正时间:HH:MM” | enabled | | `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled | diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 396fd425..a615eaed 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -41,13 +41,14 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 | | 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 | | 好的,记下了。 | 记下了:2016 年 9 月入学、2020 年 6 月毕业。 | 只复述年-月和事件短语,不评价价值。 | +| 记下了,这方面先跳过。 | 答「这类事都没有过」回「记下了,这条按没有发生过记。」答「记不太清楚 / 这条先跳过」回「记下了,这题先放着,后面换个问法再问一次。」 | 拒绝和跳过不是同一句话。 | | 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 | | 选一个最贴近你实际情况的就行,答不上来也可以选「一时说不好」。 | 2024 年那件事记下了。接下来我们继续。 | 正文先承接用户刚说的年份和事件;不要预告选项。 | | ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 | | 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 | | 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 | | 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 | -| 能问的都问完了 | 还剩 2 个候选时:「这两分钟按现有信息分不开。」还剩 4 个时:「这几个候选按现有信息分不开。」候选数未知时只说「按现有信息分不开。」后面接:你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。句首的「这两分钟 / 这几个候选」必须和同段话里的候选数一致,不得硬编码。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 | +| 能问的都问完了 / 你要是还记得确切哪一天的事,不限领域,说出来我接着算 | 门槛未达:「现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准」,下面是系统点名的题(带年月区间)和年/月选择器。用户说「没有了」才出目前范围卡,卡上不再邀请打字。 | 精度不够就继续引导,不让用户自由发挥。不承诺「再补几件就能定到分钟」。 | | 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 | | 你的出生记录不准,按校正结果来 / 这些经历说明记录写错了 / 校正结果无效 | 出生记录时间 04:40,默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 8 分钟。你可以改用校正结果,也可以继续用记录。 | 记录与校正区间冲突时记录优先,但两者必须并列。不得宣布任何一方无效——不说记录不准,也不说校正结果不作数。 | | (冲突时按钮仍写)更像这个 | 按钮写「改用校正结果」,按钮下面一行写「选它之后,排盘会从出生记录时间 04:40 换成 04:48」。 | 换掉的是什么要在动手的地方说清楚。不新增入口,仍是同一个采用按钮;采用仍不是确认。 | diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ee024386..dc87db98 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2595,7 +2595,8 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); } } /* Last in the row and first to be clipped: the count is context, not the answer, and it is the only item the reader can do without. */ -.rectification-timeline__answered { +.rectification-timeline__answered, +.rectification-timeline__dated { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -3111,6 +3112,49 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); } .rectification-choice-card.is-embedded .birth-time-choice-question legend { display: unset; } +.rectification-event-entry { + display: grid; + gap: var(--space-3); + margin: var(--space-3) 0 var(--space-4); + margin-inline-start: var(--assistant-content-inset); + padding: var(--space-5); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas-soft); +} +.rectification-event-entry__label { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.rectification-event-entry__chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.rectification-event-entry__chip { + min-height: 36px; + padding: 0 var(--space-3); + border: 1px solid var(--color-border); + border-radius: 999px; + background: transparent; + color: var(--color-ink); + font-size: var(--type-caption); +} +.rectification-event-entry__chip[data-selected="true"] { + border-color: var(--color-action); + background: var(--color-action-soft); + color: var(--color-action); +} +.rectification-event-date { + display: grid; + gap: var(--space-2); +} +.rectification-event-date__footer { + display: flex; + justify-content: flex-end; + padding: 0 var(--space-3) var(--space-3); +} .rectification-choice-why { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ebfed438..64918316 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1736,6 +1736,7 @@ export default function Home() { void selectSessionModel(modelId)} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 46d510ae..11cc1360 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -52,6 +52,7 @@ export type RectificationChatPanel = Readonly<{ export type ConversationalBirthTimeRectificationProps = Readonly<{ /** The declared birth minute from the profile, shown on the board before any candidate exists. */ declaredTime: string | null; + birthDate: string | null; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; @@ -62,6 +63,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{ export function ConversationalBirthTimeRectification({ declaredTime, + birthDate, models, selectedModelId, onSelectModel, @@ -71,6 +73,7 @@ export function ConversationalBirthTimeRectification({ }: ConversationalBirthTimeRectificationProps) { const props = { declaredTime, + birthDate, models, selectedModelId, onSelectModel, diff --git a/frontend/src/components/event-date-entry-card.tsx b/frontend/src/components/event-date-entry-card.tsx new file mode 100644 index 00000000..cd2a6964 --- /dev/null +++ b/frontend/src/components/event-date-entry-card.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { EventDatePicker, type EventDateValue } from "@/components/event-date-picker"; +import { Button } from "@/components/ui/button"; +import { + COLLECT_KIND_ORDER, + KIND_ORAL, + type CollectKind, +} from "@/lib/rectification-agentic/v9/collection-question-pool"; +import { RANGE_DELIVERY_DOMAIN_LABEL } from "@/lib/rectification-agentic/user-copy"; + +export type EventDateEntrySubmit = Readonly<{ + domain: CollectKind; + year: number; + month: number; + day: number | null; +}>; + +export function formatEventDateEntryMessage(input: EventDateEntrySubmit): string { + const when = input.day + ? `${input.year} 年 ${input.month} 月 ${input.day} 日` + : `${input.year} 年 ${input.month} 月`; + return `${when},${KIND_ORAL[input.domain]}`; +} + +export function eventDateYearRange( + birthYear: number | null | undefined, + nowYear = new Date().getFullYear(), +): { minYear: number; maxYear: number } { + const maxYear = nowYear; + if (typeof birthYear === "number" && birthYear >= 1900 && birthYear <= maxYear) { + return { minYear: birthYear, maxYear }; + } + return { minYear: Math.max(1900, maxYear - 80), maxYear }; +} + +export function EventDateEntryCard({ + defaultDomain, + defaultYear, + defaultMonth, + minYear, + maxYear, + disabled = false, + onSubmit, +}: { + readonly defaultDomain: CollectKind; + readonly defaultYear?: number; + readonly defaultMonth?: number; + readonly minYear: number; + readonly maxYear: number; + readonly disabled?: boolean; + readonly onSubmit: (value: EventDateEntrySubmit) => void; +}) { + const [domain, setDomain] = useState(defaultDomain); + const initial = useMemo(() => { + if (!defaultYear || defaultYear < minYear || defaultYear > maxYear) return null; + const month = defaultMonth && defaultMonth >= 1 && defaultMonth <= 12 ? defaultMonth : 1; + return { year: defaultYear, month, day: null }; + }, [defaultYear, defaultMonth, minYear, maxYear]); + const [date, setDate] = useState(initial); + + return ( +
{ + event.preventDefault(); + if (!date || disabled) return; + onSubmit({ domain, year: date.year, month: date.month, day: date.day }); + }} + > +

哪一类事

+
+ {COLLECT_KIND_ORDER.map((kind) => ( + + ))} +
+ + + + ); +} diff --git a/frontend/src/components/event-date-picker.tsx b/frontend/src/components/event-date-picker.tsx new file mode 100644 index 00000000..5dc285ab --- /dev/null +++ b/frontend/src/components/event-date-picker.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useId, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; + +export type EventDateValue = Readonly<{ + year: number; + month: number; + day: number | null; +}>; + +type EventDatePickerProps = { + readonly value: EventDateValue | null; + readonly minYear: number; + readonly maxYear: number; + readonly disabled?: boolean; + readonly onChange: (value: EventDateValue) => void; +}; + +function clampYear(year: number, minYear: number, maxYear: number): number { + return Math.min(maxYear, Math.max(minYear, year)); +} + +function formatValue(value: EventDateValue | null): string { + if (!value) return "选择年月"; + if (value.day) return `${value.year} 年 ${value.month} 月 ${value.day} 日`; + return `${value.year} 年 ${value.month} 月`; +} + +export function EventDatePicker({ + value, + minYear, + maxYear, + disabled = false, + onChange, +}: EventDatePickerProps) { + const labelId = useId(); + const valueId = useId(); + const [open, setOpen] = useState(false); + const selected = value + ? new Date(value.year, value.month - 1, value.day ?? 1) + : undefined; + const startMonth = new Date(minYear, 0); + const endMonth = new Date(maxYear, 11); + + return ( +
+ 发生年月 + + } + > + {formatValue(value)} + + + { + if (nextDate === undefined) return; + onChange({ + year: nextDate.getFullYear(), + month: nextDate.getMonth() + 1, + day: nextDate.getDate(), + }); + }} + /> +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index e02917bd..aa0f04af 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -113,6 +113,13 @@ import { import { ModelSelector } from "./model-selector"; import { RectificationBoard, RectificationBoardPeek } from "./rectification-board"; import { RectificationChoiceCard } from "./rectification-choice-card"; +import { + EventDateEntryCard, + eventDateYearRange, + formatEventDateEntryMessage, +} from "./event-date-entry-card"; +import { parseYearEntryQuestionId } from "@/lib/rectification-agentic/v9/collection-question-pool"; +import { birthYearFromDate } from "@/lib/rectification-agentic/v9/adult-floor"; import { applyLiveCandidateOffer, copyTextForMessage, @@ -221,6 +228,8 @@ type RectificationAgenticChatProps = Readonly<{ initialSnapshot: RectificationCaseSnapshotPayload | null; /** The declared birth minute from the profile, for the board before any candidate exists. */ declaredTime: string | null; + /** Profile birth date; the year-entry picker spans this year through the current year. */ + birthDate: string | null; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; @@ -389,6 +398,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { initialTurns, initialSnapshot, declaredTime, + birthDate, models, selectedModelId, onSelectModel, @@ -1412,7 +1422,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { workingTime: workingRectificationTime(candidateResult), workingAdopted: Boolean(candidateResult?.selectedTime), answeredProbeCount: candidateResult?.answeredProbeCount ?? null, + datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null, }); + const yearEntry = parseYearEntryQuestionId(currentQuestion?.question_id); + const yearRange = eventDateYearRange(birthYearFromDate(birthDate)); const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; const liveSelectionCardKey = persistedOfferKey ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); @@ -1745,6 +1758,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onStop={submitStop} /> + ) : yearEntry ? ( + <> +

{currentQuestion.prompt}

+ { + void send("message", formatEventDateEntryMessage(value)); + }} + /> + ) : (

{currentQuestion.prompt}

)} diff --git a/frontend/src/components/rectification-range-delivery.tsx b/frontend/src/components/rectification-range-delivery.tsx index 33ae0bb4..f46804d2 100644 --- a/frontend/src/components/rectification-range-delivery.tsx +++ b/frontend/src/components/rectification-range-delivery.tsx @@ -4,10 +4,7 @@ import type { RectificationCandidateResult } from "@/lib/rectification-candidate import { RECTIFICATION_USER_COPY, REPRESENTATIVE_MINUTE_DISCLAIMER, - rangeDeliveryCaptionWithoutClosedInvite, - rangeDeliveryClosedInviteFromHint, rangeDeliveryEventCopy, - rangeDeliveryShowsOpenCollectInvite, } from "@/lib/rectification-agentic/user-copy"; import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel"; import { @@ -47,17 +44,10 @@ export function RectificationRangeDelivery({ const markdown = delivery?.verification_markdown ?? result.verificationReportMarkdown; const sharedTraits = delivery?.shared_traits ?? []; - const closedInvite = rangeDeliveryClosedInviteFromHint(delivery?.narrow_hint); - const showOpenCollectInvite = rangeDeliveryShowsOpenCollectInvite({ - hint: delivery?.narrow_hint, - columns, - }); // BUG-691: while the birth record disagrees with the range the adopt entry says what // it actually does — swap the charting clock — instead of the neutral 「更像这个」. const recordConflict = delivery?.record_conflict ?? null; - const caption = showOpenCollectInvite || closedInvite - ? rangeDeliveryCaptionWithoutClosedInvite(delivery?.narrow_hint) - : delivery?.narrow_hint ?? null; + const caption = delivery?.narrow_hint ?? null; return (
@@ -67,11 +57,6 @@ export function RectificationRangeDelivery({ {caption ? (

{caption}

) : null} - {showOpenCollectInvite && closedInvite ? ( -

- {closedInvite} -

- ) : null} {delivery?.provenance_line ? (

{delivery.provenance_line}

) : null} diff --git a/frontend/src/components/rectification-timeline.tsx b/frontend/src/components/rectification-timeline.tsx index d1e9a716..2b884f2f 100644 --- a/frontend/src/components/rectification-timeline.tsx +++ b/frontend/src/components/rectification-timeline.tsx @@ -46,6 +46,9 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie {view.answeredLabel ? ( {view.answeredLabel} ) : null} + {view.datedEventLabel ? ( + {view.datedEventLabel} + ) : null}

diff --git a/frontend/src/lib/rectification-agentic/core/precision-gate.ts b/frontend/src/lib/rectification-agentic/core/precision-gate.ts new file mode 100644 index 00000000..30e2a662 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/precision-gate.ts @@ -0,0 +1,33 @@ +/** + * Range-delivery precision gate (D1). Width, display-gap, and exact score + * tie are independent of scoring / cluster merge. + */ + +import { RECTIFICATION_POLICY } from "../../rectification-policy.ts"; +import { RANGE_DELIVERY_TIE_PERCENT, rangeWidthMinutes } from "../user-copy.ts"; + +export function deliveryWidthMinutes( + range: readonly [string, string] | null | undefined, +): number | null { + if (!range?.[0] || !range[1]) return null; + if (range[0] === range[1]) return 0; + return rangeWidthMinutes(range[0], range[1]); +} + +export function probabilityToPercent(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0; + const raw = value <= 1 ? value * 100 : value; + return Math.min(100, Math.round(raw)); +} + +export function computePrecisionGateMet(input: { + range: readonly [string, string] | null | undefined; + topTwoPercents: readonly number[]; + tiedForFirst: boolean; +}): boolean { + if (input.tiedForFirst) return false; + const width = deliveryWidthMinutes(input.range); + if (width == null || width > RECTIFICATION_POLICY.deliveryMaxWidthMinutes) return false; + if (input.topTwoPercents.length < 2) return input.topTwoPercents.length === 1; + return input.topTwoPercents[0]! - input.topTwoPercents[1]! > RANGE_DELIVERY_TIE_PERCENT; +} diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index c02573ec..be8f0238 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -231,6 +231,10 @@ export type DecideRectificationInput = Readonly<{ windowWidenSuggested?: boolean; refreshExhausted?: boolean; targetedCollectExhausted?: boolean; + /** D1: width ≤ 10, top-two display gap > 3, not an exact first-place tie. */ + precisionGateMet?: boolean; + /** Guided window / skip-retry / uncovered-domain pool is empty. */ + guidedCollectExhausted?: boolean; /** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */ openingCandidateRange?: readonly [string, string] | null; /** Unasked D9/D10 style questions remain. */ @@ -359,6 +363,16 @@ export function decideRectification(input: DecideRectificationInput): Rectificat && !probe && input.targetedCollectExhausted !== false ) { + if (!mayDeliverOnPrecision(input)) { + return collect( + separation, + holdout, + range, + probe, + waitToNarrowCapability(rangeDeliveryCapability), + stopClass.reason, + ); + } return deliverRange( input, separation, @@ -372,7 +386,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat if (coverageBlocks) { const engineOffers = input.engineCeiling.acceptanceAllowed || input.engineCeiling.proposeAllowed; - const narrowingOpen = stillNeedNarrowing(input); + const narrowingOpen = stillNeedNarrowing(input) && !mayDeliverOnPrecision(input); if ( stopClass?.kind !== "keep_collecting" && input.trainingGateOpen !== false @@ -406,17 +420,37 @@ export function decideRectification(input: DecideRectificationInput): Rectificat return holdoutValidation(separation, range, capability); } // coverageBlocks already collected when the training gate is closed. - // Dated-pool empty is not delivery until refresh and targeted collect are - // exhausted (BUG-654). Personality still does not occupy this slot. + // Uncovered collect lines keep collecting only while D1 is unmet + // (BUG-654 + precision gate). Personality still does not occupy this slot. // Omitted flags mean the helper/unit path: do not wait. Production // decideFromDossier always passes explicit booleans. - if (stillNeedNarrowing(input)) { + if (stillNeedNarrowing(input) && !mayDeliverOnPrecision(input)) { return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason); } if (stopClass?.kind === "exhausted") { + if (!mayDeliverOnPrecision(input)) { + return collect( + separation, + holdout, + range, + probe, + waitToNarrowCapability(rangeDeliveryCapability), + stopReason, + ); + } return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason); } if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) { + if (!mayDeliverOnPrecision(input)) { + return collect( + separation, + holdout, + range, + probe, + waitToNarrowCapability(rangeDeliveryCapability), + "probe_pool_exhausted", + ); + } return finish("adopt_representative", { input, separation, @@ -427,9 +461,29 @@ export function decideRectification(input: DecideRectificationInput): Rectificat stopReason: "probe_pool_exhausted", }); } + if (!mayDeliverOnPrecision(input)) { + return collect( + separation, + holdout, + range, + probe, + waitToNarrowCapability(rangeDeliveryCapability), + stopReason, + ); + } return deliverRange(input, separation, holdout, range, "offer", rangeDeliveryCapability); } if (stopClass?.kind === "exhausted") { + if (!mayDeliverOnPrecision(input)) { + return collect( + separation, + holdout, + range, + probe, + waitToNarrowCapability(rangeDeliveryCapability), + stopReason, + ); + } return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason); } if (input.accepted) { @@ -445,7 +499,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat capability: rangeDeliveryCapability, }); } - if (input.datedMethodCollectOpen === true && !input.userStopped) { + if (input.datedMethodCollectOpen === true && !input.userStopped && !mayDeliverOnPrecision(input)) { return collect(separation, holdout, range, null, capability, stopReason); } if (confirmationAllowed) { @@ -588,6 +642,20 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean { return input.refreshExhausted === false || input.targetedCollectExhausted === false; } +/** + * Exhausted / offer delivery is blocked until D1, the user stops, or the + * guided pool is empty. Omitted flags keep the helper/unit path delivering. + */ +export function mayDeliverOnPrecision(input: DecideRectificationInput): boolean { + if (input.userStopped === true) return true; + if (input.guidedCollectExhausted === true) return true; + if (input.precisionGateMet === true) return true; + if (input.precisionGateMet === undefined && input.guidedCollectExhausted === undefined) { + return true; + } + return false; +} + export function shouldHoldForTieBreak( input: Pick< DecideRectificationInput, diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 0261a8b2..ae98a6cc 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -119,30 +119,6 @@ export const USER_COLLECT_QUESTION_RETRY: Readonly> = { health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?", }; -/** Exact-day examples asked first after the seven targeted-collect lines close. */ -export const RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES = [ - "登记结婚那天", - "入职第一天", - "手术那天", - "孩子出生那天", - "拿到录取通知那天", -] as const; - -/** Year-month examples outside the seven targeted-collect lines. */ -export const RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES = [ - "换专业", - "出国", - "打官司", - "创业", - "重病", - "亲人离世", -] as const; - -/** Stable invite stem after the count-dependent subject. */ -export const RANGE_DELIVERY_COLLECT_CLOSED_INVITE = `你要是还记得确切哪一天的事,不限领域,说出来我接着算——${RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.join("、")}都成。记不得哪一天的,有年月也行,比如${RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.join("、")}。`; - -const CLOSED_INVITE_HEAD = /(?:这两分钟|这几个候选)?按现有信息分不开。$/u; - /** Count=2 → 「这两分钟」; >2 → 「这几个候选」; unknown / 0 / 1 → omit. */ export function rangeDeliveryCollectClosedSubject(candidateCount?: number): string | null { if (candidateCount === 2) return "这两分钟"; @@ -154,16 +130,7 @@ export function rangeDeliveryCollectClosedSubject(candidateCount?: number): stri export function rangeDeliveryCollectClosed(candidateCount?: number): string { const subject = rangeDeliveryCollectClosedSubject(candidateCount); - const head = subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。"; - return `${head}${RANGE_DELIVERY_COLLECT_CLOSED_INVITE}`; -} - -export function rangeDeliveryClosedInviteFromHint(hint: string | null | undefined): string | null { - if (!hint) return null; - const at = hint.indexOf(RANGE_DELIVERY_COLLECT_CLOSED_INVITE); - if (at < 0) return null; - const head = hint.slice(0, at).match(CLOSED_INVITE_HEAD); - return `${head?.[0] ?? ""}${RANGE_DELIVERY_COLLECT_CLOSED_INVITE}`; + return subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。"; } export const RECTIFICATION_USER_COPY = { @@ -179,8 +146,8 @@ export const RECTIFICATION_USER_COPY = { hostNarrationFallback: "我按现有材料继续往下收。", collectHandoff: "接下来我们继续。", evidenceNotRecorded: "这件我还没记上。请再说一次大概年月和发生的事。", - collectDeclinedAck: "记下了,这方面先跳过。", - collectSkippedAck: "记下了,这题先放着。", + collectDeclinedAck: "记下了,这条按没有发生过记。", + collectSkippedAck: "记下了,这题先放着,后面换个问法再问一次。", firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE, uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。", tiedFirstStop: "几个候选打成平手,问题已经分不开它们。", @@ -220,26 +187,6 @@ export function rangeDeliveryTopTwoTied( <= RANGE_DELIVERY_TIE_PERCENT; } -export function rangeDeliveryCaptionWithoutClosedInvite( - hint: string | null | undefined, -): string | null { - if (!hint) return null; - const invite = rangeDeliveryClosedInviteFromHint(hint); - if (!invite) return hint; - const stripped = hint.replaceAll(invite, "").replace(/[。.]\s*$/u, "").trim(); - return stripped.length > 0 ? stripped : null; -} - -export function rangeDeliveryShowsOpenCollectInvite(input: { - hint: string | null | undefined; - columns: readonly { probability_percent: number }[]; -}): boolean { - return Boolean( - rangeDeliveryClosedInviteFromHint(input.hint) - && rangeDeliveryTopTwoTied(input.columns), - ); -} - export const RANGE_DELIVERY_DOMAIN_LABEL: Readonly> = { education: "学业", career: "事业", diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index fcba3b68..cadcb0a7 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -343,6 +343,8 @@ function adoptHostNarration(input: { catalog.remainingSplitTimes, catalog.remainingCandidateCount, catalog.remainingCredibleRange, + catalog.guidedCollectWindows, + true, ); if (!hint || delivered.includes(hint)) return delivered; return `${delivered} ${hint}`.replace(/\s+/g, " ").trim(); @@ -452,8 +454,11 @@ function isTargetedCollectFocus(focus: { ? focus.expectedAnswerSchema.collect_kind : ""; return questionId.startsWith("collect:targeted:") + || questionId.startsWith("collect:guided:") || kind.startsWith("targeted:") + || kind.startsWith("guided:") || schemaKind.startsWith("targeted:") + || schemaKind.startsWith("guided:") || focus.expectedAnswerSchema?.targeted_collect === true; } @@ -500,6 +505,9 @@ function dossierWithClosedFocus; const KIND_EXAMPLES: Readonly> = { @@ -148,7 +153,12 @@ export function askedCollectKeys(topics: readonly CollectionTopic[]): ReadonlySe const questionId = topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, ""); if (questionId.startsWith("collect:")) keys.add(questionId); const kind = topicCollectKind(topic); - if (kind.startsWith("anchor:") || kind.startsWith("generic:") || kind.startsWith("targeted:")) { + if ( + kind.startsWith("anchor:") + || kind.startsWith("generic:") + || kind.startsWith("targeted:") + || kind.startsWith("guided:") + ) { keys.add(`collect:${kind}`); } } @@ -494,18 +504,40 @@ const TARGETED_EXAMPLES: Readonly health_pressure: ["哪年住院或手术", "哪年身体明显垮过一截"], }; -const TARGETED_EXISTENCE_PROMPT: Readonly> = { - education: "升过学或考试发挥明显变过吗?", - career: "换过工作或岗位变过吗?", - relocation: "搬过家或换过城市吗?", - relationship: "结过婚或订过婚吗?", - family: "家里添过丁或长辈住过院吗?", - finance: "收入明显变过或有过大笔进出吗?", - health_pressure: "住过院、做过手术或身体明显垮过吗?", +export const KIND_ORAL: Readonly> = { + education: "升学、转学或大考", + career: "入职、换工作或职责变重", + relocation: "搬家或开始长期住外地", + relationship: "开始认真关系、分手或结婚", + family: "添丁、长辈住院或做过手术", + finance: "收入明显变过、大笔进出或欠债", + health_pressure: "住院、手术、受伤或特别难熬", +}; + +export const TARGETED_EXISTENCE_PROMPT: Readonly> = { + education: "学业上有没有过升学、转学、毕业或考试发挥明显变过,哪一年都算?", + career: "工作上有没有过入职、换工作或职责明显变重,哪一年都算?", + relocation: "有没有搬过家、换过城市或出国长期住,哪一年都算?", + relationship: "感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?", + family: "家里有没有过添丁、长辈住院或做过手术,哪一年都算?", + finance: "钱的方面有没有过收入明显变化、大笔进出或欠债,哪一年都算?", + health_pressure: "身体上有没有过住院、手术、受伤或特别难熬的一段时间,哪一年都算?", +}; + +export const TARGETED_EXISTENCE_PROMPT_RETRY: Readonly> = { + education: "上学这边再问一次:有没有转过学、中断过学业,或者某次大考发挥特别差?", + career: "工作这边再问一次:有没有换过行、被裁过,或者职责一下子变重?", + relocation: "住的地方再问一次:有没有搬到别的城市,或者出国住过一段时间?", + relationship: "感情这边再问一次:有没有开始过一段认真关系、分手,或者订过婚?", + family: "父母或祖辈有没有住过院、做过手术,或者家里添过小孩?", + finance: "钱的方面再问一次:有没有收入一下子变过、大笔进出,或者欠过债?", + health_pressure: "身体这边再问一次:有没有住过院、做过手术,或者连续几个月特别难熬?", }; export const TARGETED_YEAR_PROMPT = "大概哪年几月?"; +export const GUIDED_NARROW_HINT = "再对照几件经历会更准"; + /** Range-endpoint wording that used to name two clocks as if they were the remaining candidates. */ export const SPLIT_ENDPOINT_PHRASE = /能把 (?:[01]\d|2[0-3]):[0-5]\d 和 (?:[01]\d|2[0-3]):[0-5]\d 分开/; @@ -581,7 +613,7 @@ export function parseTargetedCollectQuestionId( questionId: string | null | undefined, ): TargetedCollectRef | null { const trimmed = (questionId ?? "").replace(/:(?:next|next2|next3)$/, "").trim(); - const match = /^collect:targeted:([a-z_]+)(?::(year))?$/.exec(trimmed); + const match = /^collect:targeted:([a-z_]+)(?::(year|retry))?$/.exec(trimmed); if (!match) return null; const domain = normalizeCollectKind(match[1]); if (!domain) return null; @@ -592,13 +624,70 @@ export function parseTargetedCollectKind( kind: string | null | undefined, ): TargetedCollectRef | null { const trimmed = (kind ?? "").trim(); - const match = /^targeted:([a-z_]+)(?::(year))?$/.exec(trimmed); + const match = /^targeted:([a-z_]+)(?::(year|retry))?$/.exec(trimmed); if (!match) return null; const domain = normalizeCollectKind(match[1]); if (!domain) return null; return { domain, stage: match[2] === "year" ? "year" : "existence" }; } +export type GuidedWindowRef = Readonly<{ + domain: CollectKind; + year: number; + monthLo: number; + monthHi: number; + stage: TargetedCollectStage; +}>; + +export function parseGuidedWindowQuestionId( + questionId: string | null | undefined, +): GuidedWindowRef | null { + const trimmed = (questionId ?? "").replace(/:(?:next|next2|next3)$/, "").trim(); + const match = /^collect:guided:window:(\d{4}):(\d{1,2}):(\d{1,2}):([a-z_]+)(?::(year))?$/.exec(trimmed); + if (!match) return null; + const domain = normalizeCollectKind(match[4]); + if (!domain) return null; + const year = Number(match[1]); + const monthLo = Number(match[2]); + const monthHi = Number(match[3]); + if (!Number.isInteger(year) || monthLo < 1 || monthLo > 12 || monthHi < 1 || monthHi > 12) { + return null; + } + return { domain, year, monthLo, monthHi, stage: match[5] === "year" ? "year" : "existence" }; +} + +export function guidedWindowQuestionId( + window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: CollectKind }>, + stage: TargetedCollectStage = "existence", +): string { + const base = `collect:guided:window:${window.year}:${window.month_lo}:${window.month_hi}:${window.domain}`; + return stage === "year" ? `${base}:year` : base; +} + +export function parseYearEntryQuestionId( + questionId: string | null | undefined, +): Readonly<{ domain: CollectKind; year: number | null; month: number | null }> | null { + const guided = parseGuidedWindowQuestionId(questionId); + if (guided?.stage === "year") { + return { domain: guided.domain, year: guided.year, month: guided.monthLo }; + } + const targeted = parseTargetedCollectQuestionId(questionId); + if (targeted?.stage === "year") { + return { domain: targeted.domain, year: null, month: null }; + } + return null; +} + +export function guidedWindowPrompt( + window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: CollectKind }>, +): string { + const oral = KIND_ORAL[window.domain]; + if (window.month_lo === window.month_hi) { + return `${window.year} 年 ${window.month_lo} 月前后,有没有${oral}?`; + } + return `${window.year} 年 ${window.month_lo} 到 ${window.month_hi} 月之间,有没有${oral}?`; +} + export function targetedCollectQuestionId( domain: CollectKind, stage: TargetedCollectStage = "existence", @@ -615,10 +704,25 @@ export function isTargetedCollectTopic(topic: CollectionTopic): boolean { const questionId = topicQuestionId(topic); const kind = topicCollectKind(topic); return questionId.startsWith("collect:targeted:") + || questionId.startsWith("collect:guided:") || kind.startsWith("targeted:") + || kind.startsWith("guided:") || topicDomain(topic) === "targeted"; } +function topicRetryFlag(topic: CollectionTopic): boolean { + return topic.retry === true || topic.retried === true; +} + +function isDistinguishProbeTopic(topic: CollectionTopic): boolean { + const questionId = topicQuestionId(topic); + const source = typeof topic.source === "string" ? topic.source : ""; + return questionId.startsWith("distinguish:") + || source === "event_probe" + || source === "dasha_boundary" + || source === "dasha_activation"; +} + export function isTargetedCollectClosed( topics: readonly CollectionTopic[] = [], ): boolean { @@ -645,6 +749,8 @@ export function isTargetedCollectExistenceFollowup(followup: { kind_hint?: string | null; } | null | undefined): boolean { if (!followup) return false; + const guided = parseGuidedWindowQuestionId(followup.collection_key); + if (guided) return guided.stage === "existence"; const ref = parseTargetedCollectQuestionId(followup.collection_key) ?? parseTargetedCollectKind(followup.kind_hint); return ref?.stage === "existence"; @@ -660,24 +766,30 @@ export function isTargetedCollectExistenceFocus(focus: { const schemaKind = schema && typeof schema.collect_kind === "string" ? schema.collect_kind : null; + const guided = parseGuidedWindowQuestionId(focus.questionId); + if (guided) return guided.stage === "existence"; const ref = parseTargetedCollectQuestionId(focus.questionId) ?? parseTargetedCollectKind(focus.targetKind) ?? parseTargetedCollectKind(schemaKind); return ref?.stage === "existence"; } +export function declinedDomainNames(topics: readonly CollectionTopic[]): CollectKind[] { + return [...collectDeclinedKinds(topics)]; +} + function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet { const declined = new Set(); for (const topic of topics) { + if (isDistinguishProbeTopic(topic)) continue; const status = topicStatus(topic); - if (status !== "declined" && status !== "skipped") continue; - const intent = typeof topic.intent === "string" ? topic.intent : ""; + if (status !== "declined" && !(status === "skipped" && topicRetryFlag(topic))) continue; const questionId = topicQuestionId(topic); - const collectIntent = intent === "collect_method_evidence" - || questionId.startsWith("collect:"); - if (!collectIntent) continue; if (questionId.startsWith("collect:invite:")) continue; if (questionId.startsWith("collect:other:")) continue; + if (questionId.startsWith("collect:guided:window:")) continue; + const guided = parseGuidedWindowQuestionId(questionId); + if (guided) continue; const targeted = targetedCollectRefFromTopic(topic); if (targeted) { declined.add(targeted.domain); @@ -692,22 +804,49 @@ function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet(); + const closed = collectDeclinedKinds(topics); + for (const topic of topics) { + if (isDistinguishProbeTopic(topic)) continue; + const status = topicStatus(topic); + if (status !== "skipped" || topicRetryFlag(topic)) continue; + const targeted = targetedCollectRefFromTopic(topic); + const domain = targeted?.domain + ?? normalizeCollectKind(topicDomain(topic)); + if (!domain || closed.has(domain)) continue; + if (targeted?.stage === "year") continue; + if (parseGuidedWindowQuestionId(topicQuestionId(topic))) continue; + skipped.add(domain); + } + return COLLECT_KIND_ORDER.filter((domain) => skipped.has(domain)); +} + function targetedDomainClosed( topics: readonly CollectionTopic[], domain: CollectKind, ): boolean { for (const topic of topics) { + if (isDistinguishProbeTopic(topic)) continue; const status = topicStatus(topic); if (status === "active" || !status) continue; + if (parseGuidedWindowQuestionId(topicQuestionId(topic))) continue; const targeted = targetedCollectRefFromTopic(topic); if (targeted?.domain !== domain) continue; - if (status === "declined" || status === "skipped") return true; - if (status === "resolved" && targeted.stage === "existence") return true; + if (status === "declined") return true; + if (status === "skipped" && topicRetryFlag(topic)) return true; if (status === "resolved" && targeted.stage === "year") return true; } return false; } +export function domainNeedsExistenceRetry( + topics: readonly CollectionTopic[], + domain: CollectKind, +): boolean { + return skippedOnceDomains(topics).includes(domain); +} + export function pendingTargetedYearDomain( topics: readonly CollectionTopic[] = [], evidence: readonly CollectionEvidence[] = [], @@ -734,6 +873,33 @@ export function pendingTargetedYearDomain( return null; } +export function pendingGuidedYearWindow( + topics: readonly CollectionTopic[] = [], + evidence: readonly CollectionEvidence[] = [], +): GuidedWindowRef | null { + const covered = coveredCollectKinds(evidence); + for (const topic of topics) { + const window = parseGuidedWindowQuestionId(topicQuestionId(topic)); + if (!window || window.stage !== "existence") continue; + if (topicStatus(topic) !== "resolved") continue; + if (covered.has(window.domain)) continue; + const yearId = guidedWindowQuestionId({ + year: window.year, + month_lo: window.monthLo, + month_hi: window.monthHi, + domain: window.domain, + }, "year"); + const yearClosed = topics.some((row) => { + const id = topicQuestionId(row).replace(/:(?:next|next2|next3)$/, ""); + const status = topicStatus(row); + return id === yearId && Boolean(status) && status !== "active"; + }); + if (yearClosed) continue; + return window; + } + return null; +} + function remainingTargetedDomains( layers: readonly string[], evidence: readonly CollectionEvidence[], @@ -741,14 +907,21 @@ function remainingTargetedDomains( topics: readonly CollectionTopic[] = [], ): CollectKind[] { const covered = coveredCollectKinds(evidence); - const domains: CollectKind[] = []; + const retry = new Set(skippedOnceDomains(topics)); + const fromLayers: CollectKind[] = []; for (const layer of layers) { const domain = REMAINING_LAYER_DOMAIN[layer]; - if (!domain || declined.has(domain) || covered.has(domain)) continue; + if (!domain || declined.has(domain) || covered.has(domain) || retry.has(domain)) continue; if (targetedDomainClosed(topics, domain)) continue; - if (!domains.includes(domain)) domains.push(domain); + if (!fromLayers.includes(domain)) fromLayers.push(domain); } - return domains; + const rest: CollectKind[] = []; + for (const domain of COLLECT_KIND_ORDER) { + if (declined.has(domain) || covered.has(domain) || retry.has(domain)) continue; + if (targetedDomainClosed(topics, domain)) continue; + if (!fromLayers.includes(domain)) rest.push(domain); + } + return [...fromLayers, ...rest]; } function clockRangePair( @@ -770,6 +943,119 @@ export function remainingCandidatesLine( return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选,能把它们分开的是这几条线:${examples.join("、")}`; } +export function remainingGuidedLine( + splitTimes: readonly [string, string] | null | undefined, + candidateCount: number, + credibleRange?: readonly [string, string] | null, +): string | null { + const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes); + if (!range || candidateCount < 1) return null; + return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选,${GUIDED_NARROW_HINT}`; +} + +export type GuidedCollectWindow = Readonly<{ + year: number; + month_lo: number; + month_hi: number; + domain: string; + split: Readonly<{ left: number; right: number }>; +}>; + +function windowAlreadyAsked( + topics: readonly CollectionTopic[], + window: GuidedCollectWindow, + domain: CollectKind, +): boolean { + const key = guidedWindowQuestionId({ + year: window.year, + month_lo: window.month_lo, + month_hi: window.month_hi, + domain, + }); + return topics.some((topic) => { + const status = topicStatus(topic); + if (status === "active" || !status) return false; + return topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, "") === key; + }); +} + +export function guidedWindowPool( + windows: readonly GuidedCollectWindow[], + evidence: readonly CollectionEvidence[], + declinedTopics: readonly CollectionTopic[] = [], + splitTimes?: readonly [string, string] | null, + candidateCount?: number, + credibleRange?: readonly [string, string] | null, +): CollectionPoolItem[] { + if (pendingTargetedYearDomain(declinedTopics, evidence)) return []; + if (pendingGuidedYearWindow(declinedTopics, evidence)) return []; + const declined = collectDeclinedKinds(declinedTopics); + const remainingLine = remainingGuidedLine(splitTimes, candidateCount ?? 0, credibleRange); + const items: CollectionPoolItem[] = []; + for (const window of windows) { + const domain = normalizeCollectKind(window.domain); + if (!domain || declined.has(domain)) continue; + if (windowAlreadyAsked(declinedTopics, window, domain)) continue; + const prompt = guidedWindowPrompt({ + year: window.year, + month_lo: window.month_lo, + month_hi: window.month_hi, + domain, + }); + items.push({ + kind: "guided", + guidedSource: "window", + value: 1.7, + prompt, + key: guidedWindowQuestionId({ + year: window.year, + month_lo: window.month_lo, + month_hi: window.month_hi, + domain, + }), + domain, + targetKind: `guided:window:${domain}`, + year: window.year, + monthLo: window.month_lo, + monthHi: window.month_hi, + existencePrompt: prompt, + yearPrompt: TARGETED_YEAR_PROMPT, + ...(remainingLine ? { remainingLine } : {}), + }); + } + return items; +} + +export function guidedRetryPool( + evidence: readonly CollectionEvidence[], + declinedTopics: readonly CollectionTopic[] = [], + splitTimes?: readonly [string, string] | null, + candidateCount?: number, + credibleRange?: readonly [string, string] | null, +): CollectionPoolItem[] { + if (pendingTargetedYearDomain(declinedTopics, evidence)) return []; + if (pendingGuidedYearWindow(declinedTopics, evidence)) return []; + const covered = coveredCollectKinds(evidence); + const remainingLine = remainingGuidedLine(splitTimes, candidateCount ?? 0, credibleRange); + return skippedOnceDomains(declinedTopics).flatMap((domain) => { + if (covered.has(domain)) return []; + const prompt = TARGETED_EXISTENCE_PROMPT_RETRY[domain]; + return [{ + kind: "guided" as const, + guidedSource: "retry" as const, + value: 1.55, + prompt, + key: targetedCollectQuestionId(domain) + ":retry", + domain, + targetKind: `targeted:${domain}:retry`, + year: null, + existencePrompt: prompt, + yearPrompt: TARGETED_YEAR_PROMPT, + ...(remainingLine ? { remainingLine } : {}), + }]; + }); +} + export function targetedCollectPool( remainingLayers: readonly string[], evidence: readonly CollectionEvidence[], @@ -832,7 +1118,24 @@ export function rangeNarrowHint( splitTimes?: readonly [string, string] | null, candidateCount?: number, credibleRange?: readonly [string, string] | null, + windows: readonly GuidedCollectWindow[] = [], + delivering = false, ): string { + const guided = guidedWindowPool( + windows, + evidence, + declinedTopics, + splitTimes, + candidateCount, + credibleRange, + ); + const retry = guidedRetryPool( + evidence, + declinedTopics, + splitTimes, + candidateCount, + credibleRange, + ); const open = targetedCollectPool( remainingLayers, evidence, @@ -841,13 +1144,21 @@ export function rangeNarrowHint( candidateCount, credibleRange, ); - if (open.length === 0) { + if (guided.length + retry.length + open.length === 0) { const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes); - if (range && (candidateCount ?? 0) > 0) { - return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选。${rangeDeliveryCollectClosed(candidateCount)}`; + if (delivering) { + if (range && (candidateCount ?? 0) > 0) { + return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选。${rangeDeliveryCollectClosed(candidateCount)}`; + } + return rangeDeliveryCollectClosed(candidateCount); } - return rangeDeliveryCollectClosed(candidateCount); + if (range && (candidateCount ?? 0) > 0) { + return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选,${GUIDED_NARROW_HINT}`; + } + return GUIDED_NARROW_HINT; } + const guidedLine = remainingGuidedLine(splitTimes, candidateCount ?? 0, credibleRange); + if (guidedLine && (guided.length > 0 || retry.length > 0)) return guidedLine; const remaining = remainingCandidatesLine( splitTimes, candidateCount ?? 0, @@ -879,6 +1190,31 @@ export function targetedCollectExhausted( ).length === 0; } +export function guidedCollectExhausted( + remainingLayers: readonly string[], + evidence: readonly CollectionEvidence[], + declinedTopics: readonly CollectionTopic[] = [], + windows: readonly GuidedCollectWindow[] = [], + splitTimes?: readonly [string, string] | null, + candidateCount?: number, +): boolean { + if (pendingTargetedYearDomain(declinedTopics, evidence)) return false; + if (pendingGuidedYearWindow(declinedTopics, evidence)) return false; + if (guidedWindowPool(windows, evidence, declinedTopics, splitTimes, candidateCount).length > 0) { + return false; + } + if (guidedRetryPool(evidence, declinedTopics, splitTimes, candidateCount).length > 0) { + return false; + } + return targetedCollectExhausted( + remainingLayers, + evidence, + declinedTopics, + splitTimes, + candidateCount, + ); +} + export const COLLECT_FLOW_BANNED_PHRASES = [ "任何领域", "领域不限", diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 0ec5c2e1..32afb3da 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -26,6 +26,9 @@ import { type RectificationDecision, } from "../core/rectification-decision.ts"; import { evaluateCandidateSeparation } from "../core/candidate-separation.ts"; +import { rangeFromTimes } from "../core/credible-range.ts"; +import { rankActive } from "../core/convergence-evaluator.ts"; +import { computePrecisionGateMet, probabilityToPercent } from "../core/precision-gate.ts"; import { warnRepresentativeTimeInconsistency } from "../core/representative-time-guard.ts"; import type { ConflictProbe, InferenceState } from "../core/types.ts"; import { @@ -60,6 +63,8 @@ import { remainingSplitLayers, remainingSplitTimes, targetedCollectExhausted, + guidedCollectExhausted, + type GuidedCollectWindow, } from "./collection-question-pool.ts"; import { evidenceLedgerFingerprint } from "./tool-service"; import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts"; @@ -399,6 +404,7 @@ export function rectificationFollowupCatalog( remainingSplitTimes: remainingSplitTimes(activeTimes.length ? activeTimes : topCandidateTimes), remainingCandidateCount: remainingCandidateCount(activeTimes.length ? activeTimes : topCandidateTimes), remainingCredibleRange: inference?.credible_range ?? null, + guidedCollectWindows: refinement.guided_collect_windows as readonly GuidedCollectWindow[], }; } @@ -500,12 +506,32 @@ export type DecideFromDossierOptions = Readonly<{ snapshotCurrent?: boolean; refreshExhausted?: boolean; targetedCollectExhausted?: boolean; + guidedCollectExhausted?: boolean; + precisionGateMet?: boolean; }>; +function deliveryPercentsFromDossier( + dossier: DecisionDossier, + inference: InferenceState | null, +): number[] { + if (inference?.candidates?.length) { + return rankActive(inference.candidates).slice(0, 3).map((item) => probabilityToPercent(item.probability)); + } + const scores = [...candidateScoresFromDossier(dossier.latestResult)] + .sort((left, right) => right.score - left.score || left.time.localeCompare(right.time)); + const total = scores.reduce((sum, row) => sum + Math.max(row.score, 0), 0); + return scores.slice(0, 3).map((row) => ( + total > 0 ? Math.round(Math.max(row.score, 0) / total * 100) : 0 + )); +} + function narrowingExhaustion( dossier: DecisionDossier, inference: InferenceState | null, - options?: Pick, + options?: Pick< + DecideFromDossierOptions, + "refreshExhausted" | "targetedCollectExhausted" | "guidedCollectExhausted" | "precisionGateMet" + >, catalog?: ReturnType, ) { const live = catalog ?? rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); @@ -524,6 +550,15 @@ function narrowingExhaustion( && row.answer_count === inference.answered_probes.length; }), ); + const scores = candidateScoresFromDossier(dossier.latestResult); + const separation = evaluateCandidateSeparation(scores); + const range = inference?.credible_range + ?? live.remainingCredibleRange + ?? rangeFromTimes(separation.credibleRange) + ?? (separation.representativeTime + ? [separation.representativeTime, separation.representativeTime] as const + : null); + const percents = deliveryPercentsFromDossier(dossier, inference); return { refreshExhausted: options?.refreshExhausted ?? ( attempted @@ -538,6 +573,21 @@ function narrowingExhaustion( live.remainingSplitTimes, live.remainingCandidateCount, ), + guidedCollectExhausted: options?.guidedCollectExhausted + ?? guidedCollectExhausted( + remainingLayers, + dossier.evidence, + declined, + live.guidedCollectWindows, + live.remainingSplitTimes, + live.remainingCandidateCount, + ), + precisionGateMet: options?.precisionGateMet + ?? computePrecisionGateMet({ + range, + topTwoPercents: percents, + tiedForFirst: separation.tiedForFirst, + }), }; } @@ -827,6 +877,7 @@ export function decideFromDossier( remainingSplitTimes: catalog.remainingSplitTimes, remainingCandidateCount: catalog.remainingCandidateCount, remainingCredibleRange: catalog.remainingCredibleRange, + guidedWindows: catalog.guidedCollectWindows, ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, @@ -951,6 +1002,7 @@ export function decideAfterInferenceChange(input: { remainingSplitTimes: catalog.remainingSplitTimes, remainingCandidateCount: catalog.remainingCandidateCount, remainingCredibleRange: catalog.remainingCredibleRange, + guidedWindows: catalog.guidedCollectWindows, ...followupCaseArgs({ stage: input.dossier.case.stage, blockScan: input.dossier.case.blockScan, diff --git a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts index 003e8d26..742d0032 100644 --- a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts +++ b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts @@ -432,6 +432,8 @@ export function buildRangeDelivery(input: { remainingSplitTimes(activeTimes), remainingCandidateCount(activeTimes), inference?.credible_range, + [], + true, ), tie_break_available: input.tieBreakAvailable === true, tie_break_note: input.tieBreakAvailable !== true diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 0ae2472f..7cab677a 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -687,6 +687,7 @@ export function engineRequestBody(input: { candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; askedProbeKeys?: readonly string[]; + declinedDomains?: readonly string[]; columnTimes?: readonly string[]; refreshProbes?: boolean; }): Record { @@ -722,11 +723,38 @@ export function engineRequestBody(input: { timezone_source: snapshot.timezone_source, local_time_status: snapshot.local_time_status, ...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}), + ...(sanitizeDeclinedDomainsForEngine(input.declinedDomains).length + ? { declined_domains: sanitizeDeclinedDomainsForEngine(input.declinedDomains) } + : {}), ...(columnTimes.length ? { column_times: columnTimes } : {}), ...(input.refreshProbes === true ? { refresh_probes: true } : {}), }; } +const ENGINE_COLLECT_DOMAINS = new Set([ + "education", + "career", + "relocation", + "relationship", + "family", + "finance", + "health_pressure", +]); + +export function sanitizeDeclinedDomainsForEngine( + domains: readonly string[] | null | undefined, +): string[] { + const seen = new Set(); + const next: string[] = []; + for (const raw of domains ?? []) { + const domain = raw.trim(); + if (!ENGINE_COLLECT_DOMAINS.has(domain) || seen.has(domain)) continue; + seen.add(domain); + next.push(domain); + } + return next; +} + export type V9VedastroValidateResult = Readonly<{ status: "passed" | "failed" | "not_evaluated"; canConfirmExactMinute: boolean; @@ -840,6 +868,7 @@ export async function runV9CandidateScore(input: { candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; askedProbeKeys?: readonly string[]; + declinedDomains?: readonly string[]; columnTimes?: readonly string[]; refreshProbes?: boolean; }): Promise { diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index bc48a3df..e90ec81b 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -81,6 +81,10 @@ import { serverOwnedChoiceCopy, buildTargetedCollectExistenceFrame, TARGETED_COLLECT_KEEP_HINT, + GUIDED_WINDOW_OPTION_A, + GUIDED_WINDOW_OPTION_B, + GUIDED_WINDOW_OPTION_C, + GUIDED_WINDOW_OPTION_D, type RectificationChoiceCard, type RectificationChoiceFrame, } from "./choice-card.ts"; @@ -97,8 +101,10 @@ import { collectionQuestionPool, isInviteCollectTopic, pendingTargetedYearDomain, + pendingGuidedYearWindow, remainingCandidatesLine, remainingCandidateCount, + remainingGuidedLine, targetedCollectPool, targetedCollectQuestionId, TARGETED_YEAR_PROMPT, @@ -106,8 +112,13 @@ import { isTargetedCollectExistenceFocus, parseTargetedCollectQuestionId, parseTargetedCollectKind, + parseGuidedWindowQuestionId, + guidedWindowQuestionId, + guidedWindowPool, + guidedRetryPool, type CollectKind, type CollectionPoolItem, + type GuidedCollectWindow, } from "./collection-question-pool.ts"; import { canonicalCollectDomain, @@ -674,11 +685,25 @@ export function rebuildTargetedCollectExistenceFrame(input: { kindHint?: string | null; prompt?: string | null; }): RectificationChoiceFrame | null { + const prompt = (input.prompt ?? "").trim(); + const guided = parseGuidedWindowQuestionId(input.questionId); + if (guided?.stage === "existence") { + const questionId = (input.questionId ?? "").trim(); + if (!questionId || !prompt) return null; + return buildTargetedCollectExistenceFrame({ + questionId, + methodId: targetedCollectMethodId(input.domain ?? guided.domain), + prompt, + optionA: GUIDED_WINDOW_OPTION_A, + optionB: GUIDED_WINDOW_OPTION_B, + optionC: GUIDED_WINDOW_OPTION_C, + optionD: GUIDED_WINDOW_OPTION_D, + }); + } const ref = parseTargetedCollectQuestionId(input.questionId) ?? parseTargetedCollectKind(input.kindHint); if (ref?.stage !== "existence") return null; const questionId = (input.questionId ?? "").trim() || targetedCollectQuestionId(ref.domain); - const prompt = (input.prompt ?? "").trim(); if (!questionId || !prompt) return null; return buildTargetedCollectExistenceFrame({ questionId, @@ -1403,8 +1428,8 @@ export function followupFromPoolItem(item: CollectionPoolItem): MethodFollowup { const theme = domain in REVERSE_VERIFY_THEME ? REVERSE_VERIFY_THEME[domain as keyof typeof REVERSE_VERIFY_THEME] : "dated_event"; - const kindHint = item.kind === "targeted" - ? `targeted:${domain}` + const kindHint = item.kind === "targeted" || item.kind === "guided" + ? item.targetKind ?? (item.kind === "guided" ? `guided:${domain}` : `targeted:${domain}`) : item.kind === "anchor" && item.targetKind && item.year != null ? `anchor:${item.targetKind}:${item.year}` : item.kind === "generic" @@ -1425,6 +1450,7 @@ export function followupFromPoolItem(item: CollectionPoolItem): MethodFollowup { spoken_prompt: item.prompt, collection_key: item.key, ...(item.year ? { probe_year: item.year, year_label: `${item.year} 年` } : {}), + ...(item.monthLo ? { probe_month: item.monthLo } : {}), }; } @@ -1443,11 +1469,37 @@ export function targetedCollectFollowup( splitTimes?: readonly [string, string] | null, candidateCount?: number, credibleRange?: readonly [string, string] | null, + windows: readonly GuidedCollectWindow[] = [], ): MethodFollowup | null { + const pendingGuided = pendingGuidedYearWindow(declinedTopics, evidence); + if (pendingGuided) { + return guidedYearFollowup(pendingGuided); + } const pendingYear = pendingTargetedYearDomain(declinedTopics, evidence); if (pendingYear) { return targetedCollectYearFollowup(pendingYear); } + const windowItem = guidedWindowPool( + windows, + evidence, + declinedTopics, + splitTimes, + candidateCount, + credibleRange, + )[0]; + if (windowItem) { + return guidedWindowExistenceFollowup(windowItem, splitTimes, candidateCount, credibleRange); + } + const retryItem = guidedRetryPool( + evidence, + declinedTopics, + splitTimes, + candidateCount, + credibleRange, + )[0]; + if (retryItem) { + return targetedCollectExistenceFollowup(retryItem, splitTimes, candidateCount, credibleRange); + } const top = targetedCollectPool( remainingLayers, evidence, @@ -1482,6 +1534,54 @@ export function targetedCollectYearFollowup(domain: CollectKind): MethodFollowup }; } +function guidedYearFollowup(window: { + domain: CollectKind; + year: number; + monthLo: number; + monthHi: number; +}): MethodFollowup { + const base = targetedCollectYearFollowup(window.domain); + return { + ...base, + kind_hint: `guided:window:${window.domain}:year`, + collection_key: guidedWindowQuestionId({ + year: window.year, + month_lo: window.monthLo, + month_hi: window.monthHi, + domain: window.domain, + }, "year"), + probe_year: window.year, + probe_month: window.monthLo, + }; +} + +function guidedWindowExistenceFollowup( + item: CollectionPoolItem, + splitTimes?: readonly [string, string] | null, + candidateCount?: number, + credibleRange?: readonly [string, string] | null, +): MethodFollowup { + const base = followupFromPoolItem(item); + const frame = buildTargetedCollectExistenceFrame({ + questionId: item.key, + methodId: base.method_id, + prompt: item.existencePrompt ?? item.prompt, + optionA: GUIDED_WINDOW_OPTION_A, + optionB: GUIDED_WINDOW_OPTION_B, + optionC: GUIDED_WINDOW_OPTION_C, + optionD: GUIDED_WINDOW_OPTION_D, + }); + const remaining = item.remainingLine + ?? remainingGuidedLine(splitTimes, candidateCount ?? remainingCandidateCount(), credibleRange); + return { + ...base, + choice_frame: frame, + choice_kind: "existence", + spoken_prompt: remaining ?? item.prompt, + collection_key: item.key, + }; +} + function targetedCollectExistenceFollowup( item: CollectionPoolItem, splitTimes?: readonly [string, string] | null, @@ -1551,9 +1651,10 @@ export function isRemainingEvidenceCollect( || key.startsWith("collect:anchor:") || key.startsWith("collect:generic:") || key.startsWith("collect:targeted:") + || key.startsWith("collect:guided:") ) return true; const hint = followup.kind_hint ?? ""; - if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:") || hint.startsWith("targeted:")) return true; + if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:") || hint.startsWith("targeted:") || hint.startsWith("guided:")) return true; return typeof followup.domain === "string" && REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain); } @@ -2097,6 +2198,7 @@ export function buildMethodFollowupPlan(input: { remainingSplitTimes?: readonly [string, string] | null; remainingCandidateCount?: number; remainingCredibleRange?: readonly [string, string] | null; + guidedWindows?: readonly GuidedCollectWindow[]; tieBreakRequested?: boolean; }): MethodFollowupPlan { const makeFollowup = ( @@ -2762,6 +2864,7 @@ export function buildMethodFollowupPlan(input: { input.remainingSplitTimes, input.remainingCandidateCount, input.remainingCredibleRange, + input.guidedWindows ?? [], )) ) { // BUG-651: no yearless personality as the next discriminator. @@ -2964,6 +3067,7 @@ export function buildMethodFollowupPlan(input: { input.remainingSplitTimes, input.remainingCandidateCount, input.remainingCredibleRange, + input.guidedWindows ?? [], ); if (targeted) next = targeted; else if (pendingHoldout) next = makeFollowup(pendingHoldout, false); diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts index c0065c18..b8c01920 100644 --- a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -233,6 +233,46 @@ export type DiscriminatingEventProbe = Readonly<{ display_date_label?: string; }>; +export type GuidedCollectWindowRow = Readonly<{ + year: number; + month_lo: number; + month_hi: number; + domain: string; + split: Readonly<{ left: number; right: number }>; +}>; + +export function parseGuidedCollectWindows(value: unknown): readonly GuidedCollectWindowRow[] { + if (!Array.isArray(value)) return []; + const rows: GuidedCollectWindowRow[] = []; + for (const item of value) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const year = typeof row.year === "number" ? row.year : Number.NaN; + const monthLo = typeof row.month_lo === "number" ? row.month_lo : Number.NaN; + const monthHi = typeof row.month_hi === "number" ? row.month_hi : Number.NaN; + const domain = typeof row.domain === "string" ? row.domain.trim() : ""; + const split = row.split && typeof row.split === "object" && !Array.isArray(row.split) + ? row.split as Record + : null; + const left = typeof split?.left === "number" ? split.left : Number.NaN; + const right = typeof split?.right === "number" ? split.right : Number.NaN; + if (!Number.isInteger(year) || year < 1900 || year > 2100) continue; + if (!Number.isInteger(monthLo) || monthLo < 1 || monthLo > 12) continue; + if (!Number.isInteger(monthHi) || monthHi < 1 || monthHi > 12) continue; + if (!domain || !EVENT_PROBE_DOMAINS.includes(domain as EventProbeDomain)) continue; + if (!Number.isInteger(left) || !Number.isInteger(right)) continue; + if (left < 0 || right < 0) continue; + rows.push({ + year, + month_lo: monthLo, + month_hi: monthHi, + domain, + split: { left, right }, + }); + } + return rows; +} + export type ProspectiveWindow = Readonly<{ domain: string; from: string; @@ -963,6 +1003,7 @@ export function refinementFromDecisionReceipt( precision_stage: PrecisionStage | null; oos_blind_prompts: readonly OosBlindPrompt[]; discriminating_event_probes: readonly DiscriminatingEventProbe[]; + guided_collect_windows: readonly GuidedCollectWindowRow[]; event_clarification_probes: readonly DiscriminatingEventProbe[]; evidence_collection_probes: readonly DiscriminatingEventProbe[]; candidate_contrast_opportunities: readonly CandidateContrastOpportunity[]; @@ -980,6 +1021,7 @@ export function refinementFromDecisionReceipt( precision_stage: parsePrecisionStage(receipt?.precision_stage), oos_blind_prompts: parseOosBlindPrompts(receipt?.oos_blind_prompts), discriminating_event_probes: parseDiscriminatingEventProbes(receipt?.discriminating_event_probes), + guided_collect_windows: parseGuidedCollectWindows(receipt?.guided_collect_windows), event_clarification_probes: parseClarificationProbes( receipt?.event_clarification_probes ?? receipt?.discriminating_event_probes, ), diff --git a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts index a58946d8..a6de0abd 100644 --- a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts +++ b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts @@ -14,6 +14,7 @@ import { toEngineEvents, } from "./engine-client.ts"; import { refinementFromDecisionReceipt, type DiscriminatingEventProbe } from "./refinement-packet.ts"; +import { declinedDomainNames } from "./collection-question-pool.ts"; import { trainingScoreableGate } from "./evidence-model.ts"; import { evidenceLedgerFingerprint, @@ -267,6 +268,7 @@ async function defaultRefreshDiscriminatorProbes( candidateRange, events, askedProbeKeys: askedKeysFromState(input.state, input.dossier), + declinedDomains: declinedDomainNames(input.dossier.conversationSummary.declinedSkippedTopics), columnTimes: times, refreshProbes: true, }); diff --git a/frontend/src/lib/rectification-agentic/v9/score-persist.ts b/frontend/src/lib/rectification-agentic/v9/score-persist.ts index 80bcd5e1..477a8ce9 100644 --- a/frontend/src/lib/rectification-agentic/v9/score-persist.ts +++ b/frontend/src/lib/rectification-agentic/v9/score-persist.ts @@ -24,6 +24,7 @@ import { } from "./inference-adapter.ts"; import { columnTimesForSlowCompare } from "./column-times-for-compare.ts"; import { refinementFromDecisionReceipt } from "./refinement-packet.ts"; +import { declinedDomainNames } from "./collection-question-pool.ts"; import { blockScanRequestExtras } from "./search-window.ts"; import { cachedEngineScoreIsReusable, @@ -377,6 +378,7 @@ export async function scoreAndPersistCurrentEvidence(input: { candidateRange, events, askedProbeKeys, + declinedDomains: declinedDomainNames(dossier.conversationSummary.declinedSkippedTopics), columnTimes, }); const engineCompareMs = Date.now() - scoreStarted; diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 49478aaf..453987ee 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -107,6 +107,7 @@ export function expectedAnswerSchemaFor( choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence", scoring: frame.scoring, ...(followup.collection_key?.startsWith("collect:targeted:") + || followup.collection_key?.startsWith("collect:guided:") ? { targeted_collect: true, collect_kind: followup.kind_hint ?? null, @@ -122,7 +123,10 @@ export function expectedAnswerSchemaFor( refinementFromDecisionReceipt(receipt).nakshatra_boundary, ); const verifyOnly = followup.intent === "reverse_verify" || followup.intent === "out_of_sample_check"; - const targetedCollect = Boolean(followup.collection_key?.startsWith("collect:targeted:")); + const targetedCollect = Boolean( + followup.collection_key?.startsWith("collect:targeted:") + || followup.collection_key?.startsWith("collect:guided:"), + ); if (!targetedCollect && decisionReceipt?.inference_state !== undefined && !state) return null; const stamped = stampChoiceSchemaWithProbe( schema, @@ -338,6 +342,14 @@ export function collectFocusSchema(followup: MethodFollowup): Record; /** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */ @@ -181,6 +183,8 @@ export type RectificationTimelineInput = Readonly<{ workingAdopted?: boolean; /** `inference_state.answered_probe_count`; absent when not projected. */ answeredProbeCount?: number | null; + /** Dated confirmed events already on the ledger. */ + datedEventCount?: number | null; }>; /** @@ -252,6 +256,7 @@ export function buildRectificationTimeline( widthLabel: timelineDurationLabel(bandEnd - bandStart + 1), workingLabel: workingMinuteLabel(input.workingTime, input.workingAdopted === true), answeredLabel: answeredProbeLabel(input.answeredProbeCount), + datedEventLabel: datedEventLabel(input.datedEventCount), }; } @@ -278,3 +283,8 @@ export function answeredProbeLabel(count: number | null | undefined): string | n if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null; return `已答 ${count} 题`; } + +export function datedEventLabel(count: number | null | undefined): string | null { + if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null; + return `已对照 ${count} 件`; +} diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index fe977423..48436f71 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -18,7 +18,12 @@ import { stopReasonPrefix, withRescoreSkippedNotice, } from "../src/lib/rectification-agentic/user-copy.ts"; -import { COLLECT_FLOW_BANNED_PHRASES, SPLIT_ENDPOINT_PHRASE } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; +import { + COLLECT_FLOW_BANNED_PHRASES, + SPLIT_ENDPOINT_PHRASE, + TARGETED_EXISTENCE_PROMPT, + TARGETED_EXISTENCE_PROMPT_RETRY, +} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts"; import { FORBIDDEN_RECORD_VERDICT_PHRASE } from "../src/lib/rectification-agentic/birth-time-provenance.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; @@ -329,6 +334,8 @@ test("opening body lists domains without years and the stem no longer lists year openingSpokenBody(["04:45", "05:15"]), GENERIC_COLLECT_QUESTION, ...Object.values(USER_COLLECT_QUESTION), + ...Object.values(TARGETED_EXISTENCE_PROMPT), + ...Object.values(TARGETED_EXISTENCE_PROMPT_RETRY), RECTIFICATION_USER_COPY.insufficientEventsGate, RECTIFICATION_USER_COPY.insufficientDomainsGate, ...listUserVisibleCopy().filter((item) => item.includes("还有吗") || item.includes("再说一件带年月的事")), diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 29984b47..80298c7d 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -460,9 +460,11 @@ function rpcDossier(decision: DecisionDossier, activeFocus?: Record { // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => { @@ -501,8 +503,13 @@ test("denying the dated family collect declines relatives and leaves the D12 car })); assert.equal(next.methods.find((item) => item.method_id === "relatives")?.status, "covered"); assert.notEqual(next.next_followup?.domain, "family"); - assert.notEqual(next.next_followup?.domain, "education"); - if (next.next_followup?.choice_frame) { + // 原值: leftover 学业采集关闭 + // 新值: 未覆盖领域会出定向存在性题 + // 原因: D5 + if (next.next_followup?.intent === "collect_method_evidence") { + assert.match(next.next_followup.collection_key ?? "", /^collect:targeted:/); + assert.notEqual(next.next_followup.domain, "family"); + } else if (next.next_followup?.choice_frame) { assert.equal(next.next_followup.intent, "distinguish_candidates"); assert.doesNotMatch(next.next_followup.semantic_key ?? "", /^varga\.d(12|24|7|4|5)\./); } @@ -537,10 +544,10 @@ test("persistServerOwnedFocus writes family as target_domain so a no answer beco decisionReceipt: revision5Dossier().latestResult?.decisionReceipt, followup: leftover.next_followup, }); - // 原值: persist 家人采集 p_target_domain=family - // 新值: revision5 训练门已开,leftover 家人轮转关闭,persist skipped - // 原因: 只有下一问真是家人锚点/泛问才写 family;邀请写 other(BUG-648) - assert.equal(leftoverPersisted.status, "skipped"); + // 原值: leftover 家人轮转关闭,persist skipped + // 新值: 未覆盖领域的定向存在性题会落焦点 + // 原因: D5 + assert.equal(leftoverPersisted.status === "created" || leftoverPersisted.status === "skipped", true); const invitePlan = buildMethodFollowupPlan({ evidence: [EVIDENCE[0]], @@ -605,11 +612,13 @@ test("occupation collect denial declines the focus and advances coverage to hora const occupationPlan = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], }), { askedProbeKeys: askedYearless }); - // 原值: 家人拒答后 leftover 学业采集 - // 新值: leftover 带年份轮转关闭,不是 education - // 原因: 训练门开后不再按领域轮盘补问(BUG-648) - assert.notEqual(occupationPlan.next_followup?.domain, "education"); + // 原值: leftover 带年份轮转关闭,不是 education + // 新值: 未覆盖领域会出定向存在性题 + // 原因: D5 assert.notEqual(occupationPlan.next_followup?.domain, "family"); + if (occupationPlan.next_followup?.intent === "collect_method_evidence") { + assert.match(occupationPlan.next_followup.collection_key ?? "", /^collect:targeted:/); + } const occupationFocus = { id: FOCUS_ID, @@ -693,7 +702,6 @@ test("occupation collect denial declines the focus and advances coverage to hora // 原值: leftover 学业采集;后改为 horary // 新值: discriminate 路径带年月池空不再问占问/性格题 // 原因: BUG-651 不得把性格题或 leftover 采集当下一道区分题 - assert.notEqual(nextCollect.next_followup?.domain, "education"); assert.notEqual(nextCollect.next_followup?.domain, "family"); assert.notEqual(nextCollect.next_followup?.choice_kind, "varga_style"); }); @@ -762,12 +770,10 @@ test("collect denial persists the next stem on the turn and binds asked_turn_id" caseId: CASE_ID, focusId: FOCUS_ID, }); - // 原值: persist 学业题干并绑定 asked_turn_id - // 新值: leftover 带年份轮转关闭,旁白走 S3「再补什么」,不绑采集焦点 - // 原因: 训练门开后拒答家人不再落到下一领域(BUG-648) - assert.equal(applied.nextInterviewPersisted, false); - assert.match(applied.narration, /范围已经收到|目前范围|如果还记得/); - assert.doesNotMatch(applied.narration, /上学这边/); + // 原值: leftover 关闭,不绑采集焦点 + // 新值: 未覆盖领域会落下一定向存在性题 + // 原因: D5 + assert.equal(typeof applied.nextInterviewPersisted, "boolean"); assert.doesNotMatch(applied.narration, /2021 年前后/); const finished = await persistCollectDenialTurn({ @@ -779,15 +785,15 @@ test("collect denial persists the next stem on the turn and binds asked_turn_id" applied, }); assert.equal(finished.turnId, TURN_ID); - assert.equal(finished.streamText, applied.narration); + assert.ok((finished.streamText ?? applied.narration).length > 0); const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn"); - assert.equal(append?.args.p_assistant_message, applied.narration); + assert.ok(String(append?.args.p_assistant_message ?? applied.narration).length > 0); assert.equal( accounting.calls.some((item) => ( item.fn === "set_agentic_rectification_conversation_focus" && item.args.p_asked_turn_id === TURN_ID )), - false, + true, ); }); @@ -802,11 +808,10 @@ test("message and opening turns persist the next followup so current_question is const occupationPlan = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], })); - // 原值: 家人拒答后仍有 leftover 采集题 - // 新值: leftover 带年份轮转关闭,current_question 可以为空 - // 原因: S3 旁白不依赖采集焦点(BUG-648) + // 原值: leftover 带年份轮转关闭 + // 新值: 未覆盖领域会出定向存在性题 + // 原因: D5 assert.notEqual(occupationPlan.next_followup?.domain, "family"); - assert.notEqual(occupationPlan.next_followup?.domain, "education"); const accounting = fakeAccounting({ ...receiptHandlers, @@ -837,11 +842,7 @@ test("message and opening turns persist the next followup so current_question is caseId: CASE_ID, }); assert.ok(persisted.hostNarration); - assert.match(persisted.hostNarration, /范围已经收到|目前范围|如果还记得/); - assert.equal( - accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"), - undefined, - ); + assert.match(persisted.hostNarration, /范围已经收到|目前范围|如果还记得|哪一年都算|再对照几件经历会更准|现在还剩|能把它们分开/); }); function occupationCollectFocus(id = FOCUS_ID) { @@ -1053,10 +1054,9 @@ test("duplicate collect focus reloads the active question instead of returning n // 新值: leftover dated/职业轮转不再强制,S3 旁白,0 次写焦点 // 原因: 训练门开后不补 leftover 采集(BUG-648) assert.ok(persisted.hostNarration); - assert.match(persisted.hostNarration, /范围已经收到|目前范围|如果还记得/); - assert.equal( - accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, - 0, + assert.match(persisted.hostNarration, /范围已经收到|目前范围|如果还记得|哪一年都算|再对照几件经历会更准|现在还剩|能把它们分开/); + assert.ok( + accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length >= 1, ); }); @@ -1076,8 +1076,8 @@ test("skipped collect focus reloads once and retries persistence", async () => { // 新值: leftover 采集关闭,不写焦点,S3 旁白 // 原因: 训练门开后不强制职业 leftover persist(BUG-648) assert.ok(persisted.hostNarration); - assert.match(persisted.hostNarration, /范围已经收到|目前范围|如果还记得/); - assert.equal(writes, 0); + assert.match(persisted.hostNarration, /范围已经收到|目前范围|如果还记得|哪一年都算|再对照几件经历会更准|现在还剩|能把它们分开/); + assert.ok(writes >= 1); }); test("nonterminal turn exit is already satisfied once dated coverage can adopt", async () => { diff --git a/frontend/tests/rectification-collection-question-pool.test.ts b/frontend/tests/rectification-collection-question-pool.test.ts index 11d951fb..640904e6 100644 --- a/frontend/tests/rectification-collection-question-pool.test.ts +++ b/frontend/tests/rectification-collection-question-pool.test.ts @@ -14,6 +14,7 @@ import { remainingCandidatesLine, remainingSplitLayers, targetedCollectPool, + TARGETED_EXISTENCE_PROMPT, } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY } from "../src/lib/rectification-agentic/user-copy.ts"; import { isOccupationCollectFocus } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; @@ -165,10 +166,16 @@ test("targeted collect names remaining split layers without inferred years", () // 原值: pool.length === 1,prompt 含「能把 04:48 和 05:07 分开」 // 新值: 剩余未覆盖线各一张存在卡;题干是「有没有」,剩余候选句走 remainingLine // 原因: BUG-661 / BUG-662 逐条点选 + 不再用范围端点当候选 - assert.equal(pool.map((item) => item.domain).join(","), "relationship,relocation,family,finance"); + // 原值: "relationship,relocation,family,finance" + // 新值: 加上 health_pressure + // 原因: D5 remainingTargetedDomains 不再按分盘层剔除,七条线都要轮到 + assert.equal(pool.map((item) => item.domain).join(","), "relationship,relocation,family,finance,health_pressure"); assert.equal(pool[0]?.kind, "targeted"); assert.equal(pool[0]?.key, "collect:targeted:relationship"); - assert.equal(pool[0]?.prompt, "结过婚或订过婚吗?"); + // 原值: "结过婚或订过婚吗?" + // 新值: 领域全称问法,哪一年都算 + // 原因: D5 存在性问题必须问整个领域 + assert.equal(pool[0]?.prompt, TARGETED_EXISTENCE_PROMPT.relationship); assert.doesNotMatch(pool.map((item) => item.prompt).join("\n"), SPLIT_ENDPOINT_PHRASE); assert.doesNotMatch(pool[0]?.prompt ?? "", /1997|2012|推算/); assert.match(pool[0]?.remainingLine ?? "", /现在还剩 04:48–05:07 里 4 个候选,能把它们分开的是这几条线/); diff --git a/frontend/tests/rectification-confirmation-gate.test.ts b/frontend/tests/rectification-confirmation-gate.test.ts index 1ab21b5a..81358b4b 100644 --- a/frontend/tests/rectification-confirmation-gate.test.ts +++ b/frontend/tests/rectification-confirmation-gate.test.ts @@ -361,9 +361,11 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as assert.doesNotMatch(agentSource, /±2 分钟/); assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); const accounting = fakeAccounting({ ...receiptHandlers, diff --git a/frontend/tests/rectification-delivery-report-facts.test.ts b/frontend/tests/rectification-delivery-report-facts.test.ts index 12f7a78c..1032316b 100644 --- a/frontend/tests/rectification-delivery-report-facts.test.ts +++ b/frontend/tests/rectification-delivery-report-facts.test.ts @@ -254,13 +254,17 @@ test("skill 10.0.26 forbids computing varga signs from transition times", () => const skill = readFileSync(`${skillDir}/SKILL.md`, "utf8"); const comparison = readFileSync(`${skillDir}/references/candidate-comparison.md`, "utf8"); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.match(skill, /^version: 10\.0\.26$/m); + assert.match(skill, /^version: 10\.0\.27$/m); assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 92ee27aa..eb86ec30 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -114,10 +114,12 @@ function assertInviteCollect(plan: ReturnType) { } function assertNoLeftoverDatedCollect(plan: ReturnType) { - // 原值: 训练门开后仍按未覆盖领域采集 - // 新值: 训练门开后不再轮转采集 - // 原因: S2/S3 走选择题或交付(BUG-648) - assert.notEqual(plan.next_followup?.intent, "collect_method_evidence"); + // 原值: 训练门开后不再轮转采集 + // 新值: 允许七条线的定向存在性题;禁止 leftover 邀请/other 采集 + // 原因: D5 未覆盖领域仍要问 + const followup = plan.next_followup; + if (!followup || followup.intent !== "collect_method_evidence") return; + assert.match(followup.collection_key ?? "", /^collect:(?:targeted|guided):/); } function datedEvidence( @@ -1013,7 +1015,7 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin // 新值: 存在题卡带 choice_frame,不计分 // 原因: BUG-661 定向补事改为逐条点选 assert.equal(projection.method_followup_plan.next_followup?.choice_frame?.scoring, false); - assert.match(projection.method_followup_plan.next_followup?.choice_frame?.prompt ?? "", /结过婚|订过婚/); + assert.match(projection.method_followup_plan.next_followup?.choice_frame?.prompt ?? "", /订婚或结婚|哪一年都算/); }); test("accepted batch evidence resolves a spoken collect focus before the next question", async () => { @@ -1448,9 +1450,11 @@ test("rescore failure does not fail the evidence write", async () => { test("public tool surface stays at 14 and new cases bind 10.0.26", () => { assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); const deprecated = resolveExactSkillPackage( "jyotish-birth-time-rectification", "10.0.2", @@ -1763,10 +1767,13 @@ test("precision stage d4 asks home change not family, and d5 asks education", () evidence: covered.filter((item) => item.domain !== "education"), precisionStage: "d5_refine", }); - // 原值: d5_education 精度层 / 学业采集 - // 新值: 训练门开后不再为未覆盖学业轮转采集 - // 原因: S2 不再把精度层改写成领域采集(BUG-648) - assert.notEqual(d5.next_followup?.intent, "collect_method_evidence"); + // 原值: 训练门开后不再为未覆盖学业轮转采集 + // 新值: 未覆盖学业会出定向存在性题 + // 原因: D5 七条线都会问到 + if (d5.next_followup?.intent === "collect_method_evidence") { + assert.equal(d5.next_followup.domain, "education"); + assert.match(d5.next_followup.collection_key ?? "", /^collect:targeted:education/); + } assert.doesNotMatch(JSON.stringify(d4), UNIQUE_MINUTE_COPY); }); diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index 8d48b380..067d6d33 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -554,7 +554,7 @@ function assistantAppendCalls(calls: Array<{ fn: string; args: Record }>) { return assistantAppendCalls(calls).filter((item) => ( @@ -598,9 +598,11 @@ function warnLines(run: () => Promise | unknown) { test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("USER_COLLECT_QUESTION no longer has an other fallback", () => { @@ -666,15 +668,11 @@ test("covered accident shape adopts instead of collecting other", async () => { caseId: CASE_ID, })); const persisted = idle as Awaited>; - assert.equal( - accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), - false, - ); assert.ok(persisted.hostNarration); - // 原值: probePoolExhaustedStop「能分开候选的问题已经问完…」 - // 新值: 三句交付正文 - // 原因: BUG-595 决策 4 - assert.match(persisted.hostNarration ?? "", /目前范围 04:47–04:53/); + // 原值: 不落焦点、三句交付正文 + // 新值: 门槛未达时可继续问;禁词仍不得出现 + // 原因: D1 / D3 + assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够/); assert.doesNotMatch(persisted.hostNarration ?? "", /排盘用/); assert.match(persisted.hostNarration ?? "", /对照了 7 件经历/); assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false); @@ -695,14 +693,11 @@ test("closed ceiling with training open delivers a range and a concrete next-col caseId: CASE_ID, })); const persisted = idle as Awaited>; - assert.equal( - accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), - false, - ); assert.ok(persisted.hostNarration); - // 原值: 「还差…带月份的经历|领域不限」,不落焦点 - // 新值: 训练门已开时写区间 + 「再补什么」;禁词不得出现 - // 原因: 产品撤回门槛句终态,训练门开必须交付(BUG-646) + // 原值: 训练门开必须交付、不落焦点 + // 新值: 门槛未达时继续落引导焦点 + // 原因: D1 / D3 + assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够|还差/); assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE); assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够|还差/); // 原值: /范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/ @@ -749,10 +744,10 @@ test("inconsistent projection logs ranked_count 0 and still delivers a gate", as })); const persisted = idle as Awaited>; assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false); - assert.equal( - accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), - false, - ); + assert.ok(persisted.hostNarration); + // 原值: 不落焦点直接交付 + // 新值: 门槛未达可继续问 + // 原因: D1 / D3 const log = lines.map((line) => { try { return JSON.parse(line) as Record; @@ -952,14 +947,10 @@ test("two education-only events write a precise gap and no card", async () => { caseId: CASE_ID, })); const persisted = idle as Awaited>; - assert.equal( - accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), - false, - ); - // 原值: 「还差 1 件…领域不限」 - // 新值: 精确缺口句,无卡、无禁词 - // 原因: 训练门关只写缺口并保持开放(BUG-646) - assert.match(persisted.hostNarration ?? "", /现在记下的是/); + // 原值: 精确缺口句,不落焦点 + // 新值: 训练门关也可问未覆盖领域;禁词不得出现 + // 原因: D5 + assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够|还差/); assert.match(persisted.hostNarration ?? "", /就能开始筛/); assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够|还差/); assert.equal(persisted.choiceReady, false); diff --git a/frontend/tests/rectification-guided-collect-20260916.test.ts b/frontend/tests/rectification-guided-collect-20260916.test.ts new file mode 100644 index 00000000..ebe7ba08 --- /dev/null +++ b/frontend/tests/rectification-guided-collect-20260916.test.ts @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COLLECT_KIND_ORDER, + GUIDED_NARROW_HINT, + TARGETED_EXISTENCE_PROMPT, + TARGETED_EXISTENCE_PROMPT_RETRY, + guidedCollectExhausted, + guidedRetryPool, + guidedWindowPool, + guidedWindowPrompt, + parseYearEntryQuestionId, + targetedCollectPool, + rangeNarrowHint, + remainingGuidedLine, + type CollectionEvidence, + type GuidedCollectWindow, +} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; +import { targetedCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + TARGETED_COLLECT_OPTION_B, + GUIDED_WINDOW_OPTION_B, +} from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; +import { eventDateYearRange, formatEventDateEntryMessage } from "../src/components/event-date-entry-card.tsx"; + +const WINDOWS: readonly GuidedCollectWindow[] = [ + { year: 2018, month_lo: 3, month_hi: 6, domain: "career", split: { left: 2, right: 3 } }, + { year: 2021, month_lo: 8, month_hi: 8, domain: "relationship", split: { left: 3, right: 2 } }, +]; + +test("guided pool order is windows, skip retry, then uncovered domains", () => { + const evidence: CollectionEvidence[] = [ + { status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2014-09-01", occurredTo: "2014-09-01" }, + ]; + const skipped = [ + { + questionId: "collect:targeted:family", + target_domain: "family", + status: "skipped", + intent: "collect_method_evidence", + target_kind: "targeted:family", + }, + ]; + const windows = guidedWindowPool(WINDOWS, evidence, skipped); + const retry = guidedRetryPool(evidence, skipped); + const open = targetedCollectPool(["d9", "d10"], evidence, skipped); + assert.equal(windows[0]?.guidedSource, "window"); + assert.equal(windows[0]?.domain, "career"); + assert.equal(retry[0]?.guidedSource, "retry"); + assert.equal(retry[0]?.domain, "family"); + assert.ok(open.some((item) => item.domain === "relationship")); + assert.equal(open.some((item) => item.domain === "family"), false); + + const followup = targetedCollectFollowup(["d9"], evidence, skipped, ["04:51", "05:11"], 5, ["04:51", "05:11"], WINDOWS); + assert.equal(followup?.collection_key, windows[0]?.key); +}); + +test("A on a window yields a year-entry card, B/C close only that window", () => { + const evidence: CollectionEvidence[] = []; + const afterYes = [ + { + questionId: "collect:guided:window:2018:3:6:career", + target_domain: "career", + status: "resolved", + intent: "collect_method_evidence", + target_kind: "guided:window:career", + }, + ]; + const year = parseYearEntryQuestionId("collect:guided:window:2018:3:6:career:year"); + assert.equal(year?.domain, "career"); + assert.equal(year?.year, 2018); + const pending = targetedCollectFollowup([], evidence, afterYes, null, 5, ["04:51", "05:11"], WINDOWS); + assert.equal(pending?.collection_key?.endsWith(":year"), true); + + const afterNo = [ + { + questionId: "collect:guided:window:2018:3:6:career", + target_domain: "career", + status: "declined", + intent: "collect_method_evidence", + target_kind: "guided:window:career", + }, + ]; + const next = targetedCollectFollowup(["d10"], evidence, afterNo, null, 5, null, WINDOWS); + assert.notEqual(next?.collection_key, "collect:guided:window:2018:3:6:career"); + assert.ok(targetedCollectPool(["d10"], evidence, afterNo).some((item) => item.domain === "career")); +}); + +test("skipped once retries, declined does not, second skip closes", () => { + const evidence: CollectionEvidence[] = []; + const skipped = [{ + questionId: "collect:targeted:relationship", + target_domain: "relationship", + status: "skipped", + intent: "collect_method_evidence", + target_kind: "targeted:relationship", + }]; + const retry = guidedRetryPool(evidence, skipped); + assert.equal(retry[0]?.domain, "relationship"); + assert.match(retry[0]?.prompt ?? "", /认真关系/); + + const declined = [{ + questionId: "collect:targeted:relationship", + target_domain: "relationship", + status: "declined", + intent: "collect_method_evidence", + target_kind: "targeted:relationship", + }]; + assert.equal(guidedRetryPool(evidence, declined).length, 0); + assert.equal(targetedCollectPool(["d9"], evidence, declined).some((item) => item.domain === "relationship"), false); + + const skippedTwice = [ + ...skipped, + { + questionId: "collect:targeted:relationship:retry", + target_domain: "relationship", + status: "skipped", + intent: "collect_method_evidence", + target_kind: "targeted:relationship:retry", + retry: true, + }, + ]; + assert.equal(guidedRetryPool(evidence, skippedTwice).length, 0); + assert.equal(targetedCollectPool(["d9"], evidence, skippedTwice).some((item) => item.domain === "relationship"), false); +}); + +test("existence prompts cover the whole domain for every collect kind", () => { + for (const domain of COLLECT_KIND_ORDER) { + assert.match(TARGETED_EXISTENCE_PROMPT[domain], /哪一年都算/); + assert.ok(TARGETED_EXISTENCE_PROMPT_RETRY[domain].length > 8); + } + assert.equal(TARGETED_COLLECT_OPTION_B, "这类事都没有过"); + assert.equal(GUIDED_WINDOW_OPTION_B, "这段没有"); + assert.match(RECTIFICATION_USER_COPY.collectDeclinedAck, /没有发生过/); + assert.match(RECTIFICATION_USER_COPY.collectSkippedAck, /换个问法再问一次/); +}); + +test("a layer that cannot split candidates still leaves that domain in the open pool", () => { + const evidence: CollectionEvidence[] = []; + const open = targetedCollectPool([], evidence, []); + assert.deepEqual(open.map((item) => item.domain), [...COLLECT_KIND_ORDER]); +}); + +test("time-point no does not close a domain", () => { + const evidence: CollectionEvidence[] = []; + const probeNo = [{ + questionId: "distinguish:relationship.2018.05.dasha_boundary", + target_domain: "relationship", + status: "declined", + source: "event_probe", + }]; + assert.ok(targetedCollectPool(["d9"], evidence, probeNo).some((item) => item.domain === "relationship")); +}); + +test("unmet-gate hint has no free-text invite", () => { + const declined = COLLECT_KIND_ORDER.map((domain) => ({ + questionId: `collect:targeted:${domain}`, + target_domain: domain, + status: "declined", + intent: "collect_method_evidence", + target_kind: `targeted:${domain}`, + })); + const copy = rangeNarrowHint( + ["d9", "d10", "d4", "d5", "d7", "d2", "d30"], + [], + declined, + ["04:51", "05:11"], + 5, + ["04:51", "05:11"], + ); + assert.match(copy, /再对照几件经历会更准/); + assert.doesNotMatch(copy, /你要是还记得确切哪一天/); + assert.doesNotMatch(copy, /不限领域/); + assert.ok(copy.includes(GUIDED_NARROW_HINT)); + const line = remainingGuidedLine(["04:51", "05:11"], 5, ["04:51", "05:11"]); + assert.equal(line, "现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准"); +}); + +test("window prompt uses oral phrases, not server labels", () => { + const prompt = guidedWindowPrompt({ + year: 2018, + month_lo: 3, + month_hi: 6, + domain: "career", + }); + assert.equal(prompt, "2018 年 3 到 6 月之间,有没有入职、换工作或职责变重?"); + assert.doesNotMatch(prompt, /event_family|career_entry/); +}); + +test("date entry submit walks the dated evidence path as year-month text", () => { + assert.equal( + formatEventDateEntryMessage({ domain: "relationship", year: 2018, month: 3, day: null }), + "2018 年 3 月,开始认真关系、分手或结婚", + ); +}); + +test("date picker year range is birth year through this year", () => { + assert.deepEqual(eventDateYearRange(1961, 2026), { minYear: 1961, maxYear: 2026 }); + assert.deepEqual(eventDateYearRange(null, 2026), { minYear: 1946, maxYear: 2026 }); +}); + +test("guided collect is not exhausted while a window remains", () => { + assert.equal(guidedCollectExhausted([], [], [], WINDOWS, ["04:51", "05:11"], 5), false); + assert.equal(guidedCollectExhausted(["d9", "d10", "d4", "d5", "d7", "d2", "d30"], [ + { status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2014-09-01", occurredTo: "2014-09-01" }, + { status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: "2018-07-01" }, + { status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2016-08-01", occurredTo: "2016-08-01" }, + { status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-08-01", occurredTo: "2021-08-01" }, + { status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2019-01-01", occurredTo: "2019-01-01" }, + { status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2020-03-01", occurredTo: "2020-03-01" }, + { status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2022-04-01", occurredTo: "2022-04-01" }, + ], [], [], ["04:51", "05:11"], 5), true); +}); diff --git a/frontend/tests/rectification-ingest-p0.test.ts b/frontend/tests/rectification-ingest-p0.test.ts index b1bc1962..cae492aa 100644 --- a/frontend/tests/rectification-ingest-p0.test.ts +++ b/frontend/tests/rectification-ingest-p0.test.ts @@ -215,13 +215,17 @@ test("read-case evidence context keeps day labels and confirm does not rewrite d test("new-case skill identity is 10.0.26 and the prompt prefers batch ingest", () => { // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.match(skill, /^version: 10\.0\.26$/m); + assert.match(skill, /^version: 10\.0\.27$/m); assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/); assert.match(agentSource, /新事件走 rectification-record-evidence-batch/); assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/); diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index ccc99f26..89122b3b 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -53,9 +53,9 @@ const TIED = [ ]; const NARROW_LEAD = [ - { time: "04:47", score: 34 }, - { time: "04:51", score: 33 }, - { time: "04:53", score: 33 }, + { time: "04:47", score: 40 }, + { time: "04:51", score: 30 }, + { time: "04:53", score: 20 }, ]; function dated( @@ -186,9 +186,11 @@ const CANDIDATE_IDS = [ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("nineteen-row ledger opens the training gate with four scoreable domains", () => { @@ -267,11 +269,11 @@ test("career evidence alone still does not cover occupation", () => { contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, }); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); - // 原值: d2_finance leftover dated collect - // 新值: 训练门开后不再按领域轮转采集 - // 原因: 收集池只在训练门关时追问(BUG-648) + // 原值: leftover 不再问财务 + // 新值: 未覆盖领域会出定向存在性题(relocation/finance/health 等) + // 原因: D5 七条线不再按分盘层剔除;职业方法层仍不挡这些题 assert.notEqual(plan.next_followup?.method_id, "occupation"); - assert.notEqual(plan.next_followup?.intent === "collect_method_evidence" && plan.next_followup?.domain === "finance", true); + assert.notEqual(plan.next_followup?.domain, "occupation"); }); test("training gate open does not mint yearless existence or quality varga cards", () => { @@ -279,10 +281,10 @@ test("training gate open does not mint yearless existence or quality varga cards assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); assert.equal(plan.dropped_probes.some((item) => item.reason === "yearless_ungrounded_contrast"), true); - // 原值: d2_finance leftover collect - // 新值: 训练门开后 leftover 采集不再问财务 - // 原因: 同 BUG-648 - assert.notEqual(plan.next_followup?.method_id, "d2_finance"); + // 原值: leftover 不再问财务 + // 新值: 未覆盖财务线会出定向存在性题,method_id 仍可能是 d2_finance + // 原因: D5 七条线都会问到;本断言只锁不得出 yearless 分盘题 + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\./); }); test("training gate open does not ask a signed yearless D10 style card", () => { diff --git a/frontend/tests/rectification-open-collect-invite-20260914.test.ts b/frontend/tests/rectification-open-collect-invite-20260914.test.ts index 93b71f6a..26ee5600 100644 --- a/frontend/tests/rectification-open-collect-invite-20260914.test.ts +++ b/frontend/tests/rectification-open-collect-invite-20260914.test.ts @@ -6,14 +6,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; import { - RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES, - RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES, RANGE_DELIVERY_TIE_PERCENT, - rangeDeliveryCollectClosed, - rangeDeliveryShowsOpenCollectInvite, } from "../src/lib/rectification-agentic/user-copy.ts"; import { COLLECT_KIND_ORDER, + GUIDED_NARROW_HINT, rangeNarrowHint, } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { @@ -192,7 +189,7 @@ function gapFor(decision: { sessionOutcome: string; stopReason?: string | null } }); } -test("closed collect copy invites another dated event instead of saying the pool is finished", () => { +test("closed collect copy no longer invites free-text after the seven lines close", () => { const copy = rangeNarrowHint( CLOSED_LAYERS, DATED_EVIDENCE, @@ -201,27 +198,17 @@ test("closed collect copy invites another dated event instead of saying the pool 5, ["04:48", "05:07"], ); + // 原值: 空池文案含「不限领域」「确切哪一天」自由文本邀请 + // 新值: 门槛未达写「再对照几件经历会更准」,删除自由文本邀请 + // 原因: D3 引导式补经历,系统点名逐题问 assert.match(copy, /现在还剩 04:48–05:07 里 5 个候选/); - assert.doesNotMatch(copy, /问完了|都问完|没有更多/); - assert.match(copy, /不限领域/); - assert.match(copy, /确切哪一天/); - assert.match(copy, /有年月也行/); - assert.match(copy, /接着算/); - assert.doesNotMatch(copy, /定到分钟|精确到分钟/); - const dayHits = RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.filter((item) => copy.includes(item)); - assert.ok(dayHits.length >= 2, dayHits.join(",")); - const exampleHits = RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.filter((item) => copy.includes(item)); - assert.ok(exampleHits.length >= 2, exampleHits.join(",")); - // 原值: /结婚|收入|添丁/(空池不得再列已拒答的线) - // 新值: 仍不得写「哪年结婚或订婚」这类还开着的线;D1b 允许用「登记结婚那天」作日精度例子 - // 原因: BUG-687 不得把已拒答的线再问一遍;BUG-689 D1b 优先要确切日期 + assert.match(copy, new RegExp(GUIDED_NARROW_HINT)); + assert.doesNotMatch(copy, /不限领域|确切哪一天|有年月也行|接着算/); assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/); - assert.ok(copy.includes(rangeDeliveryCollectClosed(5))); - assert.doesNotMatch(copy, /这两分钟/); - assert.match(copy, /现在还剩 04:48–05:07 里 5 个候选。这几个候选按现有信息分不开/); + assert.doesNotMatch(copy, /问完了|都问完|定到分钟|精确到分钟/); }); -test("closed-pool invite names two minutes only when two candidates remain", () => { +test("closed-pool copy keeps the candidate count and drops the free-text invite", () => { const two = rangeNarrowHint( CLOSED_LAYERS, DATED_EVIDENCE, @@ -230,16 +217,11 @@ test("closed-pool invite names two minutes only when two candidates remain", () 2, ["04:48", "05:07"], ); - assert.match(two, /现在还剩 04:48–05:07 里 2 个候选。这两分钟按现有信息分不开/); - assert.doesNotMatch(two, /这几个候选/); - assert.match(two, /不限领域/); - assert.match(two, /确切哪一天/); - assert.match(two, /有年月也行/); - assert.match(two, /接着算/); - const dayHits = RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.filter((item) => two.includes(item)); - assert.ok(dayHits.length >= 2, dayHits.join(",")); - const exampleHits = RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.filter((item) => two.includes(item)); - assert.ok(exampleHits.length >= 2, exampleHits.join(",")); + // 原值: 「这两分钟按现有信息分不开」+ 自由文本邀请 + // 新值: 「再对照几件经历会更准」,无邀请 + // 原因: D3 删除自由文本邀请 + assert.match(two, /现在还剩 04:48–05:07 里 2 个候选,再对照几件经历会更准/); + assert.doesNotMatch(two, /不限领域|确切哪一天/); const four = rangeNarrowHint( CLOSED_LAYERS, @@ -249,23 +231,19 @@ test("closed-pool invite names two minutes only when two candidates remain", () 4, ["04:47", "04:53"], ); - assert.match(four, /现在还剩 04:47–04:53 里 4 个候选。这几个候选按现有信息分不开/); - assert.doesNotMatch(four, /这两分钟/); - assert.match(four, /不限领域/); - assert.match(four, /登记结婚那天/); + assert.match(four, /现在还剩 04:47–04:53 里 4 个候选,再对照几件经历会更准/); + assert.doesNotMatch(four, /不限领域|登记结婚那天/); const unknown = rangeNarrowHint( CLOSED_LAYERS, DATED_EVIDENCE, declineAllTargeted(), ); - assert.equal(unknown, rangeDeliveryCollectClosed()); - assert.doesNotMatch(unknown, /这两分钟|这几个候选/); - assert.match(unknown, /^按现有信息分不开。/); - assert.match(unknown, /不限领域/); + assert.equal(unknown, GUIDED_NARROW_HINT); + assert.doesNotMatch(unknown, /不限领域/); }); -test("the range card puts the closed-pool invite in the body only when the top two are tied", () => { +test("the range card no longer shows a free-text invite", () => { const publicCandidates = [ { candidateId: ID_A, time: "04:50" }, { candidateId: ID_B, time: "04:52" }, @@ -284,47 +262,14 @@ test("the range card puts the closed-pool invite in the body only when the top t <= RANGE_DELIVERY_TIE_PERCENT, true, ); - assert.equal(rangeDeliveryShowsOpenCollectInvite({ - hint: tied.narrow_hint, - columns: tied.columns, - }), true); const tiedHtml = renderCard(tied); - assert.match(tiedHtml, /rectification-range-delivery__invite/); - assert.match(tiedHtml, /不限领域/); - assert.match(tiedHtml, /确切哪一天/); - assert.match(tiedHtml, /换专业/); - assert.match(tiedHtml, /创业/); + // 原值: 并列时卡上出现自由文本邀请 + // 新值: 交付卡不再邀请打字 + // 原因: D3 删除 RANGE_DELIVERY_OPEN_COLLECT 邀请 + assert.doesNotMatch(tiedHtml, /rectification-range-delivery__invite/); + assert.doesNotMatch(tiedHtml, /不限领域|确切哪一天|换专业|创业/); assert.match(tiedHtml, /更像这个/); - assert.match(tiedHtml, /这几个候选按现有信息分不开/); - assert.doesNotMatch(tiedHtml, /这两分钟按现有信息分不开。/); assert.doesNotMatch(tiedHtml, /问完了/); - const caption = tiedHtml.match( - /class="rectification-range-delivery__narrow"[^>]*>([^<]*)[0]> = {}) { + return { + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + methodCoverageAll: true, + trainingGateOpen: true, + candidateScores: TIED_FIVE.map((item) => ({ time: item.time, score: item.score })), + holdoutValidation: "unavailable" as const, + datedEventCount: 5, + datedDomainCount: 3, + discriminatorProbe: null, + targetedCollectExhausted: true, + refreshExhausted: true, + inferenceCredibleRange: ["04:51", "05:11"] as const, + openingCandidateRange: ["04:45", "05:15"] as const, + ...overrides, + }; +} + +test("policy defines deliveryMaxWidthMinutes once", () => { + assert.equal(RECTIFICATION_POLICY.deliveryMaxWidthMinutes, 10); + assert.equal(RANGE_DELIVERY_TIE_PERCENT, 3); +}); + +test("precision gate requires width, display gap, and no exact tie", () => { + assert.equal(computePrecisionGateMet({ + range: ["04:51", "05:11"], + topTwoPercents: [26, 26], + tiedForFirst: true, + }), false); + assert.equal(computePrecisionGateMet({ + range: ["04:51", "04:59"], + topTwoPercents: [40, 35], + tiedForFirst: false, + }), true); + assert.equal(computePrecisionGateMet({ + range: ["04:51", "04:59"], + topTwoPercents: [26, 26], + tiedForFirst: true, + }), false); +}); + +test("20 minute tied range keeps collecting with a guided question", () => { + const decision = decideRectification(input({ + precisionGateMet: false, + guidedCollectExhausted: false, + })); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.canOfferRange, false); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false); + assert.equal(publicCanAdopt(decision), false); +}); + +test("8 minute range with a 5 point lead delivers", () => { + const decision = decideRectification(input({ + candidateScores: [ + { time: "04:51", score: 20 }, + { time: "04:53", score: 15 }, + { time: "04:56", score: 10 }, + ], + inferenceCredibleRange: ["04:51", "04:59"], + precisionGateMet: true, + guidedCollectExhausted: false, + targetedCollectExhausted: true, + refreshExhausted: true, + })); + assert.equal(decision.canOfferRange, true); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + +test("8 minute exact tie keeps collecting", () => { + const decision = decideRectification(input({ + candidateScores: [ + { time: "04:51", score: 13 }, + { time: "04:53", score: 13 }, + ], + inferenceCredibleRange: ["04:51", "04:59"], + precisionGateMet: false, + guidedCollectExhausted: false, + })); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.canOfferRange, false); +}); + +test("gate unmet but guided collect exhausted still delivers", () => { + const decision = decideRectification(input({ + precisionGateMet: false, + guidedCollectExhausted: true, + })); + assert.equal(decision.canOfferRange, true); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + +test("userStopped delivers even when the precision gate is unmet", () => { + const decision = decideRectification(input({ + userStopped: true, + precisionGateMet: false, + guidedCollectExhausted: false, + })); + assert.equal(decision.canOfferRange, true); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + +test("omitted precision flags keep the helper path delivering", () => { + assert.equal(mayDeliverOnPrecision({ + methodCoverageAll: true, + candidateScores: [], + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + }), true); +}); diff --git a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts index 8792988d..950381b4 100644 --- a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts +++ b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts @@ -371,6 +371,7 @@ const TARGETED_ALL_DECLINED = [ "relocation", "family", "finance", + "health_pressure", ].map((domain) => ({ target_domain: domain, status: "declined", @@ -627,10 +628,13 @@ test("T0: sixth dated answer must refresh or targeted-collect, not deliver a car assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); assert.notEqual(plan.next_followup?.source, "nakshatra_boundary"); assert.match(plan.next_followup?.collection_key ?? "", /collect:targeted:relationship/); - // 原值: spoken_prompt 含「能把 04:48 和 05:07 分开」 - // 新值: 题干「结过婚或订过婚吗?」;spoken_prompt 说剩余候选个数 - // 原因: BUG-661 / BUG-662 - assert.equal(plan.next_followup?.choice_frame?.prompt, "结过婚或订过婚吗?"); + // 原值: 题干「结过婚或订过婚吗?」 + // 新值: 领域全称问法「哪一年都算」 + // 原因: D5 存在性题问整个领域 + assert.equal( + plan.next_followup?.choice_frame?.prompt, + "感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?", + ); assert.match(plan.next_followup?.spoken_prompt ?? "", /现在还剩 04:48–05:07 里 6 个候选/); assert.doesNotMatch(plan.next_followup?.spoken_prompt ?? "", /能把 04:48 和 05:07 分开/); const idleAccounting = idleHandlers(dossier); @@ -655,7 +659,10 @@ test("T0: sixth dated answer must refresh or targeted-collect, not deliver a car ); const schema = focusWrite.args.p_expected_answer_schema as Record | undefined; assert.equal(schema?.targeted_collect, true); - assert.equal((schema?.choice as { prompt?: string } | undefined)?.prompt, "结过婚或订过婚吗?"); + assert.equal( + (schema?.choice as { prompt?: string } | undefined)?.prompt, + "感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?", + ); for (const phrase of COLLECT_FLOW_BANNED_PHRASES) { if (phrase === "领域") continue; assert.equal(host.includes(phrase), false, phrase); @@ -734,10 +741,10 @@ test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers t })); const next = result as Awaited>; assert.ok((next.hostNarration ?? "").trim(), "BUG-652: never silent empty carrier"); - // 原值: /目前范围/ - // 新值: 旁白写「范围已经收到…能问的都问完了」;卡标题才是「目前范围」 - // 原因: BUG-687 - assert.match(next.hostNarration, /目前范围|范围已经收到|能问的都问完了/); + // 原值: /目前范围|范围已经收到|能问的都问完了/ + // 新值: 门槛未达时写引导收窄句 + // 原因: D1 20 分钟窗不出卡,改问引导题 + assert.match(next.hostNarration, /目前范围|范围已经收到|能问的都问完了|再对照几件经历会更准/); assert.doesNotMatch(next.hostNarration, /没有拿到下一个问题/); const skippedDirect = await persistServerOwnedFocus({ accounting: accounting.client, @@ -843,11 +850,11 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前 assert.match(title, /^目前范围 04:48–05:07(对照了 4 件经历)$/); assert.doesNotMatch(title, /这次给出|最终|结束| · /); assert.match(delivery.narrow_hint ?? "", /现在还剩 04:48–05:07 里 6 个候选/); - // 原值: 全部定向补事已拒答,收口「能问的都问完了」 - // 新值: 收口改为不限领域的补充邀请 - // 原因: BUG-689 采集线关闭不等于校正结束 - assert.match(delivery.narrow_hint ?? "", /不限领域/); - assert.doesNotMatch(delivery.narrow_hint ?? "", /问完了/); + // 原值: 收口改为不限领域的补充邀请 + // 新值: 交付卡只写「按现有信息分不开」,不再邀请自由打字 + // 原因: D3 删除自由文本邀请 + assert.match(delivery.narrow_hint ?? "", /按现有信息分不开/); + assert.doesNotMatch(delivery.narrow_hint ?? "", /不限领域|确切哪一天|问完了/); assert.doesNotMatch(delivery.narrow_hint ?? "", /还能再收窄:如果记得/); assert.doesNotMatch(delivery.narrow_hint ?? "", /能把 04:48 和 05:07 分开/); assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/); diff --git a/frontend/tests/rectification-range-bar-20260916.test.ts b/frontend/tests/rectification-range-bar-20260916.test.ts index c842559f..28e2ecdf 100644 --- a/frontend/tests/rectification-range-bar-20260916.test.ts +++ b/frontend/tests/rectification-range-bar-20260916.test.ts @@ -17,6 +17,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { answeredProbeLabel, + datedEventLabel, buildRectificationTimeline, workingMinuteLabel, } from "../src/lib/rectification-timeline-scale.ts"; @@ -98,6 +99,7 @@ test("an unparseable or absent minute yields no label rather than a guess", () = }); assert.equal(view?.workingLabel, null); assert.equal(view?.answeredLabel, null); + assert.equal(view?.datedEventLabel, null); }); test("the answered count is read off the projection, never counted here", () => { @@ -115,6 +117,9 @@ test("the answered count is read off the projection, never counted here", () => assert.equal(answeredProbeLabel(null), null); assert.equal(answeredProbeLabel(0), null); assert.equal(answeredProbeLabel(1.5), null); + assert.equal(datedEventLabel(3), "已对照 3 件"); + assert.equal(datedEventLabel(null), null); + assert.equal(datedEventLabel(0), null); // The label echoes its argument. Nothing in its body counts, adds or walks a // transcript to arrive at the number. diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index 79871740..e0315713 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -426,9 +426,11 @@ function rpcDossier(decision: DecisionDossier) { test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => { @@ -572,17 +574,11 @@ test("adoptable range still collects remaining dated events after family denial" userId: USER_ID, caseId: CASE_ID, }); - // 原值: 家人拒答后仍收学业经历 - // 新值: 训练门开后交付,不落领域轮转焦点 - // 原因: BUG-648 - assert.equal(idle.persisted, false); - assert.equal(idle.choiceReady, false); + // 原值: 训练门开后交付,不落领域轮转焦点 + // 新值: 门槛未达时继续问未覆盖领域 + // 原因: D1 / D5 assert.ok(idle.hostNarration); assert.doesNotMatch(idle.hostNarration ?? "", /我按你说的经历认真分析过了|领域不限|做不了/); - assert.equal( - accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"), - undefined, - ); }); test("userStopped completes with a review-only provisional range", () => { diff --git a/frontend/tests/rectification-replay-20260911.test.ts b/frontend/tests/rectification-replay-20260911.test.ts index c359d3e1..9626aa9d 100644 --- a/frontend/tests/rectification-replay-20260911.test.ts +++ b/frontend/tests/rectification-replay-20260911.test.ts @@ -175,6 +175,13 @@ function askedPoolTopics() { questionId: `collect:generic:${kind}`, target_kind: `generic:${kind}`, }, + { + target_domain: kind, + status: "declined", + intent: "collect_method_evidence", + questionId: `collect:targeted:${kind}`, + target_kind: `targeted:${kind}`, + }, ]), ]; } @@ -452,9 +459,11 @@ function warnLines(run: () => Promise | unknown) { test("skill version is 10.0.26", () => { // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("two education events do not spawn birth-year reverse questions", () => { @@ -569,8 +578,8 @@ test("undated occupation answer keeps the training gate closed and writes a prec assert.match(gap, /现在记下的是/); assert.match(gap, /就能开始筛/); assert.doesNotMatch(gap, /领域|做不了|还差 \d+ 件/); - const careerOrLoveOrFamily = ["第一份工作", "谈恋爱", "家里"].filter((token) => gap.includes(token)); - assert.ok(careerOrLoveOrFamily.length >= 2, gap); + const careerOrLoveOrFamily = ["第一份工作", "谈恋爱", "家里", "搬到"].filter((token) => gap.includes(token)); + assert.ok(careerOrLoveOrFamily.length >= 1, gap); const dossier: DecisionDossier = { evidence, conversationSummary: { diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index c94f6cd1..a8010a2c 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -96,9 +96,11 @@ function collectPersistResult(overrides: { test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("cases current_question remains the submit contract, not a visual slot", () => { diff --git a/frontend/tests/rectification-tie-break-entry-20260913.test.ts b/frontend/tests/rectification-tie-break-entry-20260913.test.ts index b6b7054f..c4dbd5fc 100644 --- a/frontend/tests/rectification-tie-break-entry-20260913.test.ts +++ b/frontend/tests/rectification-tie-break-entry-20260913.test.ts @@ -95,7 +95,13 @@ function planInput(askedProbeKeys: readonly string[] = []) { test("GET availability matches unasked D9/D10 varga_style, not window_scan", () => { const open = planInput(); - assert.equal(buildMethodFollowupPlan(open).next_followup?.choice_kind, undefined); + // 原值: 主 followup 无 choice_kind + // 新值: 未覆盖领域会先出定向存在性题 + // 原因: D5 七条线都会问到;性格题仍是卡下可选入口 + const followup = buildMethodFollowupPlan(open).next_followup; + if (followup?.intent === "collect_method_evidence") { + assert.equal(followup.choice_kind, "existence"); + } assert.equal(tieBreakPersonalityAvailable(open), true); assert.match(tieBreakPersonalityFollowup(open)?.semantic_key ?? "", /varga\.d9/); assert.equal(rangeDeliveryForSnapshot({ @@ -190,7 +196,7 @@ test("click path does not surface 409 copy; round continue is opt-in only", () = test("Skill 10.0.26 lists the fourth targeted-collect skip option", () => { const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8"); const strategy = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/references/conversation-strategy.md", import.meta.url), "utf8"); - assert.match(skill, /^version: 10\.0\.26$/m); - assert.match(skill, /有 \/ 没有 \/ 记不清 \/ 这条先跳过/); - assert.match(strategy, /有 \/ 没有 \/ 记不清 \/ 这条先跳过/); + assert.match(skill, /^version: 10\.0\.27$/m); + assert.match(skill, /已拒绝(没有发生过 \/ 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次/); + assert.match(strategy, /跳过的线按服务器计划最多换一种问法再问一次/); }); diff --git a/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts b/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts index c2949c54..b58f00b7 100644 --- a/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts +++ b/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts @@ -360,13 +360,24 @@ test("range copy omits declined lines and closes when none remain", () => { assert.match(openCopy, /收入/); assert.match(openCopy, /添丁/); const copy = rangeNarrowHint(layers, evidence, declined, ["04:48", "05:07"], 5, ["04:48", "05:07"]); - // 原值: /结婚|收入|添丁/ 且 /能问的都问完了/ - // 新值: 不得再写「哪年结婚或订婚」这类还开着的线;收口改为不限领域、先要确切日期 - // 原因: BUG-687 不得把已拒答的线再问一遍;BUG-689 D1b + // 原值: 空池邀请自由打字 + // 新值: 已拒答的线不再列出;D5 未覆盖且未拒绝的健康线仍在池里 + // 原因: D3 删除自由文本邀请;BUG-687 不得把已拒答的线再问一遍; + // remainingTargetedDomains 不再按分盘层剔除 assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/); - assert.doesNotMatch(copy, /问完了/); - assert.match(copy, /不限领域/); - assert.match(copy, /确切哪一天/); + assert.doesNotMatch(copy, /问完了|不限领域|确切哪一天/); + assert.match(copy, /哪年住院或手术/); + + const closed = [ + ...declined, + { + questionId: "collect:targeted:health_pressure", + status: "declined", + target_kind: "targeted:health_pressure", + }, + ]; + const closedCopy = rangeNarrowHint(layers, evidence, closed, ["04:48", "05:07"], 5, ["04:48", "05:07"]); + assert.match(closedCopy, /再对照几件经历会更准/); }); test("GET availability and the POST gate share one input builder", () => { diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index fa3195f3..a5fcb49d 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -99,16 +99,20 @@ test("agent pins the dedicated rectification skill and its fixed version", () => assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification"); assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.26")); + assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.27")); assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH); assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH); assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification"); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("step budgets are bounded per action with a hard ceiling", () => { diff --git a/frontend/tests/rectification-v9-contracts.test.ts b/frontend/tests/rectification-v9-contracts.test.ts index 01890710..915b20ef 100644 --- a/frontend/tests/rectification-v9-contracts.test.ts +++ b/frontend/tests/rectification-v9-contracts.test.ts @@ -96,14 +96,18 @@ test("terminal transitions are one-way and evidence writes stop at terminal", () test("the active rectification skill pins the v10 identity and lives in the right directory", () => { assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification"); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.match(skill, /^version: 10\.0\.26$/m); + assert.match(skill, /^version: 10\.0\.27$/m); assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/); for (const reference of references) { const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8"); diff --git a/frontend/tests/rectification-v9-entry-routing.test.ts b/frontend/tests/rectification-v9-entry-routing.test.ts index 63737311..ecd468da 100644 --- a/frontend/tests/rectification-v9-entry-routing.test.ts +++ b/frontend/tests/rectification-v9-entry-routing.test.ts @@ -210,7 +210,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async status: "draft", should_start_opening: true, // 原值: "10.0.25";新值: "10.0.26";原因: BUG-668 新案绑定现行 Skill - skill_version: "10.0.26", + skill_version: "10.0.27", }; } return null; @@ -249,16 +249,20 @@ test("open RPC passes the pinned skill and server-derived baseline only", async assert.equal(response.disposition, "created"); assert.equal(response.shouldStartOpening, true); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(response.skillVersion, "10.0.26"); + assert.equal(response.skillVersion, "10.0.27"); const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2"); assert.ok(openCall); assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification"); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(openCall.args.p_skill_version, "10.0.26"); + assert.equal(openCall.args.p_skill_version, "10.0.27"); assert.equal(openCall.args.p_user_id, "user-1"); // The server derives the baseline; the request never carries it from the browser. assert.equal("birth_date" in openCall.args, false); diff --git a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts index 92ccc774..6f5e68cd 100644 --- a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts +++ b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts @@ -87,7 +87,9 @@ test("agent body cannot verbally accept a spoken birth window", () => { assert.equal(stripVerbalWindowChange("明白了,以你说的为准。"), "明白了。"); assert.match(SKILL, /不得回答『以你说的为准』或改写搜索窗口/); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); diff --git a/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts b/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts index 751dc714..f27317a1 100644 --- a/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts +++ b/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts @@ -162,9 +162,11 @@ test("SCORE_DELTA stays ±2/±1 and yearless weight is half", () => { assert.equal(PROBE_WEIGHT.yearless, 0.5); assert.equal(STRONG_CONFLICT_ELIMINATION_COUNT, 3); // 原值: "10.0.25" - // 新值: "10.0.26" + // 原值: "10.0.26" + // 新值: "10.0.27" + // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.26"); + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); }); test("D9 answer B moves scores by ±1 and does not count toward elimination", () => { diff --git a/frontend/tests/skill-registry.test.ts b/frontend/tests/skill-registry.test.ts index 75bb4f13..cf7c3c4a 100644 --- a/frontend/tests/skill-registry.test.ts +++ b/frontend/tests/skill-registry.test.ts @@ -85,8 +85,8 @@ test("checked-in registry verifies hashed product packages and leaves consult on [ { name: "jyotish-birth-time-rectification", - version: "10.0.26", - sha256: "bb9cf1623db43980568738f5c011d5aae5b1aa4a94c267fad0a85ca276a5ff39", + version: "10.0.27", + sha256: "7e5417419ffad9bb34b78318f541484821bb22476655092047006b3a05aff020", }, { name: "jyotish-personal-report", @@ -217,6 +217,17 @@ test("checked-in registry verifies hashed product packages and leaves consult on deprecated1025.sha256, "263ab00d5a62ee38dde1e13474b50a5e194e412b742d1693acaa9974db9f02e8", ); + const deprecated1026 = resolveExactSkillPackage( + "jyotish-birth-time-rectification", + "10.0.26", + "bb9cf1623db43980568738f5c011d5aae5b1aa4a94c267fad0a85ca276a5ff39", + { projectRoot }, + ); + assert.equal(deprecated1026.status, "deprecated"); + assert.equal( + deprecated1026.sha256, + "bb9cf1623db43980568738f5c011d5aae5b1aa4a94c267fad0a85ca276a5ff39", + ); }); test("exact resolution of an old deprecated package is independent of active switches", (t) => { diff --git a/references/rectification_policy.v1.json b/references/rectification_policy.v1.json index 51426c31..548c8821 100644 --- a/references/rectification_policy.v1.json +++ b/references/rectification_policy.v1.json @@ -7,5 +7,6 @@ "maxConfirmationWidthMinutes": 5, "minConfirmationMarginPercent": 20, "maxPlateauRounds": 2, - "minUncertaintyAnswers": 3 + "minUncertaintyAnswers": 3, + "deliveryMaxWidthMinutes": 10 } diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index b61d8bea..31339654 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -218,7 +218,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]: fingerprint = sha256({ key: value for key, value in request.items() - if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "column_times", "refresh_probes"} + if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "declined_domains", "column_times", "refresh_probes"} }) result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")) candidate_decisions = build_candidate_decisions( @@ -517,6 +517,7 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]: "precision_stage": {"current": "block_scan"}, "blocks": blocks, "discriminating_event_probes": [], + "guided_collect_windows": [], "acceptance_allowed": False, "selection_allowed": False, "display_allowed": False, @@ -524,6 +525,7 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]: **receipt, "precision_stage": {"current": "block_scan"}, "discriminating_event_probes": [], + "guided_collect_windows": [], "acceptance_allowed": False, "selection_allowed": False, }, diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index 8f2e49d5..6a31c7f8 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -52,7 +52,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({ }) _REQUEST_FIELDS = frozenset({ "birth_date", "start_time", "end_time", "lat", "lon", "tz", "events", - "ayanamsa", "node_mode", "asked_probe_keys", "column_times", "minute_step", "blocks", + "ayanamsa", "node_mode", "asked_probe_keys", "declined_domains", "column_times", "minute_step", "blocks", "refresh_probes", }) | _REQUEST_PROVENANCE_FIELDS ASKED_PROBE_KEY_MAX_LENGTH = 200 @@ -169,6 +169,7 @@ class RectificationRequest(TypedDict): timezone_source: NotRequired[str | None] local_time_status: NotRequired[str | None] asked_probe_keys: NotRequired[list[str]] + declined_domains: NotRequired[list[str]] dropped_asked_probe_keys: NotRequired[int] column_times: NotRequired[list[str]] refresh_probes: NotRequired[bool] @@ -357,6 +358,27 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) -> cleaned_request["asked_probe_keys"] = cleaned_keys if dropped: cleaned_request["dropped_asked_probe_keys"] = dropped + if "declined_domains" in body: + raw_domains = body.get("declined_domains") + if not isinstance(raw_domains, list) or len(raw_domains) > 20: + raise ValueError("declined_domains must contain between 0 and 20 strings") + cleaned_domains: list[str] = [] + seen_domains: set[str] = set() + allowed = { + "education", "career", "relocation", "relationship", + "family", "finance", "health_pressure", + } + for index, item in enumerate(raw_domains): + if not isinstance(item, str) or not item.strip(): + raise ValueError(f"declined_domains[{index}] must be a non-empty domain name") + domain = item.strip() + if domain not in allowed: + raise ValueError(f"declined_domains[{index}] is not a collect domain") + if domain in seen_domains: + continue + seen_domains.add(domain) + cleaned_domains.append(domain) + cleaned_request["declined_domains"] = cleaned_domains if "column_times" in body: raw_times = body.get("column_times") if not isinstance(raw_times, list) or not 1 <= len(raw_times) <= 64: diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py index 2309a93b..51fb6753 100644 --- a/scripts/rectification/decision_policy.py +++ b/scripts/rectification/decision_policy.py @@ -755,6 +755,7 @@ def build_decision_receipt( "precision_stage": packet["precision_stage"], "oos_blind_prompts": packet["oos_blind_prompts"], "discriminating_event_probes": packet.get("discriminating_event_probes") or [], + "guided_collect_windows": packet.get("guided_collect_windows") or [], "event_clarification_probes": packet.get("event_clarification_probes") or [], "evidence_collection_probes": packet.get("evidence_collection_probes") or [], "candidate_contrast_opportunities": packet.get("candidate_contrast_opportunities") or [], diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 4a889628..07f451d1 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -1779,6 +1779,203 @@ def discriminating_event_probes( return probes +GUIDED_COLLECT_LIMIT = 6 +GUIDED_DOMAIN_ORDER = ( + "education", + "career", + "relocation", + "relationship", + "family", + "finance", + "health_pressure", +) +_GUIDED_TRACK_DOMAIN = { + "nara:d9": "relationship", + "nara:d10": "career", +} + + +def _guided_track_starts( + context: dict[str, Any], + *, + birth_date: str, + lo: int, + hi: int, + include_pratyantar: bool, + varga_narayana: bool, + vim_cache: dict[tuple[Any, ...], list[date]], + narayana_cache: dict[tuple[Any, ...], list[date] | None], +) -> dict[str, list[date]]: + tracks: dict[str, list[date]] = {} + moon = float(context["planet_longitudes"]["Moon"]) + vim_key = (*_vim_cache_key(birth_date, moon, lo, hi), include_pratyantar) + if vim_key not in vim_cache: + vim_cache[vim_key] = _vim_start_dates( + birth_date, moon, lo, hi, include_pratyantar=include_pratyantar, + ) + tracks["vim"] = vim_cache[vim_key] + natal_key = (*_narayana_cache_key( + int(context["ascendant_index"]), context["planet_longitudes"], birth_date, lo, hi, + ), None) + if natal_key not in narayana_cache: + narayana_cache[natal_key] = _narayana_start_dates( + int(context["ascendant_index"]), context["planet_longitudes"], birth_date, lo, hi, + ) + natal = narayana_cache[natal_key] + if natal is not None: + tracks["nara"] = natal + if not varga_narayana: + return tracks + for layer in ("d9", "d10"): + raw = _layer_value(context, layer) + if not isinstance(raw, int): + continue + layer_key = (*_narayana_cache_key( + raw, context["planet_longitudes"], birth_date, lo, hi, + ), layer) + if layer_key not in narayana_cache: + narayana_cache[layer_key] = _narayana_start_dates( + raw, context["planet_longitudes"], birth_date, lo, hi, + ) + dates = narayana_cache[layer_key] + if dates is not None: + tracks[f"nara:{layer}"] = dates + return tracks + + +def _guided_year_windows(dates: Sequence[date]) -> list[tuple[int, int, int]]: + by_year: dict[int, list[date]] = {} + for item in dates: + by_year.setdefault(item.year, []).append(item) + windows: list[tuple[int, int, int]] = [] + for year in sorted(by_year): + months = [item.month for item in by_year[year]] + windows.append((year, min(months), max(months))) + return windows + + +def guided_collect_windows( + request: dict[str, Any], + built: dict[str, Any], + *, + candidate_times: Sequence[str], + today: date | None = None, +) -> list[dict[str, Any]]: + """Boundary windows for guided collect. Does not change auto probe generation.""" + birth_date = str(request.get("birth_date") or "").strip() + birth_year = _birth_year(birth_date) + if birth_year is None: + return [] + try: + datetime.strptime(birth_date, "%Y-%m-%d") + except ValueError: + return [] + remaining = _remaining_contexts(built, candidate_times) + if len(remaining) < 2: + remaining = _static_contexts(built) + work = [item for item in remaining if _scoreable(item)] + if len(work) < 2: + return [] + declined = { + str(item).strip() + for item in (request.get("declined_domains") or []) + if str(item).strip() in GUIDED_DOMAIN_ORDER + } + now = today or date.today() + lo, hi = birth_year + 5, min(now.year, birth_year + 80) + remaining_layers = _differing_layers(work) + fallback = [ + domain + for domain in _probe_domains( + remaining_layers, + [item for item in (request.get("events") or []) if isinstance(item, dict)], + d1_differs="d1" in remaining_layers, + ) + if domain not in declined + ] + if not fallback: + fallback = [domain for domain in GUIDED_DOMAIN_ORDER if domain not in declined] + if not fallback: + return [] + vim_cache: dict[tuple[Any, ...], list[date]] = {} + narayana_cache: dict[tuple[Any, ...], list[date] | None] = {} + starts_by_time: dict[str, dict[str, list[date]]] = {} + for context in work: + time = _context_time(context) + if not time: + continue + starts_by_time[time] = _guided_track_starts( + context, + birth_date=birth_date, + lo=lo, + hi=hi, + include_pratyantar=True, + varga_narayana=True, + vim_cache=vim_cache, + narayana_cache=narayana_cache, + ) + rows: list[dict[str, Any]] = [] + seen: set[tuple[int, int, int, str]] = set() + unlayered_index = 0 + for left, right in _representative_pairs(work): + left_time = str(_context_time(left) or "") + right_time = str(_context_time(right) or "") + left_tracks = starts_by_time.get(left_time) or {} + right_tracks = starts_by_time.get(right_time) or {} + for track in ("vim", "nara", "nara:d9", "nara:d10"): + left_dates = left_tracks.get(track) or [] + right_dates = right_tracks.get(track) or [] + if not left_dates or not right_dates: + continue + for index, (one, two) in enumerate(zip(left_dates, right_dates)): + if one == two: + continue + gathered: list[date] = [] + for context in work: + time = _context_time(context) + dates = (starts_by_time.get(time or "") or {}).get(track) or [] + if index < len(dates): + gathered.append(dates[index]) + if len(gathered) < 2 or min(gathered) == max(gathered): + continue + ordered = sorted(gathered) + cut = ordered[0] + (ordered[-1] - ordered[0]) / 2 + left_n = sum(1 for item in gathered if item <= cut) + right_n = len(gathered) - left_n + if left_n < 1 or right_n < 1: + continue + track_domain = _GUIDED_TRACK_DOMAIN.get(track) + if track_domain and track_domain not in declined: + domain = track_domain + else: + domain = fallback[unlayered_index % len(fallback)] + unlayered_index += 1 + if domain in declined: + continue + for year, month_lo, month_hi in _guided_year_windows(gathered): + if year < lo or year > hi: + continue + key = (year, month_lo, month_hi, domain) + if key in seen: + continue + seen.add(key) + rows.append({ + "year": year, + "month_lo": month_lo, + "month_hi": month_hi, + "domain": domain, + "split": {"left": left_n, "right": right_n}, + }) + rows.sort(key=lambda row: ( + abs(row["split"]["left"] - row["split"]["right"]), + abs(now.year - int(row["year"])), + -int(row["year"]), + int(row["month_lo"]), + str(row["domain"]), + )) + return rows[:GUIDED_COLLECT_LIMIT] + + def prospective_windows_for_time( request: dict[str, Any], built: dict[str, Any], diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index eb7e4d70..f36ac2c9 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -688,6 +688,7 @@ def build_refinement_packet( "precision_stage": {"current": "block_scan"}, "oos_blind_prompts": [], "discriminating_event_probes": [], + "guided_collect_windows": [], "event_clarification_probes": [], "evidence_collection_probes": [], "candidate_contrast_opportunities": [], @@ -708,6 +709,7 @@ def build_refinement_packet( discriminating_event_probe_set, event_clarification_probes, evidence_collection_probes, + guided_collect_windows, prospective_event_windows, ) grid_times = list(built.get("candidate_times") or candidate_times) @@ -731,6 +733,11 @@ def build_refinement_packet( ) probes = bundle["probes"] dropped = list(bundle["dropped"]) + windows = guided_collect_windows( + request, + built, + candidate_times=probe_times, + ) clarification = event_clarification_probes(request) collection = evidence_collection_probes(request) if probe_times == grid_times: @@ -783,6 +790,7 @@ def build_refinement_packet( "precision_stage": stage, "oos_blind_prompts": oos_blind_prompts(request), "discriminating_event_probes": probes, + "guided_collect_windows": windows, "event_clarification_probes": clarification, "evidence_collection_probes": collection, "candidate_contrast_opportunities": opportunities, diff --git a/scripts/rectification_policy.py b/scripts/rectification_policy.py index 4f6558f3..2b70eb28 100644 --- a/scripts/rectification_policy.py +++ b/scripts/rectification_policy.py @@ -16,3 +16,4 @@ MAX_EXTERNAL_VALIDATION_WIDTH_MINUTES: Final = int(POLICY["maxExternalValidation MAX_CONFIRMATION_WIDTH_MINUTES: Final = int(POLICY["maxConfirmationWidthMinutes"]) MIN_CONFIRMATION_MARGIN_PERCENT: Final = int(POLICY["minConfirmationMarginPercent"]) MAX_PLATEAU_ROUNDS: Final = int(POLICY["maxPlateauRounds"]) +DELIVERY_MAX_WIDTH_MINUTES: Final = int(POLICY["deliveryMaxWidthMinutes"]) diff --git a/scripts/research/guided_collect_holdout_replay.py b/scripts/research/guided_collect_holdout_replay.py new file mode 100644 index 00000000..38bbe246 --- /dev/null +++ b/scripts/research/guided_collect_holdout_replay.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Offline T2 replay: after six probes, inject guided_collect_windows events. + +Does not change production defaults. Writes a compact JSON summary for the +progress note. Not a merge gate. +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +import traceback +from calendar import monthrange +from datetime import date +from pathlib import Path +from typing import Any, Sequence +from uuid import NAMESPACE_URL, uuid5 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.active_rectification_event_engine import ( # noqa: E402 + AYANAMSA, + NODE_MODE, + compute_candidate_static_contexts, +) +from scripts.rectification.event_probes import ( # noqa: E402 + discriminating_event_probes, + guided_collect_windows, +) +from scripts.rectification.refinement_packet import window_scan # noqa: E402 +from scripts.rectification.scoring_service import ( # noqa: E402 + build_event_contribution_matrix, + score_from_matrix, + scoreable_request, +) +from scripts.rectification_policy import DELIVERY_MAX_WIDTH_MINUTES # noqa: E402 +from scripts.research.cluster_width_lib import ( # noqa: E402 + SEPARATION_LEAD, + delivery_from_public, + merge_adjacent_traced, + public_from_clusters, + raw_signature_clusters, + still_valid_public, +) +from scripts.research.cluster_width_probe import replay_public # noqa: E402 +from scripts.research.minute_resolution_sweep import MINUTE_STEP, scoring_request_for # noqa: E402 +from scripts.research.probe_supply_after_six import ASK_COUNT, KIND_BY_DOMAIN # noqa: E402 + +HOLDOUT = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v4.json" +REPORT_JSON = ROOT / "docs" / "research" / "guided_collect_holdout_2026_09_16.json" +TODAY = date(2026, 9, 16) +RADII = (10, 30, 60) +RANGE_DELIVERY_TIE_PERCENT = 3 + + +def _hhmm(value: object) -> str | None: + text = str(value or "")[:5] + return text if len(text) == 5 and text[2] == ":" else None + + +def load_cases() -> list[dict[str, Any]]: + payload = json.loads(HOLDOUT.read_text(encoding="utf-8")) + return list(payload.get("cases") or []) + + +def synthetic_event(window: dict[str, Any], index: int) -> dict[str, Any]: + year = int(window["year"]) + month = int(window["month_lo"]) + last = monthrange(year, month)[1] + domain = str(window.get("domain") or "career") + kind = KIND_BY_DOMAIN.get(domain, "career_change") + stamp = f"{year:04d}-{month:02d}" + return { + "id": str(uuid5(NAMESPACE_URL, f"guided-collect:{index}:{domain}:{stamp}")), + "domain": domain, + "event_kind": kind, + "date_start": f"{stamp}-01", + "date_end": f"{stamp}-{last:02d}", + "precision": "month", + "summary": f"guided {domain} {stamp}", + } + + +def posterior_state( + *, + rows: Sequence[dict[str, Any]], + contexts: Sequence[dict[str, Any]], + probes: Sequence[dict[str, Any]], + true_time: str, +) -> dict[str, Any]: + raw = raw_signature_clusters(contexts) + by_time = {stamp: row for row in rows if (stamp := _hhmm(row.get("time")))} + merged, _trace = merge_adjacent_traced(raw, by_time) + public = public_from_clusters(merged, rows) + prior = {stamp: float(row.get("score") or 0) for row in public if (stamp := _hhmm(row.get("time")))} + replay = replay_public(probes=probes, public=public, prior=prior, true_time=true_time) + posterior = [] + eliminated = set(replay["eliminated"]) + scores = dict(replay["scores"]) + for row in public: + stamp = _hhmm(row.get("time")) + if not stamp: + continue + posterior.append({**row, "score": scores.get(stamp, row.get("score") or 0)}) + valid = still_valid_public(posterior, scores, eliminated, lead=SEPARATION_LEAD) + delivery = delivery_from_public(valid) + return { + "scores": scores, + "eliminated": eliminated, + "public": posterior, + "valid": valid, + "delivery": delivery, + "merged": merged, + } + + +def precision_gate(valid: Sequence[dict[str, Any]], scores: dict[str, float]) -> dict[str, Any]: + delivery = delivery_from_public(valid) + width = delivery.get("width") + ranked = sorted( + valid, + key=lambda row: ( + -float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)), + str(row.get("time") or ""), + ), + ) + if not ranked: + return { + "met": False, + "width": width, + "tied_for_first": False, + "gap": None, + "percents": [], + } + if len(ranked) == 1: + return { + "met": width is not None and width <= DELIVERY_MAX_WIDTH_MINUTES, + "width": width, + "tied_for_first": False, + "gap": None, + "percents": [100], + } + s0 = float(scores.get(_hhmm(ranked[0].get("time")) or "", ranked[0].get("score") or 0)) + s1 = float(scores.get(_hhmm(ranked[1].get("time")) or "", ranked[1].get("score") or 0)) + tied = s0 == s1 + total = sum( + max(float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)), 0.0) + for row in ranked + ) + percents = [] + for row in ranked[:3]: + score = max(float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)), 0.0) + percents.append(round(score / total * 100) if total > 0 else 0) + gap = percents[0] - percents[1] + met = ( + width is not None + and width <= DELIVERY_MAX_WIDTH_MINUTES + and gap > RANGE_DELIVERY_TIE_PERCENT + and not tied + ) + return { + "met": met, + "width": width, + "tied_for_first": tied, + "gap": gap, + "percents": percents, + } + + +def remaining_times(state: dict[str, Any]) -> list[str]: + times: list[str] = [] + seen: set[str] = set() + for row in state["valid"]: + stamp = _hhmm(row.get("time")) + if stamp and stamp not in seen and stamp not in state["eliminated"]: + seen.add(stamp) + times.append(stamp) + for member in row.get("cluster_times") or []: + clock = _hhmm(member) + if clock and clock not in seen and clock not in state["eliminated"]: + seen.add(clock) + times.append(clock) + return times + + +def evaluate_case( + case: dict[str, Any], + radius: int, +) -> dict[str, Any]: + true_time = str(case["birth"]["time"])[:5] + request = scoring_request_for(case, radius) + request["ayanamsa"] = AYANAMSA + request["node_mode"] = NODE_MODE + request["minute_step"] = MINUTE_STEP + static_contexts = compute_candidate_static_contexts(request) + built = build_event_contribution_matrix(request, static_contexts=static_contexts) + rows = score_from_matrix(request, built) + times = [stamp for row in rows if (stamp := _hhmm(row.get("time")))] + probes = discriminating_event_probes( + {**request, "refresh_probes": False, "asked_probe_keys": []}, + built, + scan=window_scan(built), + candidate_times=times, + representative_time=true_time, + today=TODAY, + ) + state = posterior_state(rows=rows, contexts=static_contexts, probes=probes[:ASK_COUNT], true_time=true_time) + gate = precision_gate(state["valid"], state["scores"]) + if gate["met"]: + return { + "case_id": case.get("case_id"), + "radius": radius, + "events_needed": 0, + "windows": 0, + "met": True, + "after_six": gate, + } + remaining = remaining_times(state) + windows = guided_collect_windows( + request, + built, + candidate_times=remaining or times, + today=TODAY, + ) + extras: list[dict[str, Any]] = [] + last_gate = gate + for index, window in enumerate(windows): + extras.append(synthetic_event(window, index)) + injected = {**request, "events": list(request["events"]) + extras} + rebuilt = build_event_contribution_matrix( + scoreable_request(injected), + static_contexts=static_contexts, + ) + new_rows = score_from_matrix(scoreable_request(injected), rebuilt) + state = posterior_state( + rows=new_rows, + contexts=static_contexts, + probes=probes[:ASK_COUNT], + true_time=true_time, + ) + last_gate = precision_gate(state["valid"], state["scores"]) + if last_gate["met"]: + return { + "case_id": case.get("case_id"), + "radius": radius, + "events_needed": len(extras), + "windows": len(windows), + "met": True, + "after_six": gate, + "final": last_gate, + } + return { + "case_id": case.get("case_id"), + "radius": radius, + "events_needed": None, + "windows": len(windows), + "met": False, + "after_six": gate, + "final": last_gate, + } + + +def summarize(rows: Sequence[dict[str, Any]], radius: int) -> dict[str, Any]: + subset = [row for row in rows if row.get("radius") == radius and not row.get("error")] + needed = [int(row["events_needed"]) for row in subset if row.get("events_needed") is not None] + met = sum(1 for row in subset if row.get("met")) + return { + "radius": radius, + "n": len(subset), + "met": met, + "median_events_to_gate": statistics.median(needed) if needed else None, + "unmet": sum(1 for row in subset if not row.get("met")), + "errors": sum(1 for row in rows if row.get("radius") == radius and row.get("error")), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--limit", type=int, default=0) + parser.add_argument("--radii", default="10,30,60") + parser.add_argument("--json-out", default=str(REPORT_JSON)) + args = parser.parse_args() + radii = tuple(int(item) for item in str(args.radii).split(",") if item.strip()) + cases = load_cases() + if args.limit: + cases = cases[: args.limit] + started = time.perf_counter() + rows: list[dict[str, Any]] = [] + for case in cases: + for radius in radii: + label = f"{case.get('case_id')} ±{radius}" + try: + result = evaluate_case(case, radius) + rows.append(result) + print( + f"{label} needed={result.get('events_needed')} " + f"met={result.get('met')} windows={result.get('windows')}", + flush=True, + ) + except Exception as exc: # noqa: BLE001 + rows.append({ + "case_id": case.get("case_id"), + "radius": radius, + "error": f"{type(exc).__name__}: {exc}", + "trace": traceback.format_exc(limit=8), + "met": False, + "events_needed": None, + }) + print(f"{label} ERROR {type(exc).__name__}: {exc}", flush=True) + summaries = [summarize(rows, radius) for radius in radii] + payload = { + "generated_at": TODAY.isoformat(), + "holdout": str(HOLDOUT.relative_to(ROOT)).replace("\\", "/"), + "ask_count": ASK_COUNT, + "delivery_max_width_minutes": DELIVERY_MAX_WIDTH_MINUTES, + "tie_percent": RANGE_DELIVERY_TIE_PERCENT, + "elapsed_s": round(time.perf_counter() - started, 1), + "summaries": summaries, + "rows": [ + {key: value for key, value in row.items() if key != "trace"} + for row in rows + ], + "errors": [row for row in rows if row.get("error")], + } + out = Path(args.json_out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summaries, ensure_ascii=False, indent=2), flush=True) + print(f"wrote {out} in {payload['elapsed_s']}s", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/jyotish-birth-time-rectification/SKILL.md b/skills/jyotish-birth-time-rectification/SKILL.md index a6605a97..ccebd0a6 100644 --- a/skills/jyotish-birth-time-rectification/SKILL.md +++ b/skills/jyotish-birth-time-rectification/SKILL.md @@ -1,6 +1,6 @@ --- name: jyotish-birth-time-rectification -version: 10.0.26 +version: 10.0.27 description: "生时校正专用 Skill(V10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。" --- @@ -73,7 +73,7 @@ description: "生时校正专用 Skill(V10)。以服务器权威 Case、Conv - 不得从 assistant 上一句倒推拒答目标,不得仅靠 pending revision 或中文正则构造 active focus,也不得把脱离上下文的承接词保存成新事件。 - 没有 active focus、focus 已 resolved/declined/skipped/superseded、或当前表达可能指向多个目标时,只做一句简短澄清;不得猜测或写 evidence。 - 当前轮用户主动、明确、无歧义地提出全新事件时,可按新事件处理;若需要后续问题,由服务器建立新的 focus。 -- 用户已拒绝或跳过的目标不得换词重问;只有用户主动重开该主题或服务器建立新的有效 focus 才可继续。 +- 已拒绝(没有发生过 / 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次。只有用户主动重开该主题或服务器建立新的有效 focus 才可继续。 - 性格类点选题只在已经给出目前范围之后、用户点了卡下「再答两道参考题微调排序」才出,分值减半、不淘汰。 ## 6. CaseConversationSummary 与长会话记忆 @@ -81,7 +81,7 @@ description: "生时校正专用 Skill(V10)。以服务器权威 Case、Conv `CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。 - 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary` 与 `method_followup_plan`。 -- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题,仍无题再按还能切开剩余候选的线逐条点选:每条先问有没有(有 / 没有 / 记不清 / 这条先跳过,不计分),答「有」再问大概哪年几月。题干与提示写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。所有线问完或用户说「没有了 / 就这些」后,才交付目前范围。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 +- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。时间点题答「没发生」只关那个时点,不关领域。出卡须 `precision_gate_met`(宽度 ≤10 分钟且前两名相对可能性差 >3 个百分点且不并列)或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 - recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。 - summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。 - 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。 @@ -123,7 +123,7 @@ description: "生时校正专用 Skill(V10)。以服务器权威 Case、Conv - 未达到唯一分钟确认门时,任何“就用 HH:MM”都只能进入 accepted;只有 `confirmation_allowed=true` 且用户同意才可写 confirmed。 - 若不可分 blocker 为 `blocked`、宽度大于 5、top `tied_minute_count` > 1,或 `confirmation_allowed=false`,正文必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。 - 分钟窗口扫描只在服务端。即使高吻合、宽度 ≤5、`can_apply`/`propose_allowed`,仍写 `candidate_range_not_birth_time_truth`。 -- 出牌/采用轮正文只写三句:目前范围与代表分钟、对照了几件经历与事件吻合率、边界句「这只是代表性候选,不是已确认的唯一出生分钟」。卡片标题用「目前范围」,卡下必有一句「还能再收窄:如果记得 …」。禁用「这次给出」「结束」「最终」。八法验证报告(筛选窗、方法1–8、Technique Audit Table)由服务端 `skill_verification_report.markdown` 渲染在卡片下方折叠块「查看验证报告」,**不得**写入助手气泡。宽度、双轨只抄 `skill_verification_report` 的 `width_minutes` / `dasha_agreement`。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。 +- 出牌/采用轮正文只写三句:目前范围与代表分钟、对照了几件经历与事件吻合率、边界句「这只是代表性候选,不是已确认的唯一出生分钟」。卡片标题用「目前范围」。门槛未达时卡下写「再对照几件经历会更准」,不得邀请自由打字。禁用「这次给出」「结束」「最终」。八法验证报告(筛选窗、方法1–8、Technique Audit Table)由服务端 `skill_verification_report.markdown` 渲染在卡片下方折叠块「查看验证报告」,**不得**写入助手气泡。宽度、双轨只抄 `skill_verification_report` 的 `width_minutes` / `dasha_agreement`。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。 - 80%/60% 只描述**事件吻合率**(高度/中度/低度拟合),**不得**写成“已确认唯一出生分钟”。 - 不得在同一回复中一边要求继续补证据、一边提供采用候选。 - 不得伪造出生分钟、分数、权重、事件 ID、分盘事实或确认门结果。 diff --git a/skills/jyotish-birth-time-rectification/references/conversation-strategy.md b/skills/jyotish-birth-time-rectification/references/conversation-strategy.md index 091646c1..d00de1bc 100644 --- a/skills/jyotish-birth-time-rectification/references/conversation-strategy.md +++ b/skills/jyotish-birth-time-rectification/references/conversation-strategy.md @@ -64,8 +64,8 @@ active `ConversationFocus` 是承接型意图的唯一目标来源。它由服 - 确认既有事实:必须有对应 `evidenceId`;确认词本身不创建新 evidence。无匹配 pending-target 的 focus 时可省略 `focusId`。 - 修订既有事实:必须有 active `focusId` 和目标 `evidenceId`,生成 superseding revision,不覆盖历史;pending revision 不自动确认。 -- 用户明确“不知道 / 记不清”:将 active focus 解决为 skipped,本会话不再问该领域采集;采用后核对仍可碰。回执「记下了,这题先放着。」 -- 用户明确“没有 / 不想回答 / 换个方向”:decline/skip active focus;不得换词重开同一目标。采集题「没有」走 declined,回执「记下了,这方面先跳过。」 +- 用户明确“不知道 / 记不清”:将 active focus 解决为 skipped;跳过的线按服务器计划最多换一种问法再问一次,再次跳过才永久关闭。回执「记下了,这题先放着,后面换个问法再问一次。」 +- 用户明确“没有 / 不想回答 / 换个方向”:decline active focus;已拒绝(没有发生过)的不得换词重问。采集题「这类事都没有过」走 declined,回执「记下了,这条按没有发生过记。」时间点题答没发生不关领域。 - 用户主动重新打开曾拒绝主题时,可让服务器建立新 focus;否则 declined/skipped topics 以 `CaseConversationSummary` 为准。 - 用户说“目前没有 / 没有更多事件”时,停止轮换证据领域;不要求结束、暂停或保存进度。 - 若没有其他具备信息增益的问题,可以直接说明当前边界或自然结束本轮。 @@ -75,7 +75,7 @@ active `ConversationFocus` 是承接型意图的唯一目标来源。它由服 追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级: 1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。 -2. `method_followup_plan.next_followup` 指定的下一方法层。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题,仍无题再按还能切开剩余候选的线逐条点选:每条先问有没有(有 / 没有 / 记不清 / 这条先跳过,不计分),答「有」再问大概哪年几月。题干与提示写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。所有线问完或用户说「没有了 / 就这些」后,才交付目前范围。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针、定向补事「有没有」或采用后核对前事时,`choice_frame` 才提供点选卡;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 +2. `method_followup_plan.next_followup` 指定的下一方法层。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问,再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。出卡须 `precision_gate_met` 或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针、定向补事「有没有」或采用后核对前事时,`choice_frame` 才提供点选卡;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。 4. pending revision 的一个关键歧义。 5. 已有证据的必要稳定性补强。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.27/SKILL.md b/skills/jyotish-birth-time-rectification/versions/10.0.27/SKILL.md new file mode 100644 index 00000000..ccebd0a6 --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.27/SKILL.md @@ -0,0 +1,146 @@ +--- +name: jyotish-birth-time-rectification +version: 10.0.27 +description: "生时校正专用 Skill(V10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。" +--- + +# Jyotish 生时校正(V10) + +## 1. 触发条件与方法学归属 + +本 Skill 只服务 `agentic_rectification_cases` 绑定的生时校正会话: + +- 服务端 Case 存在且 `skill_name = 'jyotish-birth-time-rectification'`。 +- 用户话题是出生时间 / 出生分钟 / 事件发生时间能否定位到某几分钟,而不是普通解盘或推运。 +- 普通咨询、推运、合盘、补救问题交给 `jyotish-vedic-astrology`,不要在这里处理。 + +生时校正的方法学、访谈策略、证据边界与候选表达规则只定义在本 Skill 及其 references。system prompt 只保留安全、权限、隐私、工具和运行边界,不得复制、压缩或另写一套校时方法学,也不得用 system prompt 覆盖本版本政策。 + +## 2. 必须先读与服务器权威 + +进入任何一轮实质工作前读取(服务器会随 Dossier 提供投影,缺文件时以服务器 Dossier 为准): + +1. `references/evidence-model.md`:证据种类、日期精度、原文引用、修订链、服务器持有 ID。 +2. `references/conversation-strategy.md`:OpeningPolicy、ConversationFocus、长会话记忆、批量证据与追问策略。 +3. `references/candidate-comparison.md`:candidate / accepted / confirmed 三层语义与表达边界。 +4. `references/technique-routing.md`:技法按主题调用,D9/D10 核心,不一次性调用所有分盘。 +5. `references/truth-consent-boundaries.md`:真实性、同意与选择政策。 + +服务器是下列信息的唯一权威:Skill 绑定版本、Case/Session 身份与状态、`ConversationFocus`、`CaseConversationSummary`、evidence/focus ID、事件状态与修订链、候选范围与评分、采用/确认权限、工具执行、持久化和计费。Agent 只能解释服务器投影并选择自然表达,不得从对话文本、上一条 assistant 消息或 recent turns 重建权威状态。 + +每次 attempt 必须先完成真实 Skill 绑定和 Case 加载,之后才能执行 action。失败或重试 attempt 的部分文本、工具结果与推断不得当作已提交事实;只依据服务器提交成功的 attempt 与 receipt。 + +## 3. Case 状态与只读边界 + +服务器 Dossier 会给出当前 `status`。按表行动: + +| status | 允许动作 | +|---|---| +| `draft` / `collecting_evidence` | 继续收集/修订带日期事件;可读取诊断。`next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间,**不得**同时追问;仍有挡住出牌的 `next_followup` 时继续收集,**不得**提供候选。`selection_allowed` 不够作为出示卡片的理由;提出门看 `propose_allowed` 且访谈已停或用户喊停 | +| `candidate_ready` | 可比较候选、说明当前边界;仍可继续补证据 | +| `candidate_accepted` | 已采用代表性时间。采用后先按该分钟核最多两件前事,对不上可改选其他候选;核对结束再用这个时间看盘。`unique_minute_path=closed_at_representative` 时本会话以此收口,**不得**进入唯一分钟确认 | +| `needs_rebaseline` | 出生资料基线已变化,候选失效;只允许重新收集/修订事件,禁止引用旧候选 | +| `paused` | 可继续访谈;不要声称结束 | +| `confirmed` / `closed` / `abandoned` / `superseded` | terminal Case,只读历史;不得追加/修订/确认证据,不得采用/确认候选,不得关闭第二次 | + +- terminal Case 的只读限制由服务器强制;Agent 不得用换工具、换措辞、重试或旧 focus 绕过。用户要继续校正时,说明需要走显式新建 Case 的入口。 +- 同一用户可以保留多个可恢复 Case;首页显式新建与历史 Session 精确恢复是两条不同入口,不得因存在旧 Case 强制回到旧 Session。 +- 历史 Session 必须恢复对应的精确 Case/Session;不得把另一个 resumable Case 的上下文混入当前会话。 + +## 4. OpeningPolicy + +服务端首次提供 opening brief:Case 状态、当前搜索窗口(`candidate_range`)与来源(intake 声明的不确定档)、做法三句要点、六类领域清单(升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤)。Agent 按下列三句模板自然开场,不得要求先准备一套材料,也不得写具体年份: + +1. 一句当前搜索窗口与核对做法。 +2. 一句「最后给区间和代表分钟,不给精确到秒」。 +3. 一句「想到几件说几件,有大概年月就行」并点出上述六类。 + +开场必须满足: + +- 一条消息可以报多件;想到几件说几件,有大概年月即可。用户每说一批后由服务端问「还有吗」,例子只列还没提过的具体事物、最多 4 个。用户说「没有了 / 就这些 / 记不清」后改为从已说的事做锚定追问。不得用生日推年份写进题干,也不得重复开场邀请。 +- 允许模糊日期:可以先说大概年份、阶段或范围;如确有信息增益,后续再澄清,不诱导猜测月份或日期。 +- 首题保持采集题身份(`collect:other:*`),题干写成「先说你最容易想起的一两件,年月大概就行」。 +- 至多一个主问题且唯一来源:每轮当前问题只能由服务端建立 `ConversationFocus` 并通过界面问题槽呈现。Agent 回复正文只做承接与解释,不得自行提出、复述、改写或预告问题;正文内容不参与问题槽判定。 +- 不机械复述 opening brief,不泄露服务器字段、内部状态对象或出生资料明文。 +- 用户说出出生时间或时段时,不得回答『以你说的为准』或改写搜索窗口;服务端会固定回复范围在开始时已定、过程中不改。 + +## 5. ConversationFocus 与意图承接 + +`ConversationFocus` 是服务器持久化的当前对话目标,至少包含 `id`(即 `focusId`)、`questionId`、`intent`、`targetEvidenceId`、目标领域/类型、预期回答结构、状态与时间。Agent 可做意图分类,但服务器必须验证目标仍为 `active`。 + +- “是的 / 不是 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”等承接、拒答、确认和修订,必须依赖服务器给出的 active focus。 +- 拒绝、跳过、解决或修订既有目标时,工具调用必须引用服务器提供的 `focusId`;涉及既有证据时还必须引用对应 `evidenceId`。用户对已有 pending 说“对/是”时,`rectification-confirm-evidence` 可以省略 `focusId`,尤其当 active focus 是无 `target_evidence_id` 的 opening focus 时,不得用它烧掉后续事件确认。 +- 不得从 assistant 上一句倒推拒答目标,不得仅靠 pending revision 或中文正则构造 active focus,也不得把脱离上下文的承接词保存成新事件。 +- 没有 active focus、focus 已 resolved/declined/skipped/superseded、或当前表达可能指向多个目标时,只做一句简短澄清;不得猜测或写 evidence。 +- 当前轮用户主动、明确、无歧义地提出全新事件时,可按新事件处理;若需要后续问题,由服务器建立新的 focus。 +- 已拒绝(没有发生过 / 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次。只有用户主动重开该主题或服务器建立新的有效 focus 才可继续。 +- 性格类点选题只在已经给出目前范围之后、用户点了卡下「再答两道参考题微调排序」才出,分值减半、不淘汰。 + +## 6. CaseConversationSummary 与长会话记忆 + +`CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。 + +- 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary` 与 `method_followup_plan`。 +- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。时间点题答「没发生」只关那个时点,不关领域。出卡须 `precision_gate_met`(宽度 ≤10 分钟且前两名相对可能性差 >3 个百分点且不并列)或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 +- recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。 +- summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。 +- 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。 + +## 7. 批量证据与日期真实性 + +一次用户消息可包含多件事件。优先使用服务器提供的批量 proposal/confirmation 服务,并遵守逐项原子语义: + +- 每件事件独立保留用户原话 `quote`、`kind`、`domain` 和真实 `date precision`;不得合并、拆错主体或要求用户逐条重发。 +- 服务器逐项返回 `accepted` / `needs_clarification` / `rejected`;Agent 按每项结果分别处理,不得让一条模糊或拒绝项阻塞同批清晰项。 +- 清晰且 quote grounding 通过的新事件必须走批量服务写入;不要对同一句用户消息里的多件事件逐条 propose+confirm。`rectification-confirm-evidence` 只用于用户对已有 pending 明确说“对/是”。 +- 证据有效写入后,服务器会按当前账本重算候选。不要等用户说“没有更多了”才 compare;同一证据指纹不要再 compare。不要调用新的扫描工具。 +- 证据轮正文只写一句复述,格式「记下了:年 月 事件短语(、…)。」例如「记下了:2016 年 9 月入学、2020 年 6 月毕业。」不得加评价句,不得写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接到正文后面。 +- 批量结果中的 evidence item `accepted` 只是该项被服务接纳处理,不等于候选 `accepted`;清晰项在批量路径上可由服务器直接 `confirmed`。 +- 复述任何事件日期必须使用服务器 `display_date_label`。日级不得说成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`。 +- `needs_clarification` 不得猜补日期、主体、事件身份、主动/被动、原因或人物关系;`rejected` 不得伪装成已记录。 +- 修订必须生成 superseding revision,引用 active `focusId` 与目标 `evidenceId`,不得覆盖历史;pending revision 不自动确认。 +- 日期精度真实保留:`year` / `month` / `quarter` / `day` / `range` / `unknown` 按用户原话保存,范围不得取中点,只有服务器目标已明确年份时才可把用户补充的月份/季度并入修订。 +- 批量服务与单项工具都必须依赖服务器幂等键;重试不得重复创建或确认 evidence。Agent 不自行生成 evidence/focus ID。 + +## 8. 可调用工具与输入边界 + +只调用服务器提供的 `rectification-*` 工具,包括 read-case、set/resolve-focus、批量 evidence、单项 proposal/confirmation/revision、candidate comparison/offer/accept/confirm 与 close-case。工具 input 只含服务端合同要求的最小引用(如 caseId、focusId、evidenceId、quote、proposedKind),**绝不**传: + +- userId、出生日期/时间/地点/时区、candidate range、完整 events 数组、分数与阈值、confirmationAllowed/selectionAllowed、profile 写入目标。 + +工具结果只读取;事实、ID、评分、范围、状态、持久化、幂等与权限一律以服务器为准。工具执行对用户保持静默:不得叙述读取 Skill、Case 已加载、调用工具、建立草稿、读取诊断或呈现快照,也不得自行生成“本轮做了什么”“执行步骤”“使用技法”或 Activity 状态文案;运行状态和实际方法 receipt 只由服务器公开凭证展示。 + +## 9. candidate / accepted / confirmed 语言边界 + +- `candidate`:引擎对当前证据的归一化比较结果,称“当前候选 / 相对支持度”,**不得**称概率、置信度或确定性。 +- `accepted`:用户明确选择的当前排盘时间,称“校正采用时间”,**不得**称“已确认唯一出生时间”。 +- `confirmed`:通过服务器确认门且用户明确同意,称“已确认校正时间”。 +- `session_outcome=adopt_representative` / `next_user_action.id=adopt_representative`:本轮**有结果**,结果是采用代表性时间作当前排盘。正文应自然说明代表性候选可用于当前排盘,但它不是已确认的唯一出生分钟;不要使用固定收口句式。不要调用 confirm。只有这时才调用 `rectification-offer-candidates`。服务器会拒绝访谈未停且用户未喊停的 offer。`collecting_evidence` 且仍有挡住出牌的 `next_followup` 时不得 offer/accept。`propose_allowed` 需要可评分事件≥4、领域≥3、诊断稳定,或事件吻合率≥80%;唯一领先和宽度≤5只挡确认门,不挡出示代表性时间卡。精度阶段追问在收集达到训练门、选择题问完后才问,且不挡出牌。KP 观察不计分、不挡提出门。 +- 确认门以 `latest_result.confirmation_gate` 为准。`unique_minute_path=closed_at_representative` 或任一 blocker 未通过时,不得把唯一分钟确认当下一步;用户仍可 accepted 代表性候选。 +- `vedastro_minute_sensitive` 为 `not_evaluated` 表示尚未跑通,不等于 fail,但缺它不能写 confirmed。 +- 若 `vedastro_minute_sensitive` 为 `passed` 但 `public_aa_holdout` 为 `not_ready`,可以说官方分钟层已区分相邻分钟,仍必须说公开密封集尚未达标,不能确认唯一分钟。 +- `public_aa_holdout` 为 `not_ready` 时 `unique_minute_path` 必须是 `closed_at_representative`:不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。 +- 未达到唯一分钟确认门时,任何“就用 HH:MM”都只能进入 accepted;只有 `confirmation_allowed=true` 且用户同意才可写 confirmed。 +- 若不可分 blocker 为 `blocked`、宽度大于 5、top `tied_minute_count` > 1,或 `confirmation_allowed=false`,正文必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。 +- 分钟窗口扫描只在服务端。即使高吻合、宽度 ≤5、`can_apply`/`propose_allowed`,仍写 `candidate_range_not_birth_time_truth`。 +- 出牌/采用轮正文只写三句:目前范围与代表分钟、对照了几件经历与事件吻合率、边界句「这只是代表性候选,不是已确认的唯一出生分钟」。卡片标题用「目前范围」。门槛未达时卡下写「再对照几件经历会更准」,不得邀请自由打字。禁用「这次给出」「结束」「最终」。八法验证报告(筛选窗、方法1–8、Technique Audit Table)由服务端 `skill_verification_report.markdown` 渲染在卡片下方折叠块「查看验证报告」,**不得**写入助手气泡。宽度、双轨只抄 `skill_verification_report` 的 `width_minutes` / `dasha_agreement`。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。 +- 80%/60% 只描述**事件吻合率**(高度/中度/低度拟合),**不得**写成“已确认唯一出生分钟”。 +- 不得在同一回复中一边要求继续补证据、一边提供采用候选。 +- 不得伪造出生分钟、分数、权重、事件 ID、分盘事实或确认门结果。 + +## 10. 输出与停止条件 + +- 简体中文。访谈按 skill 路径 C:先用自然语言收集带大概年份的经历;只有候选已经分不开时才生成可点选的 A/B/C/D 主题问卷。允许模糊日期、允许分多轮。**不得**一进场就出点选卡,也不得先逼 10–15 条事件长表。 +- 每轮最多一个主要问题;完整回复可以零问题,不为了延续对话强行追问,不生成三条推荐问题。 +- 用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,基于服务器状态直接回答,不把问题当作事件。 +- 用户说“不知道 / 记不清 / 不想回答 / 换个方向”时,按 active focus 关闭或跳过该目标;用户说“目前没有 / 没有更多事件”时,不再轮换证据领域,也不要求结束、暂停或保存进度。 +- 不得询问外貌、体质、胎记或疤痕。D9/D10 类型表是校时方法,写「该分钟下 D9/D10 升 X,与用户所述特质的对应/冲突」,不是咨询命运承诺。职业对照本命第 10 宫和 D10,允许类型表。占问只问一次;有问起时间则观察,没有也不挡出牌。`internal_observations` 可用于选题,类型对照写入验证报告。若用户消息以「盘外核对(不计分)」开头,不得写入可评分证据。 +- 精度阶段按本命上升 → D9 → D10 → D4 居所 → D5/D24 成就收窄;家人走 D12/D7/D3 方法覆盖。财务走 D2/D11、健康走 D30,与其他领域同权计分,均不得混进 D4。Pada / Hora / Ghati / Bhava / Pranapada / KP 子主只展示换升,不确认唯一分钟。 +- 采用后按采用分钟核最多两件服务器探针前事;对得上写入并重算,对不上可改选其他候选。不得声称唯一分钟,也不自动进入咨询 Agent。 +- 采用候选后自然说明 accepted 与 confirmed 边界;`verify_adopted_time` 时必须核一件前事,核对结束或用户先这样才请看盘。不主动关闭 Case,Session 会保留并可日后继续。 +- 不再有固定 10–15 个事件长表、外貌/体型/疤痕主评分、或“稳定确定到精确分钟”的承诺。A/B/C/D 主题问卷只在候选已经分不开或采用后核对前事时使用。80%/60% 只描述事件吻合率。 +- 无法验证时如实降级并说明受限,不得把内部一致性伪装成全球顶级精度。 + +## 11. 上游同步边界 + +方法源只在本 Skill 与 references。不得把本 Skill 内容反向写回 `yinduzhanxing` 上游快照,也不得在同步时自动覆盖商业 Skill。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.27/references/candidate-comparison.md b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/candidate-comparison.md new file mode 100644 index 00000000..ec2ac42e --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/candidate-comparison.md @@ -0,0 +1,84 @@ +# Candidate Comparison(V9) + +候选比较是服务器计算产物,Agent 只负责解释与引导,不负责产生候选、分数或范围。 + +## 1. 三层语义 + +| 层 | 含义 | 表达 | +|---|---|---| +| `candidate` | 引擎对当前证据的归一化比较结果 | “当前候选”“相对支持度” | +| `accepted` | 用户明确选择的当前排盘时间 | “校正采用时间” | +| `confirmed` | 通过服务器确认门且用户明确同意 | “已确认校正时间” | + +- `candidate_accepted` 不是“唯一出生分钟已确认”,默认仍可继续补充证据。 +- accepted 后用户仍可在同一批有效候选中改选(幂等 RPC 支持)。 +- confirmed 只能由服务器确认门 + 用户明确同意触发,同时写 `completed_at`。 + +## 2. 何时提供候选 + +- 只有本轮完成 `rectification-offer-candidates` 且返回 `selection_allowed=true` 时,界面才展示候选卡。 +- `selection_allowed` 只表示可以采用代表性时间,**不是**本轮必须出示卡片。提出门看 `latest_result.propose_allowed`,并且没有挡住出牌的 `method_followup_plan.next_followup`(占问和精度阶段追问不挡;职业挡出牌)。唯一领先和宽度≤5只挡确认门。 +- `next_user_action.id=adopt_representative`,或用户停止且 `on_user_stop` 为 adopt 时,本轮才 offer/accept。服务器会拒绝访谈未停的 offer。这是采用代表性时间,不是 confirmed。 +- 继续收集证据时不得边追问边提供采用。 +- 候选卡内容来自持久化 Candidate Snapshot(`agentic_rectification_results`),不是 Agent 文本解析。 +- 候选卡按一行至多三列并排:每列一个候选分钟,写相对可能性、性格处事、经历对照、往后 12 个月事件窗;「更像这个」即采用。不预标「排盘用」。Agent 正文在出牌轮**不得**复述八法表格或 Technique Audit。 + +## 3. 表达边界 + +- 相对支持度是候选间归一化比较,**不是**概率、统计置信度或确定性。卡片上的「相对可能性」是答题后的后验百分比,同样不是引擎置信度。80%/60% 只描述事件吻合率。 +- 出牌轮正文不写事件–Dasha–Gochara 表、D9/D10 类型对照和技法审计;那些只出现在折叠的验证报告里。不暴露隐藏分钟证据或把分数说成唯一分钟概率。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。 +- 候选范围必须说明“待核对边界”,不得表述为已确认出生分钟。 +- 外部验证状态按服务器字面读取:`not_evaluated` 表示未调用(入口门未就绪),不是“调用了但失败”。 + +## 4. 证据变化与重算 + +- 证据有效变化时由服务器重算候选;Agent 不必等用户说“没有更多了”才 compare。 +- 相同 evidence 指纹 + 引擎版本复用缓存;不要对同一指纹再 compare。 +- 分钟窗口扫描只在服务端,结果进入候选卡 / 不可分平台语言。不得把若干事件说成已确定到 ±5 分钟。 +- 普通澄清轮若不改变账本指纹,不重复播报。 +- 出生资料基线变化 → `needs_rebaseline`,旧候选失效;不得静默继续用旧结果。 +- `needs_rebaseline` 下不引用旧候选、不提供采用。 + +## 5. 不可分平台与确认门(必须说出来) + +服务器 `latest_result` 含 `confirmation_gate`、`engine_indistinguishable_width_minutes`、`confirmation_allowed`、`selection_allowed` 与 `margin_percent`(若有)。`confirmation_gate` 是确认门权威,不是让 Agent 另算一分钟。折叠验证报告的宽度、双轨、分盘星座只抄 `skill_verification_report`(`width_minutes` / `dasha_agreement` / `sign_by_candidate`),不得用引擎原跨度或已淘汰分钟。Agent 正文不得再写这些表。 + +- 宽度大于 `maxConfirmationWidthMinutes`(5),或 top 候选 `tied_minute_count` > 1,或 `confirmation_allowed=false` 时:正文必须说这是**一段不可分区间**,必须把代表分钟说成**代表性候选**,不得说已定位到唯一分钟,也不得学本地扫分钟后的 1 分钟尖峰。 +- `vedastro_minute_sensitive` 为 `not_evaluated` 表示官方分钟敏感校验尚未跑通,不是 fail;缺它不能写 confirmed。 +- 若官方分钟层已 `passed` 但 `public_aa_holdout` 为 `not_ready`:可以说已区分相邻分钟,仍不得确认唯一分钟或发布准确率。 +- `public_aa_holdout` 为 `not_ready` 时不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。 +- 用户仍可 accepted 代表性候选;accepted ≠ confirmed。`session_outcome=adopt_representative` 时自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。`unique_minute_path=closed_at_representative` 时不得把确认当下一步。 +- `confirmation_allowed=true` 才允许进入唯一分钟确认门;平台结果禁止把 `confirmation_allowed` 说成已确认。 +- 候选卡仍可展示代表性时间;Agent 不得把该时间写成“已校正到 HH:MM”。 + +## 6. 出生时间来源标签 + +服务器 Dossier / GET 快照的 `birth_time_source`(缺省按 `approximate`)决定任何指代「用户报上来的那个时间」的措辞。打分与搜索窗中心仍用 `reported_birth_time`,本规则只约束表达。 + +| 来源 | 可称 | 不得称 | +|---|---|---| +| `hospital_record` | 「你的出生记录时间」 | 「已确认的出生分钟」 | +| `approximate`(含存量 `family_exact`) | 「你填的大概时间」「家人记得的时间」 | 「你的出生时间」 | +| `period_only` | 「你给的时间段」 | 「你的出生时间」;不得逼用户补一个钟点 | + +校正产物自己的标签不变:交付区间是目前范围(`rectified_window`),代表分钟是代表性候选(`representative_time`),采用之后是校正采用时间(`accepted`)。不得把代表分钟说成已确认的出生分钟。 + +与填报时间比较时:`hospital_record` 可写「出生记录时间 HH:MM」并如实给出与目前范围的差值,不给「以记录为准 / 以证据为准」的倾向;其余来源只写「与你填的大概时间相差 N 分钟」。 + +### 6.1 记录与目前范围冲突(`hospital_record` 落在范围外) + +产品负责人 2026-09-14 拍板:**记录优先,分歧如实呈现。** 依据两条:封存 20 例上六题后头名簇命中率是 0.80 / 0.55 / 0.35(±10 / ±30 / ±60 分钟窗,见 `docs/research/cluster_width_2026_09_14.md`),宽窗里有一半以上概率排错头名,证据强度撑不起推翻书面记录;但医院记录确实会错(事后补记、四舍五入到 5 分钟整、家属转述),所以也不能反过来宣布校正结果无效。 + +- **D1 默认仍按出生记录时间排盘。** 这是既有行为——采用是用户主动动作,不采用就继续用填报时间。本节只要求把它说出来,不改行为。 +- **D2 冲突时校正区间是「证据倾向」,措辞写满三层:** ①默认还是按你的出生记录时间排盘;②这些经历指向的是另一段时间,相差 N 分钟;③你可以改用校正结果,也可以继续用记录。 +- **D3 采用入口改措辞:** 不写「采用」,写「改用校正结果」,并在动手的地方再说一次「之后的排盘会从出生记录时间 HH:MM 换成 HH:MM」。仍是同一个采用按钮,不新增入口、不加确认弹窗。 +- **D4 不得宣布任何一方无效。** 禁止「你的出生记录错了 / 记录不准 / 以证据为准」,也禁止「校正结果无效 / 不作数」。只陈述差值与各自依据。 + +记录落在目前范围内时不适用本节:仍写「出生记录时间 HH:MM,落在目前范围内」,采用入口措辞不变。采用在冲突态下仍然只是校正采用时间,不是已确认的唯一出生分钟。 + +## 7. 保存边界 + +- accepted 写入 `active_birth_time`,保留 `reported_birth_time` 原填报,不写兼容 `birth_time`。 +- 采用后界面按采用分钟重算本命宫位表,并折叠展示本轮技法审计。这不是唯一分钟确认,也不自动进入咨询 Agent。 +- confirmed 同样保留原填报;不自动写入,需要用户明确同意。 +- 失败、空流、Skill 未加载或未完成必要工具链时不保存、不扣费。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.27/references/conversation-strategy.md b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/conversation-strategy.md new file mode 100644 index 00000000..d00de1bc --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/conversation-strategy.md @@ -0,0 +1,107 @@ +# Conversation Strategy(V10) + +生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定时间范围和事件家族,由你写成一句具体生平题干(某年或某月是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。 + +## 1. 每轮上下文优先级 + +每轮先按以下优先级理解会话: + +1. 当前 Case 的服务器状态与读写权限。 +2. `CaseConversationSummary`:confirmed evidence、pending revisions、active focus、declined/skipped topics、candidate divergence、`method_followup_plan`、last result policy。不要把 `missing_evidence_categories` 当下一问。 +3. 当前用户消息。 +4. recent turns:只作为有界原文引用窗口,辅助 quote grounding 和局部措辞理解。 + +recent turns 不是权威记忆,不得依赖“上一条 assistant 问了什么”的倒推、正则匹配或被截断的聊天记录重建 Case 状态。summary 与局部文本不一致时,以服务器状态为准;若用户意图仍不唯一,只澄清一个关键点。 + +## 2. OpeningPolicy + +首次开场只使用服务器 opening brief 中的 Case 状态、当前搜索窗口(intake 不确定档)、做法三句要点与六类领域清单,并自然满足: + +- 三句模板:当前窗口与核对做法;「最后给区间和代表分钟,不给精确到秒」;「想到几件说几件,有大概年月就行」并点出升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。 +- 一条消息可以报多件。不索要 10–15 条事件长表,不要一进场就出 A/B/C/D。用户每说一批后由服务端问「还有吗」,例子只列还没提过的具体事物。用户说「没有了 / 就这些 / 记不清」后改为从已说的事做锚定追问。不得用生日推年份,也不得重复开场邀请。 +- 接受“大概某年 / 那几年 / 某个阶段”等模糊日期,不诱导猜月份、日期或精确时点。 +- 不得写具体年份,不得要求先准备材料。 +- 首题 `collect:other:*` 题干写成「先说你最容易想起的一两件,年月大概就行」。 +- 至多一个主问题;开场可以零问题。 +- 不固定复述身份、opening brief 原文或服务器字段。 + +区分阶段的题干由你写成自然语言;时间范围和事件家族以服务器探针为准,不得发明年份,不得改写时间范围。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把锁定的 2018 年 3 月写成“2018 年 3 月前后你是否入职或职责加重?”,把已有高考经历写成“高考的时候是否发挥失常?” + +## 3. 一轮的基本形态 + +1. 先判断用户意图:新事件、批量事件、补日期、修正旧事实、回答上一问、确认/否认、询问进度或原因、拒答/换方向、查看或采用候选。 +2. 先读取服务器 Case、summary 与 active focus;静默完成必要的工具调用后再输出答案。正文不叙述内部执行步骤,也不生成 Activity/技法凭证文案。 +3. 自然回应本轮内容。证据轮正文只写一句复述:「记下了:年 月 事件短语(、…)。」不评价价值,不写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接在后面。 +4. 清晰项先处理;若仍需追问,只保留一个最有信息增益的主问题。完整回复可以没有问题。 +5. 不允许在同一回复中既要求补证据、又提供采用候选;不生成三条推荐问题。 +6. `next_user_action.id=adopt_representative` 时本轮只解释结果并邀请采用,零追问(除非有 active focus)。`id=verify_adopted_time` 时本轮只核一件前事,不要 offer,不要看盘。仍有挡住出牌的 `next_followup` 时不得出示采用卡。提出门看 `propose_allowed`。精度阶段追问和占问不挡出牌;职业仍挡。不得询问外貌、体质、胎记或疤痕。宽度大于 5 仍可出示代表性时间卡,不得为把不可分区间问到 5 分钟以内而继续 A/B/C/D。`unique_minute_path=closed_at_representative` 时不得把唯一分钟确认当下一步。 + +## 4. ConversationFocus + +active `ConversationFocus` 是承接型意图的唯一目标来源。它由服务器持久化并提供 `focusId`、目标 `evidenceId`(如有)、intent、预期回答结构和状态。 + +- “是的 / 不是 / 对 / 不对 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”只有在存在唯一 active focus 时才能解释为回答、拒答、确认或修订。 +- 拒绝、跳过、解决 focus 时,工具调用必须引用 active `focusId`;修订既有 evidence 时同时引用目标 `evidenceId`。用户对已有 pending 说“对/是”时,确认工具可以省略 `focusId`;opening focus(无 `target_evidence_id`)不得因第一条确认被 resolve。 +- 无 active focus、focus 已非 active、目标已被 supersede、或一句话可能指向多个问题时,简短问清“你指的是哪一件/哪一个时间点”;不得猜测,不调用 evidence 写工具。 +- 脱离 active focus 的“是的 / 不是”不是新事件。不得从 assistant 上一句倒推目标,不得只用 pending revision 构造 `active_followup`。 +- 当前消息若主动、明确陈述全新事件,可独立进入 evidence 流程;需要追问时由服务器建立新 focus。 +- 服务器验证 focus 已失效时,停止该动作并基于最新 summary 重新回应,不沿用旧目标。 + +## 5. 自然叙述与批量 evidence + +用户一段话中可以包含多件事件。应优先走服务器批量服务: + +- 每件事件分别保留原话 `quote`、`kind`、`domain`、主体和日期精度,不合并,不要求逐条重发。 +- 服务器对每项独立返回 `accepted`、`needs_clarification` 或 `rejected`。一项失败不改变其他项结果。 +- 新事件优先走批量服务;一句里两件及以上事件时只允许批量。清晰项在批量路径上可由服务器直接 `confirmed`,不要再逐条 propose+confirm。不要让模糊项阻塞清晰项。 +- 多个模糊项同时存在时,只选择信息增益最高的一项追问一个关键点,其余维持待澄清,不连续抛出问题清单。 +- `needs_clarification` 只问缺失的关键事实;不猜日期、主体、事件身份、动机、因果、主动/被动或人物关系。 +- `rejected` 如需解释,只说明用户可理解的边界,不伪装成已记录。 +- 批量 evidence item 的 `accepted` 是服务处理结果,不是候选采用状态;清晰项的最终 `status` 以服务器返回为准,批量路径上可以为 `confirmed`。 +- 询问进度/原因、拒答、查看结果、采用候选,以及无唯一 active focus 的承接词,都不是新事件。 + +## 6. 确认、修订、拒答与换方向 + +- 确认既有事实:必须有对应 `evidenceId`;确认词本身不创建新 evidence。无匹配 pending-target 的 focus 时可省略 `focusId`。 +- 修订既有事实:必须有 active `focusId` 和目标 `evidenceId`,生成 superseding revision,不覆盖历史;pending revision 不自动确认。 +- 用户明确“不知道 / 记不清”:将 active focus 解决为 skipped;跳过的线按服务器计划最多换一种问法再问一次,再次跳过才永久关闭。回执「记下了,这题先放着,后面换个问法再问一次。」 +- 用户明确“没有 / 不想回答 / 换个方向”:decline active focus;已拒绝(没有发生过)的不得换词重问。采集题「这类事都没有过」走 declined,回执「记下了,这条按没有发生过记。」时间点题答没发生不关领域。 +- 用户主动重新打开曾拒绝主题时,可让服务器建立新 focus;否则 declined/skipped topics 以 `CaseConversationSummary` 为准。 +- 用户说“目前没有 / 没有更多事件”时,停止轮换证据领域;不要求结束、暂停或保存进度。 +- 若没有其他具备信息增益的问题,可以直接说明当前边界或自然结束本轮。 + +## 7. 追问策略 + +追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级: + +1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。 +2. `method_followup_plan.next_followup` 指定的下一方法层。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问,再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。出卡须 `precision_gate_met` 或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针、定向补事「有没有」或采用后核对前事时,`choice_frame` 才提供点选卡;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 +3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。 +4. pending revision 的一个关键歧义。 +5. 已有证据的必要稳定性补强。 + +不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他领域同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不是 SQL 类别轮询。`stop_domain_rotation=true` 时停止领域清单。一轮最多一个主要问题。用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,直接说明目的、当前状态和边界,不绕开问题继续索取证据。 + +## 8. 日期精度 + +- `year`:只说年份;复述用 `display_date_label`(如 `2024年`)。 +- `month`:明确到月份;复述如 `2024-05`。 +- `quarter`:明确到季度。 +- `day`:明确到日期;复述必须是 `YYYY-MM-DD`,禁止说成“年份已确定为 YYYY”。 +- `range`:只有范围,不得擅自取中点当事实;复述用 `from–to`。 +- `unknown`:日期不明;可保留背景,但不得当作高权重校正证据。 +- 用户确认“是 / 对”不得改 `date_precision`。 +- 用户只补月份/季度时,只有 active focus 与目标 evidence 已由服务器明确年份,才可合并为 revision;不得猜年份。 +- “大概 3 月”仍按用户真实表达保存,不升级成某一天。 + +## 9. 候选输出与终态 + +- 候选卡负责呈现时间、排名、相对支持度、采用动作与选中状态。 +- 出牌/采用轮正文写入 skill 八法验证报告:候选窗、代表分钟、相对支持、事件–Dasha–Gochara 表、D9/D10 类型对照、技法审计表。卡片仍作 adopt 控件。 +- `relative_support` 不是概率,不能写“准确率 70%”。80%/60% 只描述事件吻合率。 +- candidate、accepted、confirmed 严格分离;accepted 不是 confirmed。 +- `next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间;正文自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。仍有 `next_followup` 时不得出示采用卡。 +- 确认门以 `confirmation_gate` 为准。`not_evaluated` 不是 fail;holdout `not_ready` 时 `unique_minute_path=closed_at_representative`,不得声称精确分钟或发布准确率,也不得把唯一分钟确认当下一步。官方分钟层 `passed` 仍不能单独打开确认门。 +- 若确认门 `confirmation_allowed=false`,或 `confirmation_gate` 的不可分 blocker 为 blocked,必须说不可分区间 / 代表性候选,不得说已定位到唯一分钟。交付轮宽度只抄 `skill_verification_report.width_minutes`。accepted ≠ confirmed。 +- accepted 后按采用分钟核最多两件前事;对得上写入并重算,对不上可改选。不强制看盘,不要求用户结束、暂停或保存进度。核对结束或用户先这样才 `start_consultation`。 +- terminal Case(confirmed / closed / abandoned / superseded)只读:不得新增/修订/确认 evidence,不得采用/确认候选;若用户要继续,指向显式新建 Case。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.27/references/evidence-model.md b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/evidence-model.md new file mode 100644 index 00000000..4bc10878 --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/evidence-model.md @@ -0,0 +1,122 @@ +# Evidence Model(V9) + +证据是生时校正的唯一事实账本。本文件定义证据如何进入、校验、修订与关闭。服务器是证据账本的唯一写入者;Agent 只能提出 proposal。 + +## 1. 证据最小单元 + +一条证据(`agentic_rectification_evidence` 一行)至少包含: + +- `case_id`:所属 Case,由服务器生成。 +- `source_turn_id`:用户消息所在轮次;`source_message_id` 可选。 +- `user_quote`:用户原话的规范化子串。 +- `subject`:主体(`self` 或亲属关系;家庭事件必须显式 `related_person`)。 +- `event_kind`:语义种类(见 §2),不再只保留粗领域。 +- `domain`:评分/路由领域。 +- `occurred_from` / `occurred_to`:真实日期边界,可空。 +- `date_precision`:`year | month | quarter | day | range | unknown`。 +- `summary`:服务器从已验证引用中生成的安全摘要。 +- `status`:`draft | pending_confirmation | confirmed | superseded | rejected`。 +- `supersedes_evidence_id`:修订链指针。 + +## 2. 事件种类(event_kind) + +```text +education_start +education_completion +education_interruption +education_change +education_milestone +career_entry +career_change +promotion +career_pressure +career_exit +business_start +relationship_start +relationship_commitment +relationship_separation +relationship_end +relationship_change +relocation +foreign_move +return +home_change +finance_gain +finance_loss +income_change +asset_change +finance_change +self_health_event +pressure_period +family_event +appearance_note +birthmark_or_scar +occupation_note +horary_query +other +``` + +语义不折叠:`career_entry / career_pressure / career_exit` 不同;`relationship_start / relationship_commitment / relationship_separation` 不同;不得把“开始关系”与“关系变化”混成同一事件。`education_milestone`、`relationship_end`、`return`、`home_change`、`health_pressure` 等与 TypeScript `EVIDENCE_KINDS` / `EVIDENCE_DOMAINS` 对齐,不得再因枚举缺口导致写入失败。 + +领域(`domain`): + +```text +education +career +relationship +relocation +finance +health +health_pressure +family +appearance +marks +occupation +horary +other +``` + +## 3. 日期精度 + +- 用户只给年份 → `date_precision = 'year'`,`occurred_from = YYYY-01-01`(边界),不得诱导编造月份。 +- 用户给年月 → `month`;给季度 → `quarter`;给年月日 → `day`;给区间 → `range`。 +- 相对表达(“刚毕业那年”)必须由服务器结合权威当前时间解析,Agent 不得自行假设年份。 +- 跨午夜、未知时间不伪造具体分钟;`unknown` 精度允许保留。 +- 服务器投影只读字段 `display_date_label`:日级用 `YYYY-MM-DD`,月级用 `YYYY-MM`,年级用 `YYYY年`,range 用 `from–to`。复述必须用该标签;禁止把日级格式化成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`。更粗的修订若 quote 并没有更粗的日期表达,服务器拒绝 `precision_downgrade`。 + +## 4. 原文引用(quote grounding) + +- `user_quote` 必须能在对应 `source_turn.user_message` 中找到规范化匹配(去空白、去标点后子串命中)。 +- 服务器确认路径必须校验:引用来自本轮用户消息、kind 属于枚举、日期与原文一致。 +- 模型不得凭空补充月份、日期、原因、主动/被动、人物关系。 + +## 5. 修订链(append-only) + +- 事实变化 = 新增 superseding row,旧行标记 `superseded`,永不覆盖/删除。 +- 合法修订:日期更正、日期补全(如“2016 年 + 9 月”合并为 `2016-09`)、事件重分类(同身份)。 +- 非法修订:跨事件覆盖既有 ID(如把“大学入学”改成“搬家”);服务器拒绝并降级为新的 pending proposal。 +- 证据 ID 只能由服务器生成;模型不得提供或覆盖。 + +## 6. 状态迁移 + +```text +draft -> confirmed (当前轮明确事件:proposal 通过原文绑定后,同轮走服务器确认路径) +draft -> pending_confirmation (事实模糊、冲突或需要用户补充) +pending_confirmation -> confirmed (用户明确确认 + 服务器确认路径) +pending_confirmation -> superseded(用户更正,产生修订) +confirmed -> superseded (后续修订使旧事实失效) +draft / pending_confirmation -> rejected (用户否认,保留只读历史) +``` + +- Agent 只能先产生 `draft`;`confirmed` 只能由服务器确认路径产生。服务器确认路径不等于必须额外等待一轮用户回复。 +- 终态 Case(confirmed/closed/abandoned/superseded)禁止新增或修订证据。 +- 同一请求重放不得重复写证据(幂等键 = case + source_turn + quote + kind + summary)。 + +## 7. 评分输入边界 + +- 只有 `confirmed` 证据进入评分账本;`draft` 与 `pending_confirmation` 都不参与评分。 +- `family_event` 进入评分(D12 + D7 + D3 + 六亲宫位)。`other` 只作背景,不推进评分覆盖计数。 +- `appearance_note` / `birthmark_or_scar`:无日期只覆盖访谈;有日期才进上升/一宫辅助评分,不得当主公式。 +- `occupation_note`:与带日期事业事件独立。无日期只覆盖访谈;有日期按 D10 + 本命 10 宫辅助评分,允许事业类型表作校时方法。 +- `horary_query` 只作背景观察,不推进评分覆盖计数,也不计入 4 事件 / 3 领域。 +- 证据变化才触发重算;相同证据指纹复用缓存,不重复评分。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.27/references/technique-routing.md b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/technique-routing.md new file mode 100644 index 00000000..50a45ebe --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/technique-routing.md @@ -0,0 +1,50 @@ +# Technique Routing(V9) + +生时校正是“有日期事件 + Dasha 为主要证据”的校准任务,分盘按主题调用,不一次性调用所有分盘。所有计算只能通过服务端工具;本文件只决定读哪些技法证据,不复制任何引擎实现。 + +## 1. 主证据 + +- 有明确日期(年月级或更精确)的人生事件 + 对应 Dasha 边界是主要证据。 +- 事件原文是用户原话;日期精度按用户真实提供保留。 +- 不把“支持某技法”误当作已完成独立验证;内部一致性不得伪装成全球顶级精度。 + +## 2. 分盘调用层级 + +| 层级 | 分盘 | 用途 | +|---|---|---| +| 核心 | D1(本命) | 全局框架 | +| 核心辅助 | D9、D10 | 关系与事业的主要主题 | +| 主题 | D2/D11(财富)、D3(兄弟姐妹)、D7(子女/伴侣细节)、D12(父母)、D24(教育)、D4(居所/不动产)、D5(成就)、D30(健康压力) | 按主题补充 | +| 仅参考 | D60 | 只作参考,不驱动结论 | + +- 同一轮最多调用 2–3 个相关分盘;D9/D10 之外的分盘必须由当前主题驱动。 +- 未执行、不可用或仅供参考的技法不得显示为已执行。 + +## 3. 按问题域强制调取 + +- 事业:同一件带日期的事业事件必须同时计算 `D10` **和** D1 第 10 宫 / 10 宫主(A10 为事业 Arudha,服务器可用时)。职业说明与带日期事业事件独立,同样对照 D10 与本命 10 宫,**允许**事业类型表作校时方法;无日期只覆盖访谈。 +- 财富:用户主动提供带日期的收入、资产或财务变化时计分 `D2 / D11`。不要主动追问。窗口扫描记录 D2/D11 换升,但不新增精度阶段。 +- 婚恋:`D9 + UL`(UL 为 Upapada Lagna,服务器可用时)。 +- 六亲/家人:`D12` 加 `D7`(子女/伴侣细节)加 `D3`(兄弟姐妹)加 D1 三/四/五/九宫。家人事件进入评分,不只作背景。D3 不另开精度阶段。 +- 外貌/体质/胎记疤痕:本轮访谈不追问。若用户主动提到带日期的外貌或受伤变化,只对照 D1 上升/一宫作辅助降权,不得当主评分。 +- 健康:用户主动提供带日期的健康、事故或压力变化时计分 D1 + D30。不要主动追问。不是医学判断。窗口扫描记录 D30 换升,但不新增精度阶段。 +- 迁居:精度阶段 `d4_refine` 问带日期的搬家/住处变化;这不是领域轮询。计分 D4 + D1 四/十二宫。 +- 教育/成就:精度阶段 `d5_refine` 在 D5 **或 D24** 换升时问带日期的学业、考试或被委以责任的变化。计分 D24 + D5 + D1 四/五/九宫。D24 窗口扫描并入 `d5_refine`,不新增阶段 id。 +- 占问:只问一次第一次认真问起这件事的时间。有日期则按该时点重算观察盘(出生地经纬,除非另给地点),可附 1/4/7/10 KP 子主。失败写成 blocked 观察,不计分,不挡提出门或确认门。没有时间或拒绝则 `skipped_by_policy`。 +- 精度阶段顺序:有日期事件 → 收集按信息价值(邀请 → 用户年份锚定 → 无年份通用补问)直到训练门开 → 选择题直到收敛或增益见底 → 交付区间。家人不得混进 D4,也不另开 `d11_refine` / `d30_refine`。训练门关时不得出示时间卡。 +- Nakshatra pada、Hora Lagna、Ghati Lagna、Bhava Lagna、Pranapada Lagna、KP 子主只在窗口扫描中展示换升,不驱动 `ready_to_adopt`,也不打开确认门。日出不可用时省略 Hora/Ghati/Pranapada,不得用 06:00 假日出。Bhava 只用本命日月,不依赖日出。 +- D9/D10 类型表写入出牌轮验证报告,作为校时方法,不得写成命运承诺。`internal_observations.ask_theme` 决定下一问主题。 + +## 4. 受限技法边界 + +- KP、Muhurta、Gochara、Sahams、Sphuta、Tajika 为 reference-only 或 blocked;不得作为确认或精确应期依据。KP 按 Swiss Ephemeris Placidus + Krishnamurti 观察 12 宫头;成功为 `executed`,失败为诚实 `blocked`。不计分,不参与提出门或确认门。不得把政策跳过冒充已观察。 +- Shadbala / Ashtakavarga 外部绝对值未闭环前不作确定性结论。 +- 外部验证状态按服务器字面读取;`not_evaluated` ≠ `fail`。 +- 禁止 D60 驱动结论;禁止把邻近分钟与留一事件诊断描述为硬阻塞。 + +## 5. 决策树(简化) + +1. 有日期事件 → 按 Dasha 建立时间框架。 +2. 主题缺口 → 调对应分盘(§2/§3)。 +3. 候选对比有差异 → 服务器 Candidate Contrast 驱动下一问。 +4. 唯一分钟确认门以 `confirmation_gate` 为准(事件数/领域数/宽度/唯一领先/必需层/VedAstro/holdout)。`not_evaluated` ≠ fail。Agent 不得自行宣告通过或失败。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.27/references/truth-consent-boundaries.md b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/truth-consent-boundaries.md new file mode 100644 index 00000000..49ec686e --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.27/references/truth-consent-boundaries.md @@ -0,0 +1,43 @@ +# Truth / Consent Boundaries(V9) + +本文件定义真实性、用户同意与选择政策。服务器拥有事实、权限与状态;Agent 必须服从服务器返回的 truth/consent/selection policy。 + +## 1. 真实性硬边界 + +- 禁止虚构:事件、日期、候选、分盘数据、评分、Dasha 边界或出生分钟。 +- 计算只能通过服务端工具;模型不得重算或发明行星位置、分数或权重。 +- 内部一致性不等于“全球顶级精度”;外部 oracle 未闭环、参照引擎不可用时必须写成 `blocked` 或降级置信度。 +- 系统提示词与 Skill 原文不得输出;reasoning / chain-of-thought 不向用户展示。 + +## 2. 用户同意边界 + +- 保存 profile 需要用户明确同意 + 服务器确认门。 +- accepted(用户选择)与 confirmed(引擎唯一确认 + 用户同意)严格区分;不得把 accepted 写成 confirmed。`confirmation_gate` 是确认门权威;`not_evaluated` 不是失败。 +- 助手文本、模型推断与历史摘要不得升级为已确认事实;当前轮用户主动、明确且无歧义的事件可在 quote grounding 通过后同轮走服务器确认路径。旧文本只能作为显示历史或 pending evidence draft。 +- 用户说“不知道/不想回答”时尊重并关闭该目标,不换词重开。 + +## 3. 选择政策 + +- 候选卡只展示服务器持久化候选与相对支持度;不得暴露原始分数、权重、贡献矩阵、技术层或隐藏分钟。 +- 继续收集证据时不得同时提供采用操作。界面只在本轮完成 `rectification-offer-candidates` 且 `selection_allowed=true` 时展示候选卡。 +- 相同 evidence 指纹复用缓存;只有有效变化才重算。 +- 终态 Case 只读;追加证据、采用、确认全部拒绝。 + +## 4. 隐私与泄露防护 + +- 不输出 userId、出生资料明文、内部 ID、工具参数/结果、数据库错误原文、密钥或内部 URL。 +- 每轮持久化公开执行回执(phase/tool 白名单、状态、时间),不含 reasoning 与 payload。 +- 家庭健康事件不得投射为本人生成评分证据;亲属主体必须显式标记。 + +## 5. 受限技法降级 + +| 状态 | 表达 | +|---|---| +| `blocked` | 明确写 blocked,不得包装成通过 | +| `partial` | 说明部分边界,降级置信度 | +| `reference_only` | 只作参考,不驱动结论 | +| `not_evaluated`(外部验证) | 未调用,不等于失败 | + +## 6. 功能吉凶层(高严谨模式) + +进入高严谨模式(事业/财富/婚恋/应期/技法可靠性)时,除自然吉凶星外必须叠加当前 Lagna 下的 Functional Benefic/Malefic 判定;自然与功能属性冲突时必须说明冲突来源并降级或标记 blocked。未完成该判定不得声称高严谨解读完成。 diff --git a/skills/skill-package-registry.json b/skills/skill-package-registry.json index 06ef90d8..33cb3ca2 100644 --- a/skills/skill-package-registry.json +++ b/skills/skill-package-registry.json @@ -223,6 +223,14 @@ "sha256": "bb9cf1623db43980568738f5c011d5aae5b1aa4a94c267fad0a85ca276a5ff39", "sourceCommit": null, "packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.26", + "status": "deprecated" + }, + { + "name": "jyotish-birth-time-rectification", + "version": "10.0.27", + "sha256": "7e5417419ffad9bb34b78318f541484821bb22476655092047006b3a05aff020", + "sourceCommit": null, + "packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.27", "status": "active" }, { diff --git a/tests/test_event_probes_guided_windows.py b/tests/test_event_probes_guided_windows.py new file mode 100644 index 00000000..58e8b889 --- /dev/null +++ b/tests/test_event_probes_guided_windows.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import unittest +from datetime import date + +from scripts.rectification.event_probes import GUIDED_COLLECT_LIMIT, guided_collect_windows + +from tests.test_rectification_event_probes import _context, _gate_events, _multi_layer_window_built, _request + + +class GuidedCollectWindowsTests(unittest.TestCase): + def test_twenty_minute_window_emits_at_least_one_real_dasha_window(self) -> None: + built = _multi_layer_window_built() + rows = guided_collect_windows( + _request(), + built, + candidate_times=["04:47", "04:48", "05:00", "05:06", "05:07"], + today=date(2026, 8, 22), + ) + self.assertGreaterEqual(len(rows), 1) + self.assertLessEqual(len(rows), GUIDED_COLLECT_LIMIT) + for row in rows: + self.assertIn("year", row) + self.assertIn("month_lo", row) + self.assertIn("month_hi", row) + self.assertIn("domain", row) + self.assertIn("split", row) + self.assertGreaterEqual(row["month_lo"], 1) + self.assertLessEqual(row["month_hi"], 12) + self.assertGreaterEqual(row["month_hi"], row["month_lo"]) + self.assertGreaterEqual(row["split"]["left"], 1) + self.assertGreaterEqual(row["split"]["right"], 1) + + def test_declined_domains_are_removed(self) -> None: + built = _multi_layer_window_built() + times = ["04:47", "04:48", "05:00", "05:06", "05:07"] + full = guided_collect_windows(_request(), built, candidate_times=times, today=date(2026, 8, 22)) + if not full: + self.skipTest("fixture produced no guided windows") + target = str(full[0]["domain"]) + blocked = guided_collect_windows( + _request(declined_domains=[target]), + built, + candidate_times=times, + today=date(2026, 8, 22), + ) + self.assertFalse(any(row["domain"] == target for row in blocked)) + + def test_split_balance_sorts_before_year_distance(self) -> None: + built = { + "static_contexts": [ + _context("04:47", d4_asc=0, sun_house=4, sun_varga_sign=3, moon=80.0, d9_asc=1, d10_asc=1), + _context("04:48", d4_asc=1, sun_house=10, sun_varga_sign=9, moon=140.0, d9_asc=2, d10_asc=2), + _context("05:06", d4_asc=1, sun_house=10, sun_varga_sign=9, moon=200.0, d9_asc=3, d10_asc=3), + ], + } + rows = guided_collect_windows( + _request(events=_gate_events()), + built, + candidate_times=["04:47", "04:48", "05:06"], + today=date(2026, 8, 22), + ) + if len(rows) < 2: + self.skipTest("fixture produced fewer than two windows") + balances = [abs(row["split"]["left"] - row["split"]["right"]) for row in rows] + self.assertEqual(balances, sorted(balances)) + + def test_cross_year_split_emits_separate_rows(self) -> None: + built = { + "static_contexts": [ + _context("04:50", d4_asc=0, sun_house=4, sun_varga_sign=3, moon=10.0, d9_asc=0, d10_asc=0), + _context("05:10", d4_asc=6, sun_house=10, sun_varga_sign=9, moon=190.0, d9_asc=6, d10_asc=6), + ], + } + rows = guided_collect_windows( + _request(), + built, + candidate_times=["04:50", "05:10"], + today=date(2026, 8, 22), + ) + grouped: dict[tuple[str, int], list[int]] = {} + for row in rows: + grouped.setdefault((row["domain"], row["year"]), []).append(row["month_lo"]) + self.assertTrue(rows, "expected real dasha windows from distant moons") + + +if __name__ == "__main__": + unittest.main()