From 74949d04753996b2b77d370c62a9d7d38bc04d00 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 12 Oct 2026 13:18:32 +0800 Subject: [PATCH] feat(rectification): open on the nearest chart boundary (BUG-1307) --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 9 +- ...ation-nearest-boundary-opening-20261012.md | 88 +++++++++ docs/tasks/README.md | 2 +- ...ation-nearest-boundary-opening-20261012.md | 52 +++++ frontend/DESIGN.md | 4 +- frontend/docs/VOICE.md | 11 +- .../rectification-agentic/nearest-boundary.ts | 154 +++++++++++++++ .../rectification-agentic/opening-boundary.ts | 115 +++++++++++ .../lib/rectification-agentic/user-copy.ts | 57 +++++- .../v9/agent-route-typed-message.ts | 2 + .../v9/agent-run-attempt.ts | 20 +- .../v9/agent-run-messages.ts | 14 +- .../rectification-agentic/v9/answer-choice.ts | 2 + .../v9/decision-from-dossier.ts | 6 + .../v9/interview-state.ts | 2 + .../v9/method-followup.ts | 47 ++++- frontend/src/mastra/rectification-v9-tools.ts | 2 + ...-nearest-boundary-opening-20261012.test.ts | 138 +++++++++++++ scripts/rectification/nearest_boundary.py | 111 +++++++++++ scripts/rectification_varga_segments_api.py | 70 ++++++- tests/test_rectification_nearest_boundary.py | 185 ++++++++++++++++++ 22 files changed, 1066 insertions(+), 30 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md create mode 100644 docs/testing/rectification-nearest-boundary-opening-20261012.md create mode 100644 frontend/src/lib/rectification-agentic/nearest-boundary.ts create mode 100644 frontend/src/lib/rectification-agentic/opening-boundary.ts create mode 100644 frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts create mode 100644 scripts/rectification/nearest_boundary.py create mode 100644 tests/test_rectification_nearest_boundary.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fdce80..db017cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ - 用户这一轮明确要求做生时校正时,直接打开校正,不先写解读、不用点卡。推得不准时的提议仍走卡片。做不了会用一句话说明,不再沉默。普通对话不承诺收窄出生时间,申报时段不写大运、子运的起止年月日。 +## 2026-10-12 — 生时校正开场说清填的时间离换段有多近(未推送,未上线) + +- 开场两句中间多一句:填的时间离最近会换的地方差几分钟,两边不一样的是父母、工作、住处、婚恋、学业、子女、钱或身体里实际不同的那几件。不到 1 分钟就说不到 1 分钟。这段时间里都不换,就说附近这些盘都不换,先从经历开始。只知道大概时段时不加这一句。 +- 不说秒,不说记录时间错了或不准,不说已确认。先问这几件事的开关默认关,所以这句先不承诺接下来先问。打分、闸门和题库都没改。Skill 仍是 10.0.33。 + ## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余) - 事业判断表里,自然代表星、Jaimini 代表星、大运主星和宫主这几行旁边加了一句:只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。其他领域不加。行和取值没有改。系统提示没有改。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8f8fe456..c3174a82 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17561,15 +17561,18 @@ ## BUG-1307 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明 -- 状态:open(任务书 `docs/tasks/TASK-rectification-nearest-boundary-opening-20261012.md` 待领取) +- 状态:fixed-pending-verify(只上了开场呈现。选题开关默认关:v5 77 例持久回放没有跑,±10 / ±30 真值段保留和 D9、D10 头段命中都没有新数字,红线未核。部署和真机清单完成前不标 resolved) - 首次发现 / 最近更新:2026-10-12 / 2026-10-12 - 来源:产品拿上游 Skill 的一次私人样例校正比较,认为上游步骤短。上游先找记录时间最近的分盘换段点,只问两侧不同的层。 - 现象:我方开场不讲记录时间与分界的关系;`window_scan` 有逐分钟 11 层换段数据,但不以记录时间为锚;记录时间只在交付时与区间比较(`reportedTimeOffsetCopy`)。 - 根因:扫描数据从未以记录时间为锚组织,呈现与选题都面向整个窗口。 - 决策:只改呈现与首轮选题顺序,不改打分、闸门、4 件 3 域;对外不说秒级、不说「记录时间是错的」。 -- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)。 +- 修复:新模块只读 `window_scan` 的换段行,给出记录分钟前后最近的换段、两侧不同的层和前后星座。开场由服务端把这一句放在原来两句中间。申报时段、扫描失败不加这一句。窗口里没有换段,用固定的一句「附近这些盘都不换」。选题开关 `RECTIFICATION_NEAREST_BOUNDARY_PROBES=on` 才把对应领域的题排到增益序前面;默认关,开场因此不说「接下来先问这几件」。2 / 5 / 10 分钟都没有选定。Skill 仍是 10.0.33,没有改 OpeningPolicy 正文。 +- 验证:公开 AA 盘定向测试覆盖段内、紧贴换段、窗口内无换段;分盘接口默认响应不增加这个字段。前端开场单测覆盖这三句和开关默认关。77 例回放未跑。真机清单 `docs/testing/rectification-nearest-boundary-opening-20261012.md` 未走。 +- 防复发:默认开场两句保持原文字;有换段句时服务端整段替换,不靠模型现编。开关关闭时排序函数返回原数组。打分、闸门、4 件 3 域未改。 +- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)、BUG-621(历史 case 仍打开旧 Skill)。 - 复发自:无。 -- 修复版本:未修复。 +- 修复版本:分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送,未部署。进度 `docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md`。 ## BUG-1308 | 父母静态是非题(D12)对出生时间分界两侧有没有区分力未经评测 diff --git a/docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md b/docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md new file mode 100644 index 00000000..1696c0c9 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md @@ -0,0 +1,88 @@ +# PROGRESS · 生时校正开场说最近一次换段(2026-10-12,BUG-1307) + +任务书:`TASK-rectification-nearest-boundary-opening-20261012.md`。分支 `codex/rectification-nearest-boundary-opening-20261012`,基线 `e6baff95`。只 commit,不 push。未部署。 + +## 一句话 + +开场会说填的时间离最近一次换段差几分钟、两边哪几件事不同。2 / 5 / 10 分钟都没有跑 77 例,阈值没有选定,选题开关默认关。打分没改。Skill 仍是 10.0.33。 + +## 阈值 + +没有采用。候选 2 / 5 / 10 分钟都没跑持久回放,不能说哪一档过了红线。 + +代码里的 `NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5` 只在开关打开之后才用,注释写明未采纳。它不是选定结果。 + +开关:环境变量 `RECTIFICATION_NEAREST_BOUNDARY_PROBES` 必须等于 `on` 才改变出题顺序,默认关。关着的时候,开场不说「接下来先问这几件」,避免承诺一件没有做的事。 + +## 77 例回放 + +没跑。持久回放要本地 Postgres 和逐例星历,这一轮不启动。因此红线未核,按任务书让步:只上呈现,选题留开关默认关。没有为了过门改打分。 + +修后基线(`docs/research/varga_resolution_persisted_onoff_after_fix_2026_10_01.json`,段序开,生产默认窗口不超过 61 分钟;`dataset_sha256` `ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a`)这一轮没有重测: + +| 窗口 | 提问数 | D9 头段命中 | D10 头段命中 | 真值段保留 | +| --- | --- | --- | --- | --- | +| ±10 开 | 355 | 71 | 67 | 76 | +| ±30 开 | 418 | 51 | 55 | 76 | + +红线是 ±10 / ±30 真值段保留都不低于 76 / 76,D9、D10 头段命中不低于上表。任一格下降就要停。 + +| 阈值 | 问题数 | ±10 真值段保留 | ±30 真值段保留 | ±10 D9 头段 | ±10 D10 头段 | ±30 D9 头段 | ±30 D10 头段 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 2 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | +| 5 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | +| 10 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | + +公开 AA 盘上傍晚 40 分钟里,离换段最远是 3 分钟。那是一张盘的测量,不是 77 例的门,也不拿来选阈值。 + +## 句数上限 + +| 项 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `isAcceptableOpeningBody` 句数上限 | 3 | 3 | 新句放在原来两句中间,没有放宽上限 | + +没有新句时,默认开场两句与改前逐字相同。 + +## 为什么没有 10.0.34 + +OpeningPolicy 仍要求正文两句大白话,旧版本目录没有改。开场正文由服务端在模型写完后整段换成 `openingSpokenBody`,不靠模型现编,也不靠 Skill 正文。历史 case 仍读 10.0.33(BUG-621)。`RECTIFICATION_SKILL_VERSION` 仍是 `10.0.33`。 + +## 呈现 + +- 新模块 `scripts/rectification/nearest_boundary.py` 只读 `window_scan` 的 `transitions`。真正换段是前后星座不同。展示层和星座没变的行忽略。距离按钟面环形分钟算,一样近时留更早的钟点。不改 `window_scan` 的返回形状。 +- 开场扫描走现有 `POST /api/rectification/v5/varga-segments`。`target_charts` 仍是调用方要的盘;只有带了 `boundary_charts` 和 `recorded_time` 才多一个 `nearest_boundary`。默认缓存键不变。`minutes.signs` 仍只含请求的目标盘。D12 仍不能当目标盘。 +- 人话对照:婚恋、工作、住处、学业、子女、父母、钱、身体。本命上升不说成一件事。客户端只用层名白名单重写主题,不采用接口里的主题字符串。 +- 申报方式 `period_only`、没有记录钟点、或扫描失败:原来两句,不挡开场。 +- 窗口内没有换段:`你填的时间附近这些盘都不换,先从经历开始。` +- 有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 或 `差 N 分钟`。不足 1 分钟说「不到 1 分钟」。 + +## 选题开关 + +打开之后,现有增益排序不变,再把领域落在这条分界上的题排到前面,其余保持刚才的顺序。不新增题型,不改分数。问完这些再接原来的顺序。`period_only`、没有记录钟点、找不到换段、距离大于未采纳的 5 分钟、或没有领域时,不改顺序。 + +开关关闭时,排序函数返回原来的数组,同输入同分、同顺序。 + +## 没改的 + +`decision_policy.py`、闸门常量、4 件 3 域、采用门、交付卡档位、`window_scan` 输出、`page.tsx`、`use-rectification-surface.ts`、咨询入口三个文件、`scripts/jyotish_api_server.py`、数据库、`.gitea`、BUG-1306、BUG-1308、Skill 10.0.33 正文。Home() 状态数没增加。 + +## 测试 + +Python 用 Anaconda `C:\Users\74082\anaconda3\python.exe`,`PYTHONHASHSEED=0`。本仓没有 `.venv`,没有往仓库里 pip install。 + +| 项 | 结果 | +| --- | --- | +| `tests/test_rectification_nearest_boundary.py` | 4 项通过。公开 AA 盘覆盖段内、紧贴换段、窗口内无换段。默认分盘响应没有 `nearest_boundary`;带上新字段后与 `window_scan` 的最近换段一致 | +| `tsc --noEmit` | 通过,退出码 0 | +| `npm run lint` | 0 error,125 条 warning,都在原有文件上 | +| 开场 / 文案合同 | `rectification-nearest-boundary-opening-20261012.test.ts`、`rectification-opening-plain-20260926.test.ts`、`agent-voice-copy-contract.test.ts` 共 31 项通过 | +| 快速门 `--profile quick` | 编译、JSON、能力清单、片段审计、解释源清单、BPHS 不变量通过。pytest 1067 passed、1 skipped、1 failed。失败的是 `tests/test_rectification_v5_services.py` 里全天 block_scan 必须在 15 秒内完成,整门里用了 38.0 秒。这个文件没改。单独再跑通过。算这台机器在长套件里的负载,不算本单回归。门禁因此停在 pytest,没有接着跑它自己的 npm test / lint / build | +| `CI=1` `npm test` | 4915 项,通过 4774,失败 100,cancelled 0。失败都落在没改过的文件上,见下 | + +这 100 条是 Windows 基线,不是本单回归:没有 Docker 起不来 Postgres;没有创建符号链接的权限(Skill 运行时别名 `EPERM`);没有 WSL 的 bash;部分路径被拼成 `G:\G:\...` 因而找不到目录;迁移文件在这台机器上读出来的是路径而不是正文;一条打字拒绝测试的子进程退出码是 null(超时或被杀掉),没有进到本单改过的出题参数。本单新增的开场测试在这 4774 条里面。 + +`next build`:失败。Turbopack 报 `frontend/node_modules` 这个 junction 指到文件系统根外面。这是已知环境缺口,没有改 Next 配置,也没有删 junction。首屏 gzip 和 `/` 是否仍为 Static 因此没有在这台机器上量。 + +## 真机 + +`docs/testing/rectification-nearest-boundary-opening-20261012.md`。公开盘两档:19:01 是「不到 1 分钟」,19:04 是「差 3 分钟」。公开记录 19:15 在默认前后 15 分钟窗口里是「差 2 分钟」。窗口内不换那一句,界面选不出那么窄的窗,由自动化测试覆盖。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 33129382..6bc8ac7b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -125,7 +125,7 @@ | `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 已合入(无独立验收记录) | `1b508d5e`(BUG-920/921 resolved;已部署 staging,run 2715);真机待核 | | `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 | | `TASK-rectification-explicit-entry-20261012.md` | `PROGRESS-rectification-explicit-entry-20261012.md` | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 已部署 staging `b8bdc003`,真机欠 | — | -| `TASK-rectification-nearest-boundary-opening-20261012.md` | — | **最近分界开场(BUG-1307)**:开场说记录时间离最近分盘换段点多近、两侧哪些层不同,近分界时首轮只问该分界;不改打分 / 闸门 / 4 件 3 域,77 例回放不降;改 page.tsx / 校正开场组件的部分须在 BUG-1306 合入后 | 待领取 | — | +| `TASK-rectification-nearest-boundary-opening-20261012.md` | `PROGRESS-rectification-nearest-boundary-opening-20261012.md` | **最近分界开场(BUG-1307)**:开场说记录时间离最近换段多近、两侧哪些事不同。选题开关默认关(77 例未跑)。不改打分 / 闸门 / 4 件 3 域。未改 page.tsx | 待验收 | 分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送 | | `TASK-rectification-d12-parent-probes-research-20261012.md` | — | **父母静态是非题(D12)研究(BUG-1308)**:公开案例 ≥60、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 待领取 | — | | `TASK-rectification-1012-batch-fix-20261012.md` | `PROGRESS-rectification-offer-opened-state-20261012.md` | **10-12 三单验收修复**:entry `6a799582` 通过(合入待产品);boundary `b285011a` 未通过——F1 开场与段汇总岁差写死 Raman(BUG-1307 / 新 BUG-1309)、F2 本命上升换段不说;F3 提议在开案成功后才标 opened(BUG-1306 跟进,本分支待验收);F4 研究报告补编码问题与暂停结论(BUG-1308) | F3 待验收(`codex/rectification-offer-opened-state-20261012`);F1/F2/F4 仍待领取 | — | diff --git a/docs/testing/rectification-nearest-boundary-opening-20261012.md b/docs/testing/rectification-nearest-boundary-opening-20261012.md new file mode 100644 index 00000000..14bc8766 --- /dev/null +++ b/docs/testing/rectification-nearest-boundary-opening-20261012.md @@ -0,0 +1,52 @@ +# 生时校正:开场说最近一次换段 · 真机清单(2026-10-12,BUG-1307) + +用公开盘,不要用真实用户的出生资料。盘是 Steve Jobs,1955-02-24,旧金山(纬度 37.77、经度 -122.42、西八区)。下面的钟点是为了走两种开场故意填的,不是在改这份公开记录。 + +校正开场按这张盘的候选窗口扫描,岁差固定 Raman、交点固定平均。星盘页如果选了别的岁差,这一句仍按 Raman 算。出生证界面写「前后 2 分钟」,校正实际开窗是前后 15 分钟。开场里「现在先在哪一段之间找」就是这个窗口。 + +选题开关默认关。中间那句说到句号为止,不出现「接下来先问这几件」。第一题仍按原来的顺序,不会因为提到学业或父母就提前。 + +扫描接口没连上时,开场仍是原来的两句。那是退路。先确认校正服务可用,再照下面走。 + +## 1. 紧贴换段 + +1. 新建这个人,申报方式选「有出生证或医院记录,精确到分钟」,钟点填 19:01。 +2. 明确说要做生时校正,进入校正。 +3. 开场正文三句。第一句里的窗口是 18:46–19:16。中间这一句逐字是: + + 你填的时间离最近会换的地方不到 1 分钟,两边不一样的是学业、父母。 + +4. 不应出现:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认、接下来先问这几件。 +5. 接着答一两件带年月的经历。下一题按原来的收集顺序来,不因为这句提到学业、父母就被换掉。 + +## 2. 这张盘上离得最远的一档 + +同一人,钟点改成 19:04,申报仍用出生证(窗口同样是前后 15 分钟,18:49–19:19)。「家人记得大概时间」再选「差不多准」也是这 15 分钟。 + +中间这一句逐字是: + +你填的时间离最近会换的地方差 3 分钟,两边不一样的是学业、父母。 + +这张盘在这段傍晚里,离换段最远就是 3 分钟。这里的「远离」是量到的最大间隔,不是一个很宽的空档。 + +## 3. 顺手核对公开记录钟点 + +钟点用公开记录 19:15,出生证或「差不多准」都是 19:00–19:30。中间这一句逐字是: + +你填的时间离最近会换的地方差 2 分钟,两边不一样的是婚恋、住处、学业、子女、父母、钱、身体。 + +没有「工作」。本命上升也换,这句话不提。 + +## 4. 只知道大概时段 + +申报方式选「只知道大概时段,或完全不知道」。开场不加中间那句,仍是原来两句:先说在哪一段里找,再说你讲几件大事和大概年月。 + +## 5. 窗口里看不到换段 + +这句话是: + +你填的时间附近这些盘都不换,先从经历开始。 + +校正开窗至少前后 15 分钟,而这张盘几分钟就换一次,界面上选不出这么窄的窗口。这一句由自动化测试覆盖:只扫描 19:01–19:06、记录钟点 19:04,结果是窗口内没有换段,文案就是上面这一句。 + +有任何一步和上面不一样,记下是第几步,以及开场实际写了什么。不要把真实用户的出生时间写进记录。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 7f0d8cfd..8c755c07 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -64,7 +64,9 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J ## 校正开场与步骤回执(2026-09-26,BUG-1049 / BUG-1050) -开场一轮只有两段字:正文两句讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。 +开场一轮只有两段字:正文讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。 + +2026-10-12(BUG-1307):服务端已经量过换段时,正文在原来两句中间多一句,说明填的时间离最近会换的地方差几分钟、两边哪几件事不同。没有新组件、没有新动效。只知道大概时段、这段时间里不换、或扫描没做成时,口径见 VOICE 同一节。选题顺序的开关默认关,开场不承诺「接下来先问这几件」。 > 2026-10-01 修订:校正回复结算后不再显示步骤回执(BUG-1137),本段只适用于写作中的步骤行。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 03788155..cd2021bf 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -135,6 +135,15 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 题干里的「2015 年夏天换了工作」是固定示例,不是从生日推出来的;「不得用生日推年份」照旧适用于其他题干。 - 开场不再说「最后给区间和代表分钟,不给精确到秒」。「这只是代表性候选,不是已确认的唯一出生分钟。」仍写在交付卡上。 +开场可以在这两句中间再加一句(2026-10-12,BUG-1307)。句数上限仍是 3,没有放宽。这一句由服务端按扫描结果原样放进去,模型不现编: + +- 记录了钟点,而且这段时间里有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 不足 1 分钟写「不到 1 分钟」,满 1 分钟写「差 N 分钟」。两边只用这些词:婚恋、工作、住处、学业、子女、父母、钱、身体。没有对应的事,就不写「两边不一样」。 +- 选题开关默认关。关着的时候这一句到句号为止,不说「接下来先问这几件」。开关打开、离得近、又有这几件事时,句号才换成「,接下来先问这几件。」 +- 这段时间里都不换:`你填的时间附近这些盘都不换,先从经历开始。` +- 只知道大概时段,或扫描没做成:不加这一句,仍是原来两句。 + +这一句不用:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认。 + 回答上方的步骤名(完成 / 进行中)一律大白话,同一个名字只出现一次: | 工具 | 完成名(旧 → 新) | 进行中句(新) | @@ -300,7 +309,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 坏 | 好 | 为什么 | |---|---|---| | Rahu 大运为 [具体时间已省略] 至 [具体时间已省略]。 | Rahu 大运为 2013 年 11 月中下旬至 2031 年 11 月中下旬。按范围看,不写成单一分钟。 | 出生范围用户要拿到区间说法;算出来的边界不是要删的字。 | -| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。) | 开场正文两句大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。 | +| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。)服务端量到换段时,可以在这两句中间加一句,例如「你填的时间离最近会换的地方差 5 分钟,两边不一样的是父母、工作。」 | 开场正文用大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。2026-10-12 起中间那句仍算在三句里面,不说秒,开关关着时不说接下来先问(BUG-1307)。 | | (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 | | (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 | | (服务端探针 2019 入职)2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 | diff --git a/frontend/src/lib/rectification-agentic/nearest-boundary.ts b/frontend/src/lib/rectification-agentic/nearest-boundary.ts new file mode 100644 index 00000000..5c9e1afc --- /dev/null +++ b/frontend/src/lib/rectification-agentic/nearest-boundary.ts @@ -0,0 +1,154 @@ +/** + * Nearest divisional-chart change, from window-scan transitions only. + * Question order is a switch and stays off unless the env flag is "on". + */ + +export const CHART_LAYERS = ["d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1"] as const; + +const LAYER_DOMAIN: Readonly> = { + d9: "relationship", + d10: "career", + d4: "relocation", + d5: "education", + d24: "education", + d7: "family", + d12: "family", + d2: "finance", + d11: "finance", + d30: "health_pressure", +}; + +const LAYER_TOPIC: Readonly> = { + d9: "婚恋", + d10: "工作", + d4: "住处", + d5: "学业", + d24: "学业", + d7: "子女", + d12: "父母", + d2: "钱", + d11: "钱", + d30: "身体", +}; + +/** Not adopted. The 77-case gate was not cleared, so the switch stays off. */ +export const NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5; + +export type BoundaryLayer = Readonly<{ + layer: string; + fromSign: string; + toSign: string; + topic: string | null; + domain: string | null; +}>; + +export type NearestBoundary = Readonly<{ + found: boolean; + reason: "unavailable" | "no_transition" | null; + at: string | null; + distanceMinutes: number | null; + layers: readonly BoundaryLayer[]; + topics: readonly string[]; + domains: readonly string[]; +}>; + +const EMPTY: NearestBoundary = { + found: false, reason: "unavailable", at: null, distanceMinutes: null, layers: [], topics: [], domains: [], +}; + +function clock(value: string | null | undefined): number | null { + const raw = value?.trim().slice(0, 5) ?? ""; + if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(raw)) return null; + return Number(raw.slice(0, 2)) * 60 + Number(raw.slice(3, 5)); +} + +function distance(left: number, right: number): number { + const span = Math.abs(left - right); + return Math.min(span, 1440 - span); +} + +export function nearestBoundaryFromTransitions( + transitions: readonly Readonly<{ layer?: string; at?: string; from_sign?: string; to_sign?: string }>[] | null | undefined, + recordedTime: string | null | undefined, +): NearestBoundary { + const recorded = clock(recordedTime); + if (recorded === null || !transitions) return EMPTY; + const changes: { at: number; layer: string; fromSign: string; toSign: string }[] = []; + for (const row of transitions) { + const layer = row.layer ?? ""; + if (!CHART_LAYERS.includes(layer as (typeof CHART_LAYERS)[number])) continue; + const before = row.from_sign; + const after = row.to_sign; + if (!before || !after || before === after) continue; + const at = clock(row.at); + if (at === null) continue; + changes.push({ at, layer, fromSign: before, toSign: after }); + } + if (!changes.length) return { ...EMPTY, reason: "no_transition" }; + const bestAt = changes.reduce((best, row) => { + const next = distance(row.at, recorded); + const current = distance(best, recorded); + return next < current || (next === current && row.at < best) ? row.at : best; + }, changes[0]!.at); + const layers: BoundaryLayer[] = []; + const seen = new Set(); + for (const layer of CHART_LAYERS) { + const row = changes.find((item) => item.at === bestAt && item.layer === layer); + if (!row || seen.has(layer)) continue; + seen.add(layer); + layers.push({ + layer, + fromSign: row.fromSign, + toSign: row.toSign, + topic: LAYER_TOPIC[layer] ?? null, + domain: LAYER_DOMAIN[layer] ?? null, + }); + } + const topics = topicsForLayers(layers.map((row) => row.layer)); + const domains: string[] = []; + for (const row of layers) { + if (row.domain && !domains.includes(row.domain)) domains.push(row.domain); + } + return { + found: true, + reason: null, + at: `${String(Math.floor(bestAt / 60)).padStart(2, "0")}:${String(bestAt % 60).padStart(2, "0")}`, + distanceMinutes: distance(bestAt, recorded), + layers, + topics, + domains, + }; +} + +/** Spoken topics, in chart order. Unknown layers and D1 add nothing. */ +export function topicsForLayers(layers: readonly string[]): string[] { + const topics: string[] = []; + for (const layer of CHART_LAYERS) { + if (!layers.includes(layer)) continue; + const topic = LAYER_TOPIC[layer]; + if (topic && !topics.includes(topic)) topics.push(topic); + } + return topics; +} + +export function nearestBoundaryProbesEnabled(flag: string | undefined = process.env.RECTIFICATION_NEAREST_BOUNDARY_PROBES): boolean { + return flag === "on"; +} + +/** Boundary-domain questions first; everything else keeps its incoming order. */ +export function orderByBoundaryDomains( + probes: readonly T[], + domains: readonly string[], + domainOf: (probe: T) => string, +): readonly T[] { + if (!domains.length || probes.length < 2) return probes; + const wanted = new Set(domains); + const first: T[] = []; + const rest: T[] = []; + for (const probe of probes) { + const domain = domainOf(probe); + (domain && wanted.has(domain) ? first : rest).push(probe); + } + if (!first.length || !rest.length) return probes; + return [...first, ...rest]; +} diff --git a/frontend/src/lib/rectification-agentic/opening-boundary.ts b/frontend/src/lib/rectification-agentic/opening-boundary.ts new file mode 100644 index 00000000..b1819668 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/opening-boundary.ts @@ -0,0 +1,115 @@ +/** + * Opening scan for the nearest chart change. Failures keep the original + * two-sentence opening. Question order stays on its own switch. + */ +import { requestCandidateIntervals, type DatedCandidateRange } from "./core/candidate-window.ts"; +import { + CHART_LAYERS, + NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES, + nearestBoundaryProbesEnabled, + topicsForLayers, +} from "./nearest-boundary.ts"; +import type { OpeningBoundaryFacts } from "./user-copy.ts"; + +const BOUNDARY_CHARTS = ["D1", "D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30"] as const; +const LAYERS = new Set(CHART_LAYERS); + +export function openingFactsFromBoundaryPayload( + payload: unknown, + askFirst: boolean, +): OpeningBoundaryFacts & { at?: string | null } { + if (!payload || typeof payload !== "object") return { kind: "unavailable" }; + const row = payload as { found?: unknown; reason?: unknown; distance_minutes?: unknown; layers?: unknown }; + if (row.found !== true) return row.reason === "no_transition" ? { kind: "stable" } : { kind: "unavailable" }; + const distance = row.distance_minutes; + if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0 || distance > 720) { + return { kind: "unavailable" }; + } + const layers = Array.isArray(row.layers) + ? row.layers.flatMap((item) => { + const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null; + return typeof layer === "string" && LAYERS.has(layer) ? [layer] : []; + }) + : []; + const topics = topicsForLayers(layers); + const at = (payload as { at?: unknown }).at; + return { + kind: "near", + distanceMinutes: distance, + topics, + ...(typeof at === "string" ? { at: at.slice(0, 5) } : {}), + askFirst: askFirst && topics.length > 0 && distance <= NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES, + }; +} + +function preferBoundary( + left: OpeningBoundaryFacts & { at?: string | null }, + right: OpeningBoundaryFacts & { at?: string | null }, +): OpeningBoundaryFacts & { at?: string | null } { + if (left.kind !== "near") return right.kind === "near" ? right : left; + if (right.kind !== "near") return left; + const distance = (left.distanceMinutes ?? 9999) - (right.distanceMinutes ?? 9999); + if (distance !== 0) return distance < 0 ? left : right; + return (left.at ?? "99:99") <= (right.at ?? "99:99") ? left : right; +} + +export async function fetchOpeningBoundary(input: { + snapshot: Readonly>; + range: DatedCandidateRange; + recordedTime: string; +}): Promise { + const recorded = input.recordedTime.trim().slice(0, 5); + if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(recorded)) return { kind: "unavailable" }; + const latitude = input.snapshot.latitude; + const longitude = input.snapshot.longitude; + const timezone = input.snapshot.timezone_offset; + if (typeof latitude !== "number" || typeof longitude !== "number" || typeof timezone !== "number") { + return { kind: "unavailable" }; + } + const intervals = requestCandidateIntervals(input.snapshot, input.range); + const askFirst = nearestBoundaryProbesEnabled(); + let best: OpeningBoundaryFacts = { kind: "unavailable" }; + for (const interval of intervals) { + const response = await fetch(`${process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"}/api/rectification/v5/varga-segments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(60000), + body: JSON.stringify({ + start_at: interval.start_at, + end_at: interval.end_at, + latitude, + longitude, + timezone_offset: timezone, + target_charts: ["D1"], + boundary_charts: BOUNDARY_CHARTS, + recorded_time: recorded, + }), + }); + if (!response.ok) return { kind: "unavailable" }; + const data = await response.json() as { nearest_boundary?: unknown }; + best = preferBoundary(best, openingFactsFromBoundaryPayload(data.nearest_boundary, askFirst)); + } + return best; +} + +export async function resolveOpeningBoundaryFacts(input: { + birthTimeSource?: string | null; + reportedBirthTime?: string | null; + candidateRange?: { start_time?: string | null; end_time?: string | null } | null; + loadCompute: () => Promise<{ baselineBirthSnapshot: Readonly>; candidateRange: DatedCandidateRange }>; +}): Promise { + if (input.birthTimeSource === "period_only") return { kind: "period_only" }; + const reported = input.reportedBirthTime?.trim() ?? ""; + const range = input.candidateRange; + if (!input.birthTimeSource || !reported || !range?.start_time || !range.end_time) return { kind: "unavailable" }; + try { + const compute = await input.loadCompute(); + return await fetchOpeningBoundary({ + snapshot: compute.baselineBirthSnapshot, + range: compute.candidateRange, + recordedTime: reported, + }); + } catch { + return { kind: "unavailable" }; + } +} diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index f0a8e521..09ab9770 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -76,19 +76,49 @@ export function withFirstDatedCollectInvite(stem: string): string { * Opening body in plain words (BUG-1049, product 2026-09-26): what we are doing * and how, no question, no years, no jargon. The examples live in the stem * right below it, so the body does not list them again. + * + * BUG-1307: when the server already measured a chart change, one extra sentence + * sits between those two. The 3-sentence cap is unchanged. */ -export function openingSpokenBody(range: readonly [string, string] | null, productDelivery?: ProductDelivery | null): string { +export type OpeningBoundaryFacts = Readonly<{ + kind: "period_only" | "unavailable" | "stable" | "near"; + distanceMinutes?: number | null; + topics?: readonly string[]; + /** Only when the question-order switch is on and this boundary is inside it. */ + askFirst?: boolean; +}>; + +const OPENING_BOUNDARY_TOPICS = ["婚恋", "工作", "住处", "学业", "子女", "父母", "钱", "身体"] as const; + +export function openingBoundarySentence(facts: OpeningBoundaryFacts | null | undefined): string | null { + if (!facts || facts.kind === "period_only" || facts.kind === "unavailable") return null; + if (facts.kind === "stable") return "你填的时间附近这些盘都不换,先从经历开始。"; + const distance = facts.distanceMinutes; + if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0) return null; + const topics: string[] = []; + for (const topic of facts.topics ?? []) { + if ((OPENING_BOUNDARY_TOPICS as readonly string[]).includes(topic) && !topics.includes(topic)) topics.push(topic); + } + const place = distance < 1 ? "不到 1 分钟" : `差 ${distance} 分钟`; + const sides = topics.length ? `,两边不一样的是${topics.join("、")}` : ""; + const tail = facts.askFirst && topics.length ? ",接下来先问这几件。" : "。"; + return `你填的时间离最近会换的地方${place}${sides}${tail}`; +} + +export function openingSpokenBody( + range: readonly [string, string] | null, + productDelivery?: ProductDelivery | null, + facts?: OpeningBoundaryFacts | null, +): string { const window = formatClockRange(range); - if (productDelivery) return [ - `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`, - "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。", - ].join(""); - return [ - window + const head = productDelivery + ? `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。` + : window ? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。` - : "我们来把你的出生时间缩小到更准的范围。", - "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。", - ].join(""); + : "我们来把你的出生时间缩小到更准的范围。"; + const tail = "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"; + const extra = openingBoundarySentence(facts); + return extra ? `${head}${extra}${tail}` : `${head}${tail}`; } /** @@ -119,12 +149,15 @@ export function isAcceptableOpeningBody( body: string, range: readonly [string, string] | null = null, productDelivery?: ProductDelivery | null, + facts?: OpeningBoundaryFacts | null, ): boolean { const spoken = body.trim(); if (productDelivery && (!spoken.includes("用来解读的星盘") || /缩小|更准的范围|最可能|已确认/.test(spoken))) return false; if (!spoken || OPENING_YEAR.test(spoken)) return false; if (/[??]/.test(spoken)) return false; if (OPENING_BODY_JARGON.some((word) => spoken.includes(word))) return false; + const required = openingBoundarySentence(facts); + if (required && !spoken.includes(required)) return false; const sentences = splitAfterSentencePunctuation(spoken, "。!?").map((part) => part.trim()).filter(Boolean); if (sentences.length === 0 || sentences.length > 3) return false; if (!spoken.includes("星盘") || !spoken.includes("年月")) return false; @@ -653,6 +686,10 @@ export function listUserVisibleCopy(): string[] { FIRST_DATED_COLLECT_INVITE, GENERIC_COLLECT_QUESTION, openingSpokenBody(["04:45", "05:15"]), + openingSpokenBody(["04:45", "05:15"], null, { kind: "near", distanceMinutes: 0, topics: ["父母", "工作"] }), + openingSpokenBody(["04:45", "05:15"], null, { kind: "near", distanceMinutes: 5, topics: ["婚恋"], askFirst: true }), + openingSpokenBody(["04:45", "05:15"], null, { kind: "stable" }), + openingSpokenBody(["04:45", "05:15"], null, { kind: "period_only" }), RECTIFICATION_USER_COPY.uncertaintyStop, RECTIFICATION_USER_COPY.tiedFirstStop, RECTIFICATION_USER_COPY.probePoolExhaustedStop, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts index ea7920de..cf8edf39 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts @@ -292,6 +292,8 @@ export async function handleRectificationTypedMessage( ...catalog, birthDate, candidatesSeparated: false, + reportedTime: dossier.case.reportedBirthTime, + birthTimeSource: dossier.case.birthTimeSource, }); const persisted = await persistServerOwnedFocus({ accounting, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts index e316373d..235fd52f 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts @@ -10,7 +10,7 @@ * enclosing `lastAttemptError`). */ import { resolveRectificationStepBudget } from "./step-budget"; -import { insertV9SkillRunReceipt, loadV9CaseDossier, type V9CaseDossier } from "./tool-service"; +import { insertV9SkillRunReceipt, loadV9CaseCompute, loadV9CaseDossier, type V9CaseDossier } from "./tool-service"; import { loadCaseDecisionInputs } from "./decision-inputs.ts"; import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt"; import { agentGenerationSettings, promptCacheUsage } from "../../agent-generation-settings.ts"; @@ -20,10 +20,13 @@ import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; import { RECTIFICATION_USER_COPY, isAcceptableOpeningBody, + openingBoundarySentence, openingRangeFromCandidateRange, openingSpokenBody, stripRangeProgressClaims, + type OpeningBoundaryFacts, } from "../user-copy"; +import { resolveOpeningBoundaryFacts } from "../opening-boundary.ts"; import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../rectification-run-budget.ts"; import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt"; import { focusSpokenPrompt } from "./turn-question"; @@ -120,6 +123,15 @@ export async function streamV9Attempt( return failedAttempt(attemptId, "skill_not_loaded"); } + let openingFacts: OpeningBoundaryFacts | null = null; + if (action === "opening") { + openingFacts = await resolveOpeningBoundaryFacts({ + birthTimeSource: dossier.case.birthTimeSource, + reportedBirthTime: dossier.case.reportedBirthTime, + candidateRange: dossier.case.candidateRange, + loadCompute: () => loadV9CaseCompute(accounting, userId, caseId), + }); + } const messages = buildAgentMessages( options, attemptNumber, @@ -127,6 +139,7 @@ export async function streamV9Attempt( skillInstructions, birthTimeClue, previousErrorCode, + openingBoundarySentence(openingFacts), ); const maxSteps = resolveRectificationStepBudget(action); const abortController = new AbortController(); @@ -613,8 +626,9 @@ export async function streamV9Attempt( if (action === "opening") { const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange); const productDelivery = productDeliveryForDomain(latestDossier.case.rectificationDomain); - if (!isAcceptableOpeningBody(answerText, openingRange, productDelivery)) { - answerText = openingSpokenBody(openingRange, productDelivery); + const required = openingBoundarySentence(openingFacts); + if (required || !isAcceptableOpeningBody(answerText, openingRange, productDelivery, openingFacts)) { + answerText = openingSpokenBody(openingRange, productDelivery, openingFacts); } } const latestInference = previousInferenceFromReceipt( diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts index dca8402b..d4c685bf 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts @@ -20,7 +20,11 @@ function clockWindow(range: { start_time?: string | null; end_time?: string | nu return start && end ? `${start}–${end}` : null; } -export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string { +export function buildOpeningBrief( + dossier: V9CaseDossier, + birthTimeClue?: string | null, + boundarySentence?: string | null, +): string { const confirmed = dossier.evidence.filter((item) => item.status === "confirmed"); const pending = dossier.evidence.filter((item) => item.status === "draft" || item.status === "pending_confirmation"); const domains = [...new Set(confirmed.map((item) => item.domain))].slice(0, 6); @@ -36,6 +40,9 @@ export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string const clue = typeof birthTimeClue === "string" && birthTimeClue.trim() ? birthTimeClue.trim() : ""; + const boundary = typeof boundarySentence === "string" && boundarySentence.trim() + ? boundarySentence.trim() + : ""; return [ "【服务端 opening brief】", `Case 状态:${dossier.case.status}。`, @@ -45,7 +52,7 @@ export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string ...(clue ? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`] : []), - `做法要点:正文两句大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`, + `做法要点:正文两句大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」${boundary ? `服务端另有一句,原样放在这两句之间:「${boundary}」` : ""}正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`, ].join("\n"); } @@ -56,6 +63,7 @@ export function buildAgentMessages( skillInstructions: string, birthTimeClue: string | null = null, previousErrorCode: string | null = null, + boundarySentence: string | null = null, ): unknown[] { const timeContext = options.timeContext ?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; @@ -76,7 +84,7 @@ export function buildAgentMessages( if (options.action === "opening") { return [bootstrap, { role: "user", - content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue)].join("\n"), + content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue, boundarySentence)].join("\n"), }]; } return [bootstrap, { diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index ba0e1e9e..f2356da0 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -1094,6 +1094,7 @@ export async function persistNextInterviewAfterChoice(input: { reportedBirthTime: liveDossier.case.reportedBirthTime, candidateRange: liveDossier.case.candidateRange, }), + birthTimeSource: liveDossier.case.birthTimeSource, }; const roundFollowup = input.continueTieBreakRound === true ? tieBreakPersonalityFollowup(planInput) @@ -1903,6 +1904,7 @@ export async function persistNextInterviewIfIdle(input: { reportedBirthTime: dossier.case.reportedBirthTime, candidateRange: dossier.case.candidateRange, }), + birthTimeSource: dossier.case.birthTimeSource, }); const followup = interviewToPersist(plan); if (shouldSkipFollowupPersist({ 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 1e02ba0c..7f62d6ba 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -723,6 +723,8 @@ function discriminatorProbeIfFollowupCanAsk(input: { ...(input.contrastPacket ? { contrastPacket: input.contrastPacket } : {}), ...(input.askedKeys ? { askedProbeKeys: input.askedKeys } : {}), ...(input.birthDate ? { birthDate: input.birthDate } : {}), + reportedTime: input.dossier.case.reportedBirthTime, + birthTimeSource: input.dossier.case.birthTimeSource, candidatesSeparated: dossierCandidatesSeparated(input.dossier), }); const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes); @@ -925,6 +927,8 @@ export function decideFromDossier( remainingCandidateCount: catalog.remainingCandidateCount, remainingCredibleRange: catalog.remainingCredibleRange, guidedWindows: catalog.guidedCollectWindows, + windowScan: catalog.windowScan, + birthTimeSource: dossier.case.birthTimeSource, ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, @@ -1054,6 +1058,8 @@ export function decideAfterInferenceChange(input: { remainingCandidateCount: catalog.remainingCandidateCount, remainingCredibleRange: catalog.remainingCredibleRange, guidedWindows: catalog.guidedCollectWindows, + windowScan: catalog.windowScan, + birthTimeSource: input.dossier.case.birthTimeSource, ...followupCaseArgs({ stage: input.dossier.case.stage, blockScan: input.dossier.case.blockScan, diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index 024b2c6c..564b2c5b 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -151,6 +151,7 @@ export function stepStateFromCaseDossier( accepted: Boolean(dossier.case.acceptedTime), candidatesSeparated: decision.separation.sufficient, holdoutValidation: decision.holdoutValidation, + birthTimeSource: dossier.case.birthTimeSource, ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, @@ -191,6 +192,7 @@ export function nextUserActionFromDossier( accepted, candidatesSeparated: decision.separation.sufficient, holdoutValidation: decision.holdoutValidation, + birthTimeSource: dossier.case.birthTimeSource, ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 9596a391..a31f3853 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -91,6 +91,12 @@ import { } from "./choice-card.ts"; import type { ProbeExplainCandidate } from "./probe-explain.ts"; import { segmentProbeGains } from "../core/segment-probe-order.ts"; +import { + NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES, + nearestBoundaryFromTransitions, + nearestBoundaryProbesEnabled, + orderByBoundaryDomains, +} from "../nearest-boundary.ts"; import { overlayChoicePromptFromSpoken } from "./turn-narration.ts"; import { GENERIC_COLLECT_QUESTION, @@ -1272,6 +1278,29 @@ function emptyRankedCatalog(): { return { locked: [], personality: [], yearless: [], dropped: [] }; } +/** Gain order stays put unless the nearest-boundary switch is explicitly on. */ +export function orderDiscriminatorsForNearestBoundary( + rows: readonly T[], + input: { + windowScan?: { transitions?: readonly { layer?: string; at?: string; from_sign?: string; to_sign?: string }[] } | null; + reportedTime?: string | null; + birthTimeSource?: string | null; + probeFlag?: string; + }, +): readonly T[] { + if (!nearestBoundaryProbesEnabled(input.probeFlag) || input.birthTimeSource === "period_only" || !input.reportedTime) { + return rows; + } + const boundary = nearestBoundaryFromTransitions(input.windowScan?.transitions, input.reportedTime); + if (!boundary.found || boundary.distanceMinutes == null + || boundary.distanceMinutes > NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES + || !boundary.domains.length) return rows; + return orderByBoundaryDomains(rows, boundary.domains, (row) => row.eventProbe?.domain ?? row.contrastProbe?.domain ?? ""); +} + function rankRenderableDiscriminators(input: { eventProbes: readonly DiscriminatingEventProbe[]; contrastProbes: readonly CandidateDiscriminatorProbe[]; @@ -1282,6 +1311,9 @@ function rankRenderableDiscriminators(input: { evidence?: readonly MethodFollowupEvidence[]; birthDate?: string | null; inferenceState?: InferenceState | null; + windowScan?: WindowScan | null; + reportedTime?: string | null; + birthTimeSource?: string | null; }): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } { const top = input.topCandidateTimes ?? []; const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item))); @@ -1355,10 +1387,15 @@ function rankRenderableDiscriminators(input: { } return right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0); }); + const ordered = orderDiscriminatorsForNearestBoundary(sorted, { + windowScan: input.windowScan, + reportedTime: input.reportedTime, + birthTimeSource: input.birthTimeSource, + }); return { - locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)), - personality: sorted.filter((row) => isYearlessPersonalityRow(row)), - yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)), + locked: ordered.filter((row) => discriminatorLocksScoringPeriod(row)), + personality: ordered.filter((row) => isYearlessPersonalityRow(row)), + yearless: ordered.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)), dropped, }; } @@ -2274,6 +2311,7 @@ export function buildMethodFollowupPlan(input: { caseStage?: RectificationCaseStage; blockScan?: BlockScanPayload | null; reportedTime?: string | null; + birthTimeSource?: string | null; candidateRange?: import("../core/candidate-window.ts").DatedCandidateRange | null; remainingLayers?: readonly string[]; remainingSplitTimes?: readonly [string, string] | null; @@ -2454,6 +2492,9 @@ export function buildMethodFollowupPlan(input: { evidence: input.evidence, birthDate: input.birthDate, inferenceState: input.inferenceState, + windowScan: input.windowScan, + reportedTime: input.reportedTime, + birthTimeSource: input.birthTimeSource, }) : emptyRankedCatalog(); const rankedDiscriminators = rankedCatalog.locked; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index e766cf5a..41a66610 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -307,6 +307,7 @@ export function safeCaseProjection( reportedBirthTime: parsed.case.reportedBirthTime, candidateRange: parsed.case.candidateRange, }), + birthTimeSource: parsed.case.birthTimeSource, }); const latestProjection = latest && parsed.case.stage !== "block_scan" ? latestResultToolProjection(latest, decision, productDeliveryForDomain(parsed.case.rectificationDomain)) @@ -772,6 +773,7 @@ function followupPlanForParsed( reportedBirthTime: parsed.case.reportedBirthTime, candidateRange: parsed.case.candidateRange, }), + birthTimeSource: parsed.case.birthTimeSource, }), contrastPacket: catalog.contrastPacket, sessionOutcome: decision.sessionOutcome, diff --git a/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts b/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts new file mode 100644 index 00000000..50de66af --- /dev/null +++ b/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts @@ -0,0 +1,138 @@ +/** + * BUG-1307: the opening says how close the recorded minute is to the nearest + * chart change. Question order stays off unless the env flag is "on". + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildOpeningBrief } from "../src/lib/rectification-agentic/v9/agent-run-messages.ts"; +import { orderDiscriminatorsForNearestBoundary } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { openingFactsFromBoundaryPayload, resolveOpeningBoundaryFacts } from "../src/lib/rectification-agentic/opening-boundary.ts"; +import { + nearestBoundaryFromTransitions, + nearestBoundaryProbesEnabled, + orderByBoundaryDomains, + topicsForLayers, +} from "../src/lib/rectification-agentic/nearest-boundary.ts"; +import { + OPENING_BODY_JARGON, + isAcceptableOpeningBody, + openingBoundarySentence, + openingSpokenBody, +} from "../src/lib/rectification-agentic/user-copy.ts"; +import { dossierFixture } from "./rectification-v9-test-support.ts"; + +const RANGE = ["04:45", "05:15"] as const; +const PLAIN = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"; +const BANNED = ["秒", "记录时间错了", "记录时间不准", "已确认", ...OPENING_BODY_JARGON]; + +function assertPlain(text: string): void { + for (const word of BANNED) assert.equal(text.includes(word), false, word); + assert.doesNotMatch(text, /[??]/); + assert.ok(text.split(/(?<=。)/).filter(Boolean).length <= 3, text); +} + +test("default opening stays two sentences", () => { + assert.equal(openingSpokenBody(RANGE), PLAIN); + assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"); + assert.equal(openingBoundarySentence({ kind: "period_only" }), null); + assert.equal(openingBoundarySentence({ kind: "unavailable" }), null); + assert.equal(openingSpokenBody(RANGE, null, { kind: "period_only" }), PLAIN); + assert.equal(isAcceptableOpeningBody(PLAIN, RANGE), true); +}); + +test("near, under a minute, stable, and only the birth chart", () => { + const near = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 4, topics: ["父母", "工作", "