Compare commits

...
Author SHA1 Message Date
jesse-ux eea90926a1 fix(ui): 星盘页和星历页去掉三句过度提示
Independent Staging Quality Gate / validate (push) Successful in 14m34s
Independent Staging Quality Gate / publish (push) Canceled after 25m47s
产品判定这三句都不是给用户看的判断或计费说明:
星盘「主盘直接算 · 分盘按需 · 不消耗点数」、
「下面是词条式释义,不是对你个人的判断。」、
星历「这一页是天象本身,不是对你的判断。」
按需加载与不扣点的行为不变。
2026-09-17 19:25:57 +08:00
jesse-ux dc2f2a16bb fix(consult): 每一轮回答前都必须调用排盘工具
Independent Staging Quality Gate / validate (push) Successful in 9m22s
Independent Staging Quality Gate / publish (push) Successful in 13m39s
2026-09-17 14:21:33 +08:00
Jesse_ChenandClaude Fable 5.1 6d097de95b docs(tasks): 咨询追问轮不调排盘工具即整轮失败的任务书(BUG-922/923)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 06:10:16 +00:00
jesse-ux 1b508d5e9a fix(web): iPhone 键盘收起后顶栏复位,积分块与盘面芯片同高
Independent Staging Quality Gate / validate (push) Successful in 11m21s
Independent Staging Quality Gate / publish (push) Successful in 10m46s
2026-09-17 13:13:39 +08:00
Jesse_ChenandClaude Fable 5.1 77e3626a5c docs(tasks): 移动端视口单追加顶栏积分块「有点扁」(BUG-921)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 05:01:15 +00:00
Jesse_ChenandClaude Fable 5.1 6ca28e060b docs(tasks): iPhone 键盘收起/刷新后整页上移任务书(BUG-920)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 01:37:18 +00:00
Jesse_ChenandClaude Fable 5.1 73d3c99ccd docs(tasks): D9 修复单与移动端时间轴单验收通过,状态板改已验收
Independent Staging Quality Gate / validate (push) Successful in 13m34s
Independent Staging Quality Gate / publish (push) Successful in 12m58s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 00:57:35 +00:00
Jesse_ChenandClaude Fable 5.1 662672c153 fix(web): 手机上时间轴读数不再被裁,「跳到最新」不再压住选项
375px 下读数第四项被 ellipsis 裁成「已…」、第五项整项不可见:compact
段仍加 44px 头像缩进(每侧 60px,可用内容只剩 255px),而 cfb41daf 又把
读数从四项变成五项,按 13px 逐字估宽需要 383px。compact 段改为只取
`space-4`、`column-gap` 收到 `space-2`、第五项 `已对照 N 件` 在 767px 以下
`display: none`(仍在 DOM 与轴 aria-label 里),四项约 292px / 可用 343px。
桌面段、条高 64/56px、滚动锚定逻辑均未动。

「跳到最新」浮层占 composer 上方 56px 带,而 `.message-list` 底部留白恰好
也是 56px,末条选项贴在浮层按钮正下方、正中间点不动。留白改为
`calc(clearance + space-3)`,静止时末条下方 84px = 56px 带 + 28px 空气;
`.conversation` 追加同高 `scroll-padding-block-end`。浮层位置、居中与可见
条件未动。

顺带记一条待产品裁决的口径冲突:DESIGN §10 禁列表写明「已对照 N 件经历」
不上条、仍归交付卡,`cfb41daf` 却把它加上且未改 DESIGN;本轮只在手机上把
它移出可见行,桌面保留原状。

BUG-918、BUG-919

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 00:53:21 +00:00
Jesse_ChenandClaude Fable 5.1 5113d457b7 fix(rectification): 自建分盘探针按 receipt 重建;风格题不再计分;死卡不配采集占位
BUG-915:BUG-912 首版只在盖戳那一刻把自建探针注入内存 state,答题 / GET /
idle 三处读持久化 receipt 都找不到它 → 点选与打字回答报 stale_probe、GET 无卡、
刚落库的焦点每次 idle persist 被 superseded。新增
withOwnedDistinguishProbes(state, receipt) 按 window_scan.transitions + state
候选确定性重建(照 withNakshatraBoundaryProbe 的既有模式),接入盖戳、点选答题、
打字答题、GET 投影、idle 过期五处,并加源码契约测试钉住。重建探针的 source 为
owned_varga_style,像 nakshatra_boundary 一样从 contrast packet 与 event 探针池
排除,避免答题写回 state 后它们变成可问的题。

产品决策(任务书 §1d,选项 b):分盘风格题不再作为 distinguish_candidates 计分题。
分层判别题只在引擎给出该领域带年份事件探针时以「某年前后有没有…」出题并按该探针
计分;没有事件探针时不出题,计划直接走下一条线。删除 attachVargaDistinguishIdentity
与 withFollowupOwnedProbe,保留 buildVargaDistinguishFields / conflictProbeFromFollowup
供重建与将来选项 (a)。

BUG-917:最新助手消息带未答点选而 GET 无卡时,缺口态改走 unavailable 修复出口,
不再与「再说一件带年月的事」同屏;superseded 且未作答的焦点不再渲染成灰色选项。

BUG-916:年月阶段 host 前置挪到会话校验之后,phase 复用 answer.host_fallback。

测试改走生产路径(inferenceForPersistedAnswer / choiceCardFromCaseDossier /
persistedDistinguishFocusStale),删掉手工把自建探针塞进 state 的 fixture。
tsc 0 错;lint 0 error / 116 warning;npm test 3421 条 / 31 红,失败清单与基线
c32f81e7 逐条一致;next build --webpack 后 / 仍 ○ Static,产物 JS gzip
1,496,339 → 1,496,408(+0.005%);pytest rectification 定向 63 绿,快速门 Python
段 798 绿。Skill 未 bump。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 00:48:50 +00:00
54 changed files with 1960 additions and 282 deletions
+25
View File
@@ -1,5 +1,30 @@
# 印度占星 Skill 更新日志
## 2026-09-17 — 星盘页、星历页去掉三句说明
打开星盘后,Tab 行下面不再写「主盘直接算 · 分盘按需 · 不消耗点数」。基础信息里也不再写「下面是词条式释义,不是对你个人的判断。」星历页底部不再写「这一页是天象本身,不是对你的判断。」盘还是主盘先算、分盘点到才算、不扣点;行星卡仍是度数和星宿;星历仍只写天象事实,不是运势判断。Skill 版本不变。
## 2026-09-17 — 追问、反问、短句也会先取回本轮星盘再回答
「?」「你在说什么鬼」这类跟进不再整轮失败。每一轮都会先重新取回本轮星盘证据,再据此回答。Skill 版本不变。
## 2026-09-17 — 手机键盘收起后顶栏还在;积分块和「当前盘面」一样高
iPhone 上弹键盘再收起、或带着键盘刷新,整页会停在上移后的位置,顶栏点不到。现在键盘一收,页面回到顶,顶栏随时能点。顶栏右侧「当前盘面」和积分两枚芯片同一套尺寸,积分块不再又宽又扁。Skill 版本不变。
## 2026-09-17 — 不再问「你更接近哪一种相处方式」;灰掉的选项和「再说一件事」不会同屏
分盘风格题(「亲密关系里,你更接近哪一种相处方式?」这类)不再作为计分题出现。关系、事业、家人这些线只在引擎给得出带年份的事件时问「某年前后有没有…」,给不出就直接问下一条线或给结果,不再拿性格描述去淘汰候选时间。性格描述仍然出现在平局时的参考题和交付卡的性格列里。
上一版这类题会出现在正文里但答不了:四个选项灰着点不动,同屏的范围行和输入框却写着「再说一件带年月的事」。现在这种自相矛盾的画面不会再有——拿不到可点的题时只会出现「没有拿到下一个问题」和「接着问」,占位文案跟着一致。已经答不了的旧题也不会再画成一排灰选项。
如果你 2026-09-16 到 09-17 之间在校正里遇到过风格题,那一次的答案可能计错了分,不要采用当时的区间;补一件带年月的事触发重算,或新开一段校正。
Skill 版本不变。
## 2026-09-17 — 手机上时间轴读数不再被裁,「跳到最新」不再压住选项
窄屏(375 / 390 宽)打开生时校正时,上方时间轴那一行「范围 · 宽度 · 代表分钟 · 已答 N 题」四项完整显示,不再出现「已…」这种被裁一半的字。为腾出宽度,手机上去掉了这条与头像列对齐的 44px 缩进,并把「已对照 N 件」这项收回交付卡(桌面不变;读屏软件在两种宽度下读到的内容都一样)。「跳到最新」胶囊与对话末条之间留出空隙,不再压在选项卡最后一行上。Skill 版本不变。
## 2026-09-16 — 正文念出的判别题必有卡;性格描述不再自相矛盾
感情判别题下面会有可点的 A/B/C/D,不再只剩一句题干。这类题按关系盘自己的分组计分,不会拿学业盘的对照题来打分。2026-09-16 前如果分盘风格题被盖上了别的盘的探针,当时记下的答案可能计错分,不要采用那个区间,补一件带年月的事或新开一段即可。交付卡每一列性格只留一句,卡头共同点不会把一对相反的话写在一起。Skill 版本不变。
+153 -11
View File
@@ -5894,8 +5894,8 @@
- 根因:(1) `stampChoiceSchemaWithProbe` 用引擎最高增益探针盖 schema,把对比探针换成已答教育题;`event_probe` 缺 `probe_id` 时被当成已答,不建焦点。GET 投影不传 `contrastPacket` / `userStopped`。(2) 对比探针按整窗 D10 星座建题,`supportsCandidateIds` 是星座名不是分钟;`askedKeys` 不含自然语言答过的职责倾向。(3) 质量探针先占领域,挡住 dasha 年界;代表对取整窗第一次换升。`answersFromEvidence` 把同年入学当成考试失常。(4) 停问词匹配不到「没有了」。
- 修复:对比探针用自己的 `semantic_key` 盖戳,缺引擎 `probe_id` 时仍建焦点;GET 与工具侧同一套 plan 输入。剩余候选按 D24/D10 分钟切开;职责倾向记入 `askedKeys`。质量探针不得挡住 dasha;代表对取当前候选集。入学不再自动回答质量探针。停问词加上「没有了」等,停问且可出牌时出并列区间。不把 `occupation_note` 改成主评分事件,不打开 `confirmation_allowed`,不改 Skill `10.0.11`。
- 验证:`rectification-spoken-answer`、`rectification-server-focus`、`rectification-choice-card`、`rectification-decide-next-action`、`rectification-eight-method`、`rectification-inference-machine`、`tests/test_rectification_event_probes.py`。
- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。2026-09-16 复发(BUG-912):无探针的分盘风格题借最高增益探针。
- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374、BUG-912
- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。2026-09-16 复发(BUG-912):无探针的分盘风格题借最高增益探针。2026-09-17(BUG-915):给风格题自建探针只解决了盖戳,自建探针没有像月宿边界探针那样在每个读 state 的地方重建,答题 / GET / idle 三处读持久化 receipt 都找不到它;产品同日拍板风格题不再作为计分判别题。
- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374、BUG-912、BUG-915
- 复发自:BUG-348 / BUG-366(区分卡与覆盖≠收敛已写过,焦点持久化和剩余候选切开未接到这条会话)
- 修复版本:d7afe5b50de488d98a2ca666763784158e7c3be2
@@ -11162,14 +11162,14 @@
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-15
- 最近更新:2026-09-17
- 影响面:`assembleChartView` 的 layer 参数、`chart-page-view.tsx` 眉标、服务端引擎结果缓存
- 用户现象:打开星盘常失败;失败页上还写着「直接计算 · 打开即有 · 不消耗点数」。生产与 staging 重计算并发默认 2,开页并行打 `/api/western` 与 `/api/qizheng` 就会占满。
- 触发条件:打开 `/chart`。同时有第二个人开星盘,或校正 / 咨询在跑,更容易 429。
- 根因:进页无条件并行打两个 `HEAVY_COMPUTE_PATHS` 端点;`cache: "no-store"` 且无记忆;失败分支也渲染同一句速度承诺眉标。
- 修复:开页只请求 `/api/chart`。西洋 / 七政点到对应 tab 才发,不再占开页配额。同一账户 + 出生资料指纹 + ayanamsa + `node_mode` 的引擎结果内存缓存 5 分钟,资料或岁差一变即换键。失败页删除眉标;成功眉标改为「主盘直接算 · 分盘按需 · 不消耗点数」。不放宽限流。
- 验证:无 layers 时 `assembleChartView` 的引擎路径只有 `/api/chart`;失败页无 eyebrow;缓存键随 ayanamsa / node_mode 变化。
- 防复发:开页不得并行打 `/api/western` 与 `/api/qizheng`。不得靠放宽 `HEAVY_COMPUTE_PATHS` 或提高 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 来「解决」429。承诺速度的文案不写。
- 修复:开页只请求 `/api/chart`。西洋 / 七政点到对应 tab 才发,不再占开页配额。同一账户 + 出生资料指纹 + ayanamsa + `node_mode` 的引擎结果内存缓存 5 分钟,资料或岁差一变即换键。失败页删除眉标。不放宽限流。2026-09-17 产品判定成功页「主盘直接算 · 分盘按需 · 不消耗点数」仍是过度提示,连同基础信息「下面是词条式释义,不是对你个人的判断。」一并去掉;按需加载与不扣点的行为不变,只是不再写在页上。
- 验证:无 layers 时 `assembleChartView` 的引擎路径只有 `/api/chart`;成功页与失败页都无 eyebrow、无「不消耗点数」、无「词条式释义」;缓存键随 ayanamsa / node_mode 变化。
- 防复发:开页不得并行打 `/api/western` 与 `/api/qizheng`。不得靠放宽 `HEAVY_COMPUTE_PATHS` 或提高 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 来「解决」429。承诺速度、计费、词条边界的说明不写在星盘页上。`chart-page-view.test.tsx` 成功页与失败页都断言不得出现这些句子。
- 相关记录:BUG-707、BUG-715、BUG-716、TASK-chart-page-blocking-open-20260915
- 复发自:无
- 修复版本:待发布
@@ -11847,15 +11847,15 @@
- 状态:resolved
- 首次发现:2026-09-16
- 最近更新:2026-09-16
- 最近更新:2026-09-17
- 影响面:`stampChoiceSchemaWithProbe`、`varga-distinguish-probe.ts`、`projectRectificationChoiceCard`、`persistNextInterviewIfIdle`
- 用户现象:正文念了感情判别题,下面没有 A/B/C/D;打字答「没有」后区间被收窄。
- 触发条件:D9 风格 followup 没有自己的 `semantic_key`,inference_state 里另有高增益 D24 对照探针。
- 根因:`stampChoiceSchemaWithProbe` 在无 preferred key 时退到 `selectHighestGainProbe`。BUG-375 只堵住了对比探针被已答教育题盖掉,没堵住「无探针的分盘风格题借最高增益探针」。
- 修复:不再退到最高增益探针。D9/D10 等风格题按剩余候选的分盘上升自建探针。GET 从落库副本投影;过期焦点 `superseded` 后落下一问。
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`;BUG-375 原断言仍绿。
- 防复发:无 `semantic_key` 的计分题不得盖上别的分盘探针。宁可不落库(走 BUG-674)也不错分。
- 相关记录:BUG-375、BUG-578、BUG-582、BUG-674
- 修复:不再退到最高增益探针。D9/D10 等风格题按剩余候选的分盘上升自建探针。GET 从落库副本投影;过期焦点 `superseded` 后落下一问。**首版只在盖戳那一刻把自建探针注入内存 state,答题 / GET / idle 三处读持久化 receipt 都找不到它(BUG-915);2026-09-17 由该单补齐重建,并按产品决策不再出风格计分题。**
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`(2026-09-17 改写为落库形状回放);BUG-375 原断言仍绿。
- 防复发:无 `semantic_key` 的计分题不得盖上别的分盘探针。宁可不落库(走 BUG-674)也不错分。只在内存里存在的探针不算落库,新增自建探针必须同时给出「每个读 state 的地方都重建」的路径(BUG-915)。
- 相关记录:BUG-375、BUG-578、BUG-582、BUG-674、BUG-915、BUG-917
- 复发自:BUG-375
- 修复版本:待发布
@@ -11891,4 +11891,146 @@
- 复发自:无
- 修复版本:待发布
## BUG-915 | 自建分盘探针只活在内存里:点选报 stale_probe、GET 无卡、idle 立刻 superseded
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`varga-distinguish-probe.ts`、`expectedAnswerSchemaFor`、`applyRectificationChoice`、`rectification-resolve-focus`、`choiceCardFromCaseDossier`、`persistNextInterviewIfIdle`、`buildMethodFollowupPlan`
- 用户现象:正文出现「亲密关系里,你更接近哪一种相处方式?」,下面四个选项灰显点不了;同屏范围行与输入框占位却写「再说一件带年月的事」。点选或打字回答会报错。
- 触发条件:BUG-912 首版给分盘风格题自建了探针;该探针只在盖戳时注入内存 state,`decision_receipt.inference_state` 里没有。
- 根因:自建探针没有按月宿边界探针的既有模式在每个读 state 的地方重建。答题路径、GET 投影、idle 过期判定都读持久化 receipt:`matchProbeForChoice` 找不到 → `stale_probe`;`liveInferenceDistinguishProbe` 找不到 → 无卡;`liveDistinguishProbe` 找不到 → 刚落库的焦点每次 idle persist 都被 `superseded`。
- 修复:新增 `withOwnedDistinguishProbes(state, receipt)`,按 `window_scan.transitions` + state 候选确定性重建全部自建探针,接入盖戳、点选答题、打字答题、GET 投影、idle 过期五处(答题两处共用 `inferenceForPersistedAnswer`);重建探针的 `source` 为专用 `owned_varga_style`,并像 `nakshatra_boundary` 一样从 contrast packet 与 event 探针池排除,避免答题写回 state 后它们在下一轮变成可问的题。产品 2026-09-17 同时拍板(选项 b):分盘风格题不再作为 `distinguish_candidates` 计分题——分层判别题只在引擎给出该领域带年份事件探针时以「某年前后有没有…」出题并按该探针计分,否则该题不出,计划直接走下一条线。删除 `attachVargaDistinguishIdentity` / `withFollowupOwnedProbe`。
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts` 10 条:落库形状回放(未重建 = `stale_probe`,重建后 `applied` 且只按 D9 分组计分)、`choiceCardFromCaseDossier` 出卡、`persistedDistinguishFocusStale` 不误伤且候选集变化仍过期、五处接入点源码契约、(b) 决策两半。`npm test` 3421 条 / 31 红(与基线 `c32f81e7` 逐条一致)。
- 防复发:任何不在引擎 `inference_state` 里的探针,必须有一个从 receipt 确定性重建的函数,并在每个读 state 的地方接入;只在盖戳时注入内存的写法一律视为未落库。分盘风格题不得作为计分判别题。
- 相关记录:BUG-912、BUG-375、BUG-559、BUG-674、BUG-917
- 复发自:BUG-912(同一根因的第二层:探针身份有了,持久化没有)
- 修复版本:待发布
## BUG-916 | 年月阶段 host 前置排在会话校验之前
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`runV9AgentTurn`、`PUBLIC_RECTIFICATION_PHASES`
- 用户现象:无直接用户现象;跨会话(旧标签页)发来的无年月短句也会被写成一条确定性轮。
- 触发条件:BUG-910 的 host 前置写在 `dossier.case.sessionId !== sessionId` 校验之前;返回的 `phases: ["answer.host_year_entry"]` 不在公开 phase 白名单里(类型是 string,tsc 不报)。
- 根因:前置插入位置只考虑了「不要进模型」,没有沿用既有的会话校验顺序;phase 名新造而未登记。
- 修复:host 前置挪到会话校验之后(计费位置不变);phase 复用既有 `answer.host_fallback`,公开快照的 `answer_origin: "host_fallback"` 语义一致。
- 验证:`frontend/tests/rectification-v9-stream.test.ts` 等既有 host 前置断言仍绿;`npm test` 失败清单与基线逐条一致。
- 防复发:任何早退分支都必须排在会话 / Skill 身份校验之后;返回的 phase 必须是 `PUBLIC_RECTIFICATION_PHASES` 里的值。
- 相关记录:BUG-910、BUG-915
- 复发自:无
- 修复版本:待发布
## BUG-917 | 死掉的点选卡与「再说一件带年月的事」同屏
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`rectificationQuestionGapState`、`rectification-agentic-chat.tsx`、`rectification-message-entry.tsx`
- 用户现象:最后一条助手消息挂着一张灰色不可点的 A/B/C/D,同屏的范围行与输入框占位却说「再说一件带年月的事就能继续」,两条指令互相矛盾。
- 触发条件:焦点被 idle persist 判成 `superseded`(BUG-915),GET 没有 `choice_card`;`current_question` 为 null 于是 `interviewCollectWaiting` 为真。
- 根因:缺口态只看 `current_question`,看不到消息级的未答点选;消息内卡片只把 `status === "active"` 的当成未答,`superseded` 且没有作答的焦点仍然渲染成 `disabled` 的选项。
- 修复:`rectificationQuestionGapState` 新增 `unansweredDeadChoice` 输入,在 `delivered` 之后、`collect_waiting` 之前返回 `unavailable`(「没有拿到下一个问题」+「接着问」);chat 按最新一条 settled 助手消息计算该标志;message-entry 对「`superseded` 且无 `answer_option`」不再画卡。
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`「a dead tap card never shares the screen with the collect-wait placeholder」:`collect_waiting` + 死卡 → `unavailable`,并有 chat / message-entry 源码契约。
- 防复发:死卡与 `collect_waiting` 不得同屏;未作答却已被关闭的点选题不得渲染成灰色选项。
- 相关记录:BUG-915、BUG-675、BUG-674
- 复发自:无
- 修复版本:待发布
## BUG-918 | 校正时间轴读数在 375px 手机上被裁成「已…」
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`frontend/src/app/globals.css` `.rectification-timeline` / `.rectification-timeline__readout` 的 `@media (max-width: 767px)` 段、`frontend/src/components/rectification-timeline.tsx`
- 用户现象:iPhone 上常驻时间轴一行读作「04:48–05:07 20 分钟 代表分钟 04:53 已…」,第四项被省略号裁断,第五项完全不显示。
- 触发条件:视口宽 375 CSS px(iPhone SE 2/3、13 mini)打开生时校正,Case 已有代表分钟、已答题数与已对照事件数(读数五项齐全)。
- 根因:两条叠加。一是读数按「四项短文本」设计,`.rectification-timeline` 在 767px 以下仍取 `calc(var(--space-4) + var(--assistant-content-inset))`,即每侧 16 + 44 = 60px,375px 的可用内容宽只剩 255px;条不在消息列里,这 44px 头像沟槽在手机上买不到任何可见对齐。二是 cfb41daf 又给读数追加了第五项 `已对照 N 件`,把四项预算变成五项。按 13px 逐字保守估宽,五项加四个 12px 间隙需要约 383px,超出 255px 约 128px,于是 `__answered` 命中自身的 `text-overflow: ellipsis` 显示为「已…」,`__dated` 被条的 `overflow: hidden` 整项裁掉。
- 修复:compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)内 `.rectification-timeline` 的 `padding-inline` 改为 `var(--space-4)` 单项;读数 `column-gap` 收到 `var(--space-2)`;`.rectification-timeline__dated` 在该段 `display: none`。桌面规则与条高(64 / 56px)一字未动,滚动锚定逻辑未动。组件仍无条件渲染全部五个 span,轴的 `aria-label` 仍由五项文本拼成,被隐藏的一项对辅助技术不丢失。
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 6 条(渲染断言五项文本与 `aria-label` 一致;按保守逐字宽度断言 375 与 390 下四项 + 3×8px 间隙约 292px ≤ 343px / 358px,并断言旧口径 383px > 255px;compact 段声明断言去 inset、收 gap、隐藏第五项、未放开 nowrap 与条高;桌面段断言仍带 inset 与 13px)。`frontend/tests/rectification-timeline-20260909.test.ts` 的移动端 `padding-inline` 断言同步改写并附原值 / 新值 / 原因。全套 `npm test` 3420 tests / 31 fail,失败清单与基线(3414 / 31,无 Docker 集)逐条相同;`tsc --noEmit` 0 错、`npm run lint` 0 error、`next build --webpack` 后 `/` 仍 `○ Static`、首屏 gzip 620106 → 620128 字节(+0.004%)。
- 防复发:新增测试把「compact 段不得含 `--assistant-content-inset`」「第五项在 compact 下隐藏」与宽度预算写成断言;`globals.css` 与组件注释都改掉了原来「四项短文本在 375px 下还有余量」的错误说法,并写明再加一项必须重算预算而不是直接追加 span。
- 相关记录:BUG-478(跳到最新与滚动锚定归属)、BUG-919(同一次真机走查的另一条)。另记一条口径冲突待产品裁决:`frontend/DESIGN.md` §10 的禁列表明确写「已对照 N 件经历」不上条、仍归交付卡,而 cfb41daf 把它加到了条上且未改 DESIGN;本轮只在手机上把它移出可见行,桌面上是否也撤掉未擅自决定。
- 复发自:无
- 修复版本:待发布
## BUG-919 | 「跳到最新」浮层压住选项卡最后一个可点选项
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`frontend/src/app/globals.css` `.rectification-workspace__chat .conversation` / `.message-list` 的底部留白
- 用户现象:iPhone 上「跳到最新」胶囊浮在选项卡最后一行上,选项被遮掉一半,正中间不好点。
- 触发条件:校正对话滚到尾部附近、浮层可见(距底 > 96px)时,末条内容是一张多行选项卡。
- 根因:浮层 `.jump-to-latest` 是 `position: absolute; bottom: 100%` 挂在 composer 顶边上,占据 composer 上方 44px 触控高 + 自身 `space-3` = 56px 的带。而 `.message-list` 的底部留白恰好等于 `--rectification-jump-clearance`(同样 56px),与浮层带严丝合缝,末条内容顶到浮层正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,按钮正好落在选项行中央,于是「遮一半 + 中间点不动」。
- 修复:`.message-list` 底部留白改为 `calc(var(--rectification-jump-clearance) + var(--space-3))`;`.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)`,让任何 scroll-into-view 也落在带之上。连同 `.conversation` 自身的 `space-4`,静止时末条下方共 84px(56px 浮层带 + 28px 空气)。浮层的位置、居中对齐与可见条件(BUG-478 的归属)一字未动,`--rectification-jump-clearance` 的取值不变(时间轴 56px 条高仍与它同源),`use-conversation-scroll-anchor` 未动。
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 两条声明断言(留白表达式与 `scroll-padding-block-end`;浮层 `absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 44px 与 `conversationAnchorThreshold = 96` 未变)。全套门禁数字同 BUG-918。真机走查(375 / 390 宽)留在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`:本机无 Chrome、无真机。
- 防复发:测试锁住「留白 = 浮层带 + space-3」这条表达式,任何把它改回等于浮层带的提交会红。CSS 注释写明了为什么不做到「任何滚动位置都不重叠」——那需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px,在 667px 高的手机上等于凭空吃掉一整行选项,而浮层只在读者已经向上滚过该阈值后才出现。
- 相关记录:BUG-478(两个聊天面统一用 `useConversationScrollAnchor` 与 `JumpToLatestButton`,并把浮层改成居中)、7a4360d8(同一现象 2026-08-25 的旧修法是把胶囊右对齐,已被 BUG-478 的居中统一取代,本轮未推翻它)、BUG-918
- 复发自:无
- 修复版本:待发布
## BUG-920 | iPhone 键盘收起 / 刷新后整页上移,顶栏点不到
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`ViewportScrollLock`、`layout.tsx`
- 用户现象:刷新或收起键盘后顶栏滚出屏幕,底部留下空白,点不到 tab。
- 触发条件:iPhone Safari 上聚焦输入框弹出键盘,随后收起或带着偏移刷新。
- 根因:iOS 不支持 `interactive-widget=resizes-content`,键盘弹出时滚动 `window`。`html, body { overflow: hidden }` 挡住用户拉回,reload 又按 `scrollRestoration=auto` 还原 `scrollY`。代码里原先没有 window 级复位。
- 修复:挂载时 `history.scrollRestoration = "manual"` 并 `scrollTo(0,0)`。`visualViewport` resize/scroll、pageshow、orientationchange、focusout(300ms)在键盘已收且 `scrollY > 0` 时复位。键盘打开期间不动。
- 验证:`frontend/tests/mobile-viewport-scroll-lock-20260917.test.ts`。真机待核。
- 防复发:不得靠用户自己滚回去;键盘打开时不得抢 Safari 的露出输入框滚动。
- 相关记录:BUG-918、BUG-919、BUG-921
- 复发自:无
- 修复版本:待发布
## BUG-921 | 顶栏积分块比「当前盘面」扁
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`.credit-button`、`.chat-header-rectification .rectification-board-peek`、手机顶栏行高
- 用户现象:手机顶栏右侧积分「✦ N」又宽又矮,和旁边「当前盘面」不成一套。
- 触发条件:校正页顶栏同时出现盘面芯片与积分块。
- 根因:`.chat-header-actions button` 把高度写成 32px,比 `.credit-button` 更具体,积分块被压到 32px 却仍强制 `min-width: 64px`、13px 字。盘面芯片用另一套 44px / 14px。两枚都塞在 46px 行里。
- 修复:两枚共用 40px 高、14px tabular、同圆角同内边距;去掉积分 `min-width`。`::before` inset -2px 把热区扩到 44px。≤767px 顶栏行 `52px + safe-area`(原 64px 覆盖改为与任务书 52px 对齐)。
- 验证:CSS 声明断言高度/字号/圆角一致、无 64px min-width;`touch-target-contract` 仍绿。
- 防复发:顶栏两枚芯片必须走同一组声明,不得再各写一套高度。
- 相关记录:BUG-695、BUG-920
- 复发自:无
- 修复版本:待发布
## BUG-922 | 咨询追问被提示词允许跳过排盘工具,整轮失败
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`windowJyotishInstructions`、`jyotishInstructions`
- 用户现象:申报时段会话连发短追问得不到回答,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点」。
- 触发条件:本命或申报时段模式下发「?」「你在说什么鬼」一类追问。
- 根因:系统指令写「简单追问可复用已有 packet / context、不调工具」;`contractReady()` 却要求每次请求恰好一次成功排盘调用。跨请求没有可复用 packet(缓存只在单次请求内)。
- 修复:两处指令改为每一轮(含短追问、澄清、抱怨)都必须先调用排盘工具,并写明计算只在本请求内有效。
- 验证:`consultation-birth-time-mode.test.ts` 契约;既有 Agent 暴露工具测试仍绿。
- 防复发:不得再写「follow-ups may use the existing」。
- 相关记录:BUG-205、BUG-214、BUG-286、BUG-923
- 复发自:无
- 修复版本:待发布
## BUG-923 | 第 0 步未强制排盘工具,提示词与运行合同对不齐
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`consultationNatalPrepareStep`、`consultationWindowPrepareStep`、`consult/route.ts` 申报时段分支
- 用户现象:同 BUG-922。回执只有 skill 与 runtime-contract-retry,没有任何 tool 步骤。
- 触发条件:同上。
- 根因:提示词是软约束,`toolChoice: "auto"` 不能保证第 0 步调用排盘工具。申报时段路线甚至没有 `prepareStep`。
- 修复:第 0 步 `activeTools` 仅排盘工具且 `toolChoice: "required"`;后续步骤 `"auto"`。窗口路线对称加 `prepareStep`,首次 stream 与合同 retry 使用它。`retryForAnswer` / 续写 / 分段仍不强制。未改 `contractReady()`。
- 验证:natal / window prepareStep 单测;`route.ts` 源码契约。既有「失败后重试成功仍过门禁」「契约未绿文本丢弃」仍绿。
- 防复发:本命与窗口第 0 步必须 `required`。思考模式供应商历史上拒 named/required,本仓 Mastra 类型接受 `'required'`;若线上某模型拒收,另立单,不得静默改回 auto 而不改提示词。
- 相关记录:BUG-214、BUG-286、BUG-922
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,37 @@
# PROGRESS · 星盘页 / 星历页去掉三句过度提示(2026-09-17)
工作树:`.worktrees/chart-copy-trim-20260917`
分支:`codex/chart-copy-trim-20260917`
基线:`origin/staging` @ `dc2f2a16`
本机 Windows。直接执行(产品口头:「把这个去掉」)。
Skill **未 bump**。未改 `scripts/**`、`deploy/**`、`frontend/src/app/page.tsx`。按需加载与不扣点的行为不变。
## 决策记录
产品 2026-09-17 判定这三句都是过度提示,不是对用户的判断或计费说明,从页面拿掉:
1. 星盘成功页眉标「主盘直接算 · 分盘按需 · 不消耗点数」(BUG-717 当时留在成功页)
2. 基础信息「下面是词条式释义,不是对你个人的判断。」
3. 星历页尾「这一页是天象本身,不是对你的判断。」
行星卡、大运双轨注、坐标系边界句、Lahiri 岁差说明都还在。
## 改了哪些文件
- `chart-page-view.tsx`:删 Tab 行下眉标
- `chart-basics-tab.tsx`:删词条边界句
- `chart-view-labels.ts` / `chart-view-contract.ts` / `chart-view-mapper.ts`:删 `eyebrow`、`SYMBOL_BOUNDARY` / `symbolBoundary`
- `ephemeris-page.tsx` / `ephemeris-view.ts`:删页尾定性句
- `globals.css`:删 `.chart-page-eyebrow`、`.ephemeris-outro`
- 测试、`DESIGN.md`、`VOICE.md`、`CHANGELOG.md`、真人清单、BUG-717 跟进
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error / 116 warning(与基线同,本轮文件 0 条) |
| 定向 | `chart-page-view.test.tsx` + `ephemeris-page.test.tsx` + `chart-view-route.test.ts` **32 passed / 0 failed** |
无登录态、无 Chrome:浏览器走查留给 `docs/testing/chart-page-20260915.md`、`chart-page-open-20260915.md`、`ephemeris-page-20260915.md`。
@@ -0,0 +1,33 @@
# 进度 · 咨询追问轮必须先调排盘工具(2026-09-17)
基线:`origin/staging` @ `6d097de9`。
分支:`codex/consult-followup-tool-contract-20260917`。
BUG:922、923。Skill 未 bump。
## 做了什么
- **T1 / BUG-922**:删掉本命与窗口两处「简单追问可复用 existing packet/context」。`frontend/src` 已无 `follow-ups may use`。
- **T2 / BUG-923**:`consultationNatalPrepareStep` 第 0 步 `toolChoice: "required"`。新增 `consultationWindowPrepareStep`,申报时段首次 stream 与合同 retry 走 `windowStreamOptions`。`retryForAnswer` / 续写 / 分段不带强制。
- **prepareStep 类型**:`@mastra/core/dist/agent/types.d.ts` 的 `toolChoice` 为 `'auto' | 'none' | 'required' | { type: 'tool'; ... }`。本仓接受 `"required"`,未升级依赖。源码注释里曾写「思考模式供应商拒 named/required」;产品拍板每轮必调工具,仍上 `required`。若某模型线上拒收,另立单。
- **T4 第一轮取证**(执行方无 VPS,留给产品/部署):
```bash
cd /opt/jyotisha-production
docker compose -f deploy/docker-compose.server.yml logs web --since 2026-09-17T00:00:00 \
| grep agent-observability | grep '<该会话的 sessionId>' | head
```
三条 `run.failed` 若都没有 `tool` 步骤,即本单同一根因;若第一轮有 `tool` 且 `status: failed`,另立单。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| 改动文件 eslint | 0 error |
| `consultation-workflow-contract` + voice | **14 passed / 0 fail**(含 BUG-922/923 契约) |
| `consultation-agentic-runtime` / `consultation-birth-time-mode` | 本机 Windows EPERM symlink(skill runtime alias),Linux CI 不受影响。断言已写在这两个文件里。 |
| Python | 未改 |
| 客户端 bundle | 未改,gzip 应为 0 |
T4 第一轮失败原因需 VPS `agent-observability` 日志,本机做不了。
@@ -0,0 +1,23 @@
# 进度 · 手机顶栏可点 + 积分块不扁(2026-09-17)
基线:`origin/staging` @ `77e3626a`。
分支:`codex/mobile-viewport-scroll-lock-20260917`。
BUG:920、921。Skill 未 bump。
## 做了什么
- **T1 / BUG-920**:`viewport-scroll-lock.ts` + `ViewportScrollLock` 挂在 `layout.tsx`,与 `StaleClientRecovery` 并列。`scrollRestoration=manual`,键盘收起后 `window.scrollTo(0,0)`。键盘打开(visualViewport 矮于 innerHeight−100)不动。`focusout` 按让步做 300ms 兜底。未改 `html, body overflow: hidden`,未动 `page.tsx` 状态。
- **T3 / BUG-921**:`.chat-header-actions .credit-button` 与顶栏 `.rectification-board-peek` 共用 40px / 14px / 同圆角。积分去掉 `min-width: 64px`。`::before` −2px 扩到 44px 热区。≤767px 顶栏行从 `64px + safe-area` 收到 `52px + safe-area`(任务书按 46→52 写;手机原先已是 64px 覆盖,现与 52px 对齐)。
- **T2**:BUG_HISTORY / CHANGELOG / DESIGN / 真机清单。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| 改动文件 eslint | 0 error |
| 本单 + touch-target + 顶栏相关 | **84 passed / 0 fail**(含 BUG-695 触屏热区) |
| Python | 未改,不跑快速门 |
| `next build` | worktree junction,留给 Gitea |
无 iOS / Chrome,真机清单留给产品。
@@ -0,0 +1,102 @@
# 进度 · 自建探针只活在内存里(修复单,2026-09-17)
基线:`origin/staging` @ `c32f81e7`。分支:`codex/rectification-dead-d9-choice-fix-20260916`。
任务书:`docs/tasks/TASK-rectification-dead-d9-choice-fix-20260916.md`(§1d 已补 2026-09-17 决策记录)。
BUG:915~917(开工时 `docs/BUG_HISTORY.md` 最大号 914)。Skill **未 bump**。
## 0. 结论
| 任务 | 结果 |
| --- | --- |
| T1 `withOwnedDistinguishProbes` 五处重建(BUG-915) | 完成,三条生产路径验收断言全绿 |
| §1c 计分口径 | 按 **(b)** 落地:风格题不再是计分判别题 |
| BUG-917 死卡与 `collect_waiting` 不得同屏 | 完成(缺口态改 `unavailable`,死卡不再画灰选项) |
| T2 BUG-916 host 前置挪到会话校验之后 + phase 登记 | 完成(phase 让步为复用 `answer.host_fallback`) |
| T3 记录 | BUG-912 改 `resolved` 并补首版缺陷;BUG-915~917 新增;BUG-375 复发段补一行;CHANGELOG / 真机清单 / 本进度 |
## 1. 做了什么
### T1 · 自建探针按 receipt 确定性重建(BUG-915)
- `frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts`
- 新 `withOwnedDistinguishProbes(state, receipt)`:按 `OWNED_DISTINGUISH_QUESTIONS`(七条分层题,`<method_id>:<ask_theme>`)× `receipt.window_scan.transitions`(缺失时退到 `state.transitions`)× `state.candidates` 重建所有自建探针,同 id / 同 semantic_key 不重复,无变化时原样返回同一对象。不依赖「当前哪个 followup 在问」。
- 重建探针的 `source` 是专用的 `owned_varga_style`,`decision-from-dossier.ts` 的 `contrastPacketFromLatestResult` / `rectificationFollowupCatalog` / `contrastPacketFromState` 三处像排除 `nakshatra_boundary` 一样把它排除:答题会把重建后的 state 写回 receipt,若不排除,这些探针会在下一轮变成 contrast / event 候选,等于绕开 (b)。
- 删掉 `withFollowupOwnedProbe`(只在盖戳那一刻往内存 state 注入,是本单 P0 的根因)与 `attachVargaDistinguishIdentity`(风格计分题的生产入口,按 (b) 不再需要)。保留 `buildVargaDistinguishFields` + `conflictProbeFromFollowup` 供重建与将来 (a) 使用。
- 接入点(五处,全部写成源码契约断言):
1. `server-focus.ts` `expectedAnswerSchemaFor` 盖戳;
2. `answer-choice.ts` `applyRectificationChoice` 点选答题;
3. `interview-state.ts` `choiceCardFromCaseDossier` 的 GET 投影(`persistedDistinguishCard` 用的就是这个 state);
4. `answer-choice.ts` `persistNextInterviewIfIdle` 的 `inferenceForStale`;
5. `src/mastra/rectification-v9-tools.ts` `rectification-resolve-focus`(打字回答同一焦点的路径)。
- 2 与 5 共用新导出的 `inferenceForPersistedAnswer(receipt)`,测试直接调它,保证断言走的是生产代码而不是测试自己拼的 state。
- idle 的过期判定抽成导出的 `persistedDistinguishFocusStale(inference, focus)`(逻辑与原来逐字一致,只是可被测),BUG-559 的 split-hash 判定原样保留。
**为什么没有在 `previousInferenceFromReceipt` 出口统一注入**(任务书 §2 首选、§3 让步 1 允许退成逐点包装):那个出口的返回值同时喂给 `contrastPacketFromLatestResult` 与 `rectificationFollowupCatalog` 的 `eventProbes`(`decision-from-dossier.ts:226/384`)。自建风格探针一旦进那两条投影,就会作为 `yearless` / `contrast` 候选重新变成可问的题,等于绕开本单的 (b) 决策,并且会改动与本单无关的决策面。因此按让步 1 逐点包装,并用源码契约测试钉住五处。
### §1c 计分口径按 (b) 落地
- `method-followup.ts` `makeFollowup`:`varga_observation` / `precision_stage` 的分层判别题(`d9_relationship` / `d10_career` / `d4_home` / `d5_education` / `relatives` / `d2_finance` / `d30_health`,且自己没有 `semantic_key`)
- 若引擎在该领域还有**带年份**事件探针(`year > 0`、`choice_kind` 非 `varga_style` / `event_quality`、未在 `askedProbeKeys` / `answeredProbes` 里),就绑定它:题面是「某年前后有没有…」,`semantic_key` / `expected_outcomes` / `probe_year` 全部取该探针,按该探针计分;探针没有 `semantic_key`(盖不上戳)时不绑定身份,维持基线行为(落不了库走 BUG-674 耗尽分支,不会错分)。
- 没有可用事件探针时**不出题**:不挂 `choice_frame`,由既有 `takeRenderableDistinguish` 记 `frameless_distinguish` 后计划继续往下走(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。
- `lagna_frame`(`dasha_events`,`domain` 为 null)不在这条规则里,行为与基线一致。
- 实测对照(同一输入,同机):基线 `next_followup` = `d9_relationship` / `precision_stage` / `choice_kind=varga_style` / 题面「亲密关系里,你更接近哪一种相处方式?」;本单同输入 `next_followup = null` + `dropped_probes` 两条 `frameless_distinguish:d9_relationship`,即 09-17 真机那道题不会再出现。
### BUG-917 · 死卡不得与 `collect_waiting` 同屏
- `rectification-surface-state.ts`:`RectificationQuestionGapInput` 新增 `unansweredDeadChoice`,在 `delivered` 之后、`collect_waiting` 之前返回 `unavailable`(「没有拿到下一个问题」+「接着问」),占位与范围行随之同步。
- `rectification-agentic-chat.tsx`:按最新一条 settled 助手消息计算 `unansweredDeadChoiceOnMessages`(kind 为 `choice` / `reverse_verify`、无 `answer_option`、status 为 `active` 或 `superseded`、且没有活卡)并传进缺口态。
- `rectification-message-entry.tsx`:`unansweredDeadChoice` 补上「status `superseded` 且没有作答」——这类焦点没有答案可展示,原来会画成 `disabled` 的灰色 A/B/C/D(09-17 手机截图那张)。
### T2 · BUG-916
- `agent-run.ts`:年月阶段 host 前置(`shouldHostReaskYearEntry`)挪到 `dossier.case.sessionId !== sessionId` 校验之后,跨会话消息不再被写成确定性轮;计费位置不变。
- `phases` 从未登记的 `answer.host_year_entry` 改成既有 `answer.host_fallback`(任务书 §3 让步 2),公开快照的 `answer_origin: "host_fallback"` 语义也对得上。
## 2. 门禁数字
| 项 | 基线 `c32f81e7` | 本单 |
| --- | --- | --- |
| `./node_modules/.bin/tsc --noEmit` | 0 错 | **0 错** |
| `npm run lint` | 0 error / 116 warning | **0 error / 116 warning** |
| `npm test` | tests 3414 · pass 3368 · fail 31 · skipped 15 | tests **3421** · pass 3375 · fail **31** · skipped 15 |
| `npm test` 失败清单 | 31 条无 Docker 既有缺口 | **`diff` 逐条一致**(见下) |
| `next build --webpack` `/` 渲染标记 | `○ Static` | **`○ Static`** |
| 产物 JS gzip 合计(`.next/static/**/*.js`) | 1,496,339 字节 | **1,496,408 字节(+0.005%)** |
| `pytest tests/test_rectification_refinement_packet.py tests/test_rectification_event_probes.py tests/test_rectification_engine_memoization.py` | 63 绿 | **63 绿** |
| `run_quality_gate.py --profile quick` | 卡在末尾 `npm test`(31 条无 Docker 失败) | 同:pytest 段 **798 passed / 1 skipped**,末尾 `npm test` 步骤 exit 1(同样的 31 条) |
gzip 口径:本仓没有首屏 gzip 测量脚本,Next 16 的路由表也不再输出 Size 列;取 `.next/static` 下全部 js chunk 的 gzip 字节合计,基线与本单同机同命令(`next build --webpack`)各测一次。
失败清单比对:`diff` 基线与本单的 `not ok` 名单为空输出。31 条全部是无 Docker 的既有环境缺口(数据库 / 迁移 / 部署 / 缓存 appendix / ephemeris 等),与 `BLOCKED.md` 一致。
测试总数 3414 → 3421:本单测试文件由 4 条改写为 11 条(见下)。没有弱化任何既有断言。
### 改过的既有断言
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/rectification-dead-d9-choice-20260916.test.ts` | `GET projects the persisted distinguish copy when its probe is still live`:手工把自建探针塞进 `buildInferenceState({probes:[D24, 自建]})` 再断言有卡 | 删除该 fixture;改成 `choiceCardFromCaseDossier` 读 `{inference_state: 只含 D24, window_scan}` 的落库形状 | 任务书 §1:手塞探针的 state 不是生产形状,正是它让首版「测试全绿、生产报错」 |
| 同上 | `year-stage D9 followup does not borrow the D24 contrast probe`:答题用 `withFollowupOwnedProbe(inference, followup)` | 拆成两条:盖戳断言 + 用生产的 `inferenceForPersistedAnswer(receipt)` 答题,并先断言未重建时确实 `stale_probe` | `withFollowupOwnedProbe` 已删除;答题必须走生产读 state 的那一段 |
其余既有断言一字未改。
### 本单测试(`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`,11 条全绿)
1. 盖戳按 receipt 重建自建探针、不借 D24(并断言落库 state 里确实没有该探针)。
2. 生产答题路径:同一 schema 在未重建的 state 上 `stale_probe`,在 `inferenceForPersistedAnswer` 上 `applied`,`answered_probes` 末位 `semantic_key=d9_relationship:relationship_style`,不含 D24。
3. `choiceCardFromCaseDossier` 对该落库焦点返回卡,`question_id` 与焦点一致、四个选项。
4. `persistedDistinguishFocusStale`:未重建判 stale(这就是误伤),重建后不 stale;候选集变化后仍 stale(BUG-559 保留)。
5. 五处接入点的源码契约。
6. (b) 决策:无带年份事件探针时不出风格题(题面不含「更接近哪一种」,`dropped_probes` 有 `frameless_distinguish:d9_relationship`)。
7. (b) 决策的另一半:引擎给出 2020 年感情事件探针时该线照问,`semantic_key` 为该探针、`choice_kind=existence`、题面含年份。
7b. 重建出来的探针不得变成可问的题:`rectificationFollowupCatalog` 的 `eventProbes` 与 `contrastPacket.probes` 里都不含该 key。
8. BUG-917:`collect_waiting` + 死卡 → `unavailable`;chat 与 message-entry 的源码契约。
9. BUG-913 `closed_by_design` 复核(原样保留)。
10. BUG-914 两极不得同时进 `shared_traits`(原样保留)。
## 3. 环境缺口
- 无 Docker:31 条失败与基线逐条一致;`run_quality_gate.py --profile quick` 因此仍停在末尾 `npm test`。
- 无登录态 / 无 Chrome / 无模型凭据:浏览器级验收写成 `docs/testing/rectification-dead-d9-choice-fix-20260916.md`,未声称通过。
- staging 部署核对(`/api/health` 的 `deployment.gitCommit`)不在本单能力范围:只推分支,未推 `staging`。
- 事发 Case 的 04:48–04:59 仍建立在错分答案上,不要采用(与上一单结论相同)。
@@ -0,0 +1,94 @@
# 进度记录 · 校正页移动端:时间轴读数被裁、「跳到最新」压住选项(2026-09-17)
任务书:`docs/tasks/TASK-rectification-mobile-timeline-readout-20260917.md`
分支:`codex/rectification-mobile-timeline-readout-20260917`,基线 `origin/staging` = `c32f81e7`(任务书写的 `8d7dfbf0` 已被 staging 超车,按指令取 `c32f81e7`)。
BUG:918、919。纯前端样式,未跑 Python 预检(AGENTS §9 允许)。
未触碰(并行会话所有):`frontend/src/components/rectification-agentic-chat.tsx`、`frontend/src/lib/rectification-agentic/**`、Python、Skill。也未动时间轴固定高度(64 / 56px)与 `use-conversation-scroll-anchor`。
## 1. 做了什么
| 任务 | 文件 | 改动 |
| --- | --- | --- |
| T1 BUG-918 | `frontend/src/app/globals.css` | `@media (max-width: 767px)` 段:`.rectification-timeline` 的 `padding-inline` 由 `calc(var(--space-4) + var(--assistant-content-inset))` 改为 `var(--space-4)`;`.rectification-timeline__readout { column-gap: var(--space-2) }`;`.rectification-timeline__dated { display: none }`。桌面段未动。同时改掉基线注释里「四项短文本在 375px 下还有余量」这句被实证推翻的话。 |
| T1 BUG-918 | `frontend/src/components/rectification-timeline.tsx` | 只加注释:五个 span 永远无条件渲染,由 CSS 决定窄屏显示几项;轴 `aria-label` 仍由五项拼成;再加第六项必须重算宽度预算。无行为改动。 |
| T2 BUG-919 | `frontend/src/app/globals.css` | `.rectification-workspace__chat .message-list` 底部留白由 `var(--rectification-jump-clearance)` 改为 `calc(var(--rectification-jump-clearance) + var(--space-3))`;`.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)`。`--rectification-jump-clearance` 取值未变(时间轴 56px 条高仍与它同源,既有断言不受影响);浮层的位置、居中、可见条件未动。 |
| T3 | `docs/BUG_HISTORY.md`、`CHANGELOG.md`、`frontend/DESIGN.md`、`docs/testing/…`、本文件 | 记录。 |
## 2. 宽度预算(T1 的算术)
jsdom / happy-dom 不在本套件里,`scrollWidth` 与 `clientWidth` 都量不到,所以这是**按逐字保守估宽的算术**,不是测量。每字宽度一律向上取整(数字取 0.6em,而 -apple-system / Helvetica 的 tabular 数字约 0.556em),font-size 13px。测试里 `readoutWidthPx` 就是这套系数。
单项估宽(px):`04:48–05:07` 77.5、`20 分钟` 45.2、`代表分钟 04:53` 91.1、`已答 6 题` 54.1、`已对照 3 件` 67.1。
| | 改前 | 改后 |
| --- | --- | --- |
| compact 每侧内边距 | 16 + 44 = 60px | 16px |
| 375px 可用内容宽 | 255px | 343px |
| 可见项数 / 列间距 | 5 项 / 12px | 4 项 / 8px |
| 读数需要宽度 | 383.0px | 291.9px |
| 375px 余量 | **−128.0px(被裁)** | **+51.1px** |
| 390px 余量 | −113.0px | +66.1px |
| 320px 余量 | −143.0px | −3.9px |
375 与 390 两个目标宽度都放得下。**320px 差约 4px**:按系统实际字宽(数字 0.556em)重算是 283.3px ≤ 288px,能放下;按上面的保守系数差一点点。真机 320px 若尾部被裁一点属已知边界,已写进 `docs/testing/` 清单第「已知边界」段,范围和代表分钟在任何情况下都不会被裁。
让步顺序:没有用到。任务书让步 1(只做去 inset + 缩 gap)算下来是 5 项 335.0 + 4×8 = 367.0px > 343px,仍然放不下,所以第五项必须离开可见行;采用了任务书 T1 里的「只保留一项」分支,而不是合并成「N 题 · N 件」——合并需要原始计数,而计数只以 `已答 6 题` / `已对照 3 件` 这种成品字符串到达组件,要么去改 `frontend/src/lib/rectification-timeline-scale.ts`(超出本单文件范围),要么在组件里正则抠数字(脆弱)。隐藏这一项另有 DESIGN 依据,见第 4 节。
## 3. 「跳到最新」的几何(T2)
浮层占 composer 上方 56px 的带:按钮 44px + 浮层自身 `padding-bottom: var(--space-3)`。基线 `.message-list` 底部留白恰好也是 56px,与带严丝合缝,末条内容顶到按钮正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,而按钮居中,正落在选项行中央 → 「遮一半 + 中间点不动」。
改后:`.message-list` 68px + `.conversation` 16px = 静止时末条下方 84px = 56px 带 + 28px 空气。
**做不到、也没有假装做到的部分**:把「任何滚动位置都不重叠」做成保证,需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px。推导:设末条内容底为 E、尾部留白 P、距底 d,浮层带覆盖内容坐标 `[E+P-d-56, E+P-d]`,与真实内容重叠 ⟺ `d > P - 56`;而浮层可见 ⟺ `d > 96`。所以要让「浮层刚出现的那一刻」不压内容就得 `P - 56 ≥ 96`。152px 在 667px 高的手机上等于凭空吃掉一整行选项,且浮层只在读者已经主动向上滚过 96px 后才出现,所以按 CSS 注释记录理由后不做。本轮保证的是:**贴底静止时(浮层隐藏)末条选项整行可点且与输入框有 28px 空隙**;浮层可见时它浮在已滚上去的内容上,与两个聊天面一致,点一下即回到底。
未改浮层可见条件(任务书要求),也未把胶囊改回右对齐——`tests/rectification-agentic-entry.test.ts` 锁着 `justify-content: center`(BUG-478 的统一决策),右对齐是 2026-08-25 `7a4360d8` 的旧修法,已被取代,不擅自推翻。
## 4. 顺带发现的口径冲突(未擅自裁决,需产品拍板)
`frontend/DESIGN.md` §10 的「以下一律不得加回来」表里明确写着「已对照 N 件经历」不上条、仍归交付卡,理由是它和「已答 N 题」不是同一个量。但 `cfb41daf`(2026-09-16,BUG-740~743)把 `datedEventLabel` 加到了读数上,且没有改 DESIGN。也就是说这第五项从进条那天起就与 DESIGN 的红线矛盾,而它正是压垮 375px 宽度预算的那一项。
本轮**只在 767px 以下把它移出可见行**(任务书授权范围内),桌面保留原状,并把这处冲突写进 BUG-918 的「相关记录」、DESIGN §10 和真机清单,等产品决定桌面是否也撤掉。没有单方面改桌面。
## 5. 测试
新增 `frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx`(6 条):
1. 渲染断言:五项文本按序全在 DOM,且轴 `aria-label` 含全部五项。
2. 宽度预算断言:compact 可见四项 + 3×8px ≤ 343px(375)与 ≤ 358px(390);并反向断言旧口径 383px > 255px,把 Bug 的形状钉住。
3. compact 段声明断言:`padding-inline: var(--space-4)` 且段内不含 `--assistant-content-inset`;`column-gap: var(--space-2)`;`__dated` `display: none`;段内不得出现 `__range` / `__width` / `__working`(这两个数字任何宽度都不许隐藏或缩);不得放开 `flex-wrap` / `white-space`;条高仍 56px。段的切取是按大括号配平取出那一个 `@media` 块,不是 `slice` 到文件尾。
4. 桌面段断言:仍是 `calc(var(--space-8) + var(--assistant-content-inset))`,读数仍 `nowrap` + 13px。
5. T2 断言:留白表达式、`scroll-padding-block-end`。
6. 浮层未动断言:`absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 按钮 44px,以及 `conversationAnchorThreshold = 96` 与 anchor hook 里不出现 scroll-padding。
文件头写明了「本套件没有 DOM 实现,所以几何断言是算术不是测量,真机走查必做」。
改写的既有断言 1 条 —— `frontend/tests/rectification-timeline-20260909.test.ts`「the mobile bar stays within the 44px touch rhythm」:
- 原值:`assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/)`
- 新值:`assert.match(mobile, /padding-inline: var\(--space-4\);/)`
- 原因:BUG-918。这条断言锁的正是造成裁切的那 44px 头像沟槽。条不在消息列里,手机上对齐头像列没有可见收益;同一测试里桌面那条 inset 断言未动,`--rectification-jump-clearance` 断言未动。
没有弱化其它任何断言,没有删测试。
## 6. 门禁数字
| 项 | 基线(`c32f81e7`) | 改后 |
| --- | --- | --- |
| `./node_modules/.bin/tsc --noEmit` | — | **0 错** |
| `npm run lint` | — | **0 error**(116 warning,与基线同为既有 `no-unused-vars`,未顺手修) |
| `npm test` | 3414 tests / 31 fail | **3420 tests / 31 fail**(+6 新测试全绿) |
| 失败清单 | 31 条无 Docker 集 | **逐条相同**(排序后 `diff` 无输出) |
| `next build --webpack` | exit 0 | **exit 0** |
| `/` 路由 | `○ Static` | **`○ Static`** |
| 首屏 gzip | 620,106 B / 25 文件 | **620,128 B / 25 文件(+22 B, +0.004%)** |
- 失败清单 31 条全部是既有无 Docker / 无凭据集(database-*、staging sync、billing、ephemeris 401 等),与 `BLOCKED.md` 一致。
- gzip 口径:把 `.next/server/app/index.html` 里引用到的每个 `/_next/static/*.js|css` 单独 `gzip -9` 后求和。唯一变化是首屏 CSS 分块 36,510 → 36,532 B。
- Turbopack 拒绝软链 `node_modules`,按环境说明用 `--webpack`。
## 7. 环境缺口
- **无 Chrome、无真机**:375 / 390 宽下「读数四项完整」「胶囊不压选项」只能人工确认,清单在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`(7 条 + 已知边界)。第 2 节的宽度是算术,不是真机测量;第 3 节的 28px 空隙同样没有真机截图。
- **未部署**:本分支只推自己的分支,未推 staging,`deployment.gitCommit` 未核对。
+5 -2
View File
@@ -103,8 +103,9 @@
| `TASK-rectification-collection-redesign-20260910.md` | `PROGRESS-rectification-collection-redesign-20260910.md` | 产品口径:用户先说、系统从已说的事锚定追问、不得用生日推年份、不得说「领域」、永远交付结果。无固定题数:收集问题池按信息价值排序问到训练门开;选择题问到收敛门开或增益见底;门关不出结果只出精确缺口句并保持开放;≥4 件才留 holdout;撤回 BUG-642 年份线索、BUG-646 单终态与「暂定采用」;Skill 10.0.23(BUG-646~648) | 待验收 | `dd8f35f7` |
| `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84)**。主体默认本人不追问,Skill 10.0.29 | 已验收 | `6aabbe38`(BUG-908~911,Skill 10.0.29):tsc 0 / lint 0 error / npm test 3414 条 31 红与基线 05cf20fb 逐条相同 / `/` Static / 首屏 gzip 620,502→620,107(−0.06%)/ 快速门 Python 段 798 绿。两条小项(host 前置排在会话校验之前、`answer.host_year_entry` 未登记)并入 dead-d9 修复单 T2 |
| `TASK-rectification-dead-d9-choice-20260916.md` | `PROGRESS-rectification-dead-d9-choice-20260916.md` | 真机(6aabbe38):删卡流程已通,但**P0** D9 感情题没有自己的探针,`stampChoiceSchemaWithProbe` 退到最高增益探针,盖上了 **D24 学业六星座对照探针**(BUG-375 (1) 复发):答「没有」按 D24 计分,04:48–04:59 收窄不可信;GET 投影又匹配不上 → 无卡(BUG-912);打字答「没有」后平局直接出交付卡待回放定性(BUG-913);`NAKSHATRA_TRAITS` 整对塞进一个 option,卡头把相反两极列成共同点(BUG-914)。**串行在 year-focus-overlay 验收之后** | **验收未通过(P0)** | `227a7571`+`8d7dfbf0`:不借探针、一列一句、BUG-913 定性均过;但自建探针只在盖戳时注入内存 state,生产答题路径读持久化 state → `stale_probe` 报错、GET 无卡、idle 立刻 superseded(生产路径回放实证)。修复单见下一行 |
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过** | 待领取 | — |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 待领取 | — |
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
| `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 | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` |
### 聊天主链路与首页
@@ -128,6 +129,7 @@
| `TASK-rectification-tiebreak-card-loss-20260915.md` | `PROGRESS-rectification-tiebreak-card-loss-20260915.md` | **P0**:点卡上「再答两道参考题」交付卡消失(BUG-706);按钮亮但选项建不出变成裸题(BUG-708);旁白写「相对支持度」并与卡上入口打架(BUG-709)。卡上入口删除,出卡前收集,有活题时卡留下、采用置灰 | 待验收 | `codex/rectification-p0-20260915` |
| `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` |
| `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` |
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` |
### 个人报告
@@ -235,6 +237,7 @@
| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707–709 | 已验收(带修复单) | `d3a2c48b` |
| `TASK-readonly-pages-fix-20260916.md` | `PROGRESS-readonly-pages-fix-20260916.md` | 三份只读页单的验收修复:**BUG-710** 七政 `ketu_mode`/`sidereal_mode` 收了请求却从不传给引擎,`calculation.ketu_mode` 回写请求值而非实际值(实测请求 descending-node 仍返回 apogee 盘,无警告);**BUG-711** 星历单断言 sidebar 不得含 `/ephemeris`,与星盘单按任务书添加的入口直接冲突,staging 现在是红的;**BUG-712** `ephemeris_events` golden 存全精度浮点跨机不稳,且 golden 缺失时自动重建。另附部署缺口:`deployment.gitCommit` 仍是 `2d7698ea`。BUG 段 710+ | 待验收 | `codex/readonly-pages-fix-20260916` |
| `TASK-chart-page-blocking-open-20260915.md` | `PROGRESS-chart-page-blocking-open-20260915.md` | **P1**:星盘页开一次要等很久且常常只给一句「过一会儿再打开」。实测引擎五个调用合计 0.75 秒、mapper 13 种形态零抛出——瓶颈在 `/chart` 是动态路由 + 侧栏改成硬文档跳转,整页 SSR 等完 1 串 4 并才开始画,白屏最长 45 秒(BUG-716);`postEngine` 把 429/500/超时/坏 JSON 全碾成 `null` 且零日志,两种性质相反的故障共用一句文案(BUG-715);开页并行打两个重计算限流端点(配额 2)、无缓存,且「打开即有」印在失败页上(BUG-717)。**串行在 readonly-pages-fix 之后** | 待验收 | `codex/chart-page-blocking-open-20260915` |
| — | `PROGRESS-chart-copy-trim-20260917.md` | 产品口头:星盘页去掉「主盘直接算 · 分盘按需 · 不消耗点数」和「下面是词条式释义,不是对你个人的判断。」;星历页去掉「这一页是天象本身,不是对你的判断。」按需加载与不扣点的行为不变。跟进 BUG-717 | 待验收 | `codex/chart-copy-trim-20260917` |
| `TASK-rectification-title-repair-migration-20260915.md` | `PROGRESS-rectification-title-repair-migration-20260916.md` | BUG-699 / 704 的数据修补写成了 Node 脚本(要 `SCHEMA_DATABASE_URL`),但 `Migrate Staging Database` 只跑 `migrator` 应用 SQL 迁移、不执行任意脚本——产品没有任何按钮能修自己那批错名字的会话。脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成按钮。生产停在 `7b620c7a`(无 `use-rectification-surface.ts`),where 自然匹配 0 行,是 no-op | 待验收(`d6c359b2`,分支 `codex/rectification-title-repair-migration-20260916`):迁移 `20260916020000_rectification_session_title_repair.sql`,SQL 由 `sed` 从脚本切出、合同测试比对逐字一致;幂等两层(迁移器记账 + where 改完即不再命中);顺带给 `db-migrate.mjs` 加 NOTICE 转发,否则 node-postgres 丢掉 NOTICE、产品在日志里一个数字都看不到。tsc 0 错、lint 0 error、`npm test` 3334/fail 31 对基线 3329/fail 31(+5 全绿)。**无 Docker/无本地 PG**:`test:db` 没跑、`db:migrate:check` 缺 `SCHEMA_DATABASE_URL`,记为 BLK-002 | `codex/rectification-title-repair-migration-20260916` |
| `TASK-staging-dispatch-autofill-sha-20260915.md` | `PROGRESS-staging-dispatch-autofill-sha-20260915.md` | `Migrate Staging Database` 每次都要手抄 40 位 SHA,而那个值恰恰是「最新一个过门禁的 staging 提交」——机器能自己算,查询代码那一步里就有。改成留空自动解析、填了仍走原路径(回滚用),三条安全属性一条不丢。**产品 2026-09-15 明确授权修改该 workflow,执行方不得以 AGENTS.md §2.7 拒改**;生产两个按钮保持手填,那是护栏不是麻烦 | 待验收 | `codex/staging-dispatch-autofill-sha-20260915` |
| `TASK-staging-auto-migrate-on-deploy-20260915.md` | `PROGRESS-staging-auto-migrate-on-deploy-20260915.md` | 门禁通过后自动先跑 staging 迁移再部署,不再手点(迁移幂等、无挂起时是 no-op,`db-migrate.mjs --check` 挂起返 3 可用于日志)。今天 `deploy-staging.yml` 完全不提迁移,忘点就让新代码跑在旧 schema 上且无人拦。**产品再次授权改 workflow,范围限 `backend-quality-gate.yml` 的 dispatch 段**;迁移失败必须阻断部署;回滚不自动迁移;生产完全不动。⚠️ 同轮必须把「迁移须对已部署代码向后兼容、破坏性变更拆两轮」写进 AGENTS.md §7.6 | 待验收 | `codex/staging-auto-migrate-on-deploy-20260915` |
@@ -0,0 +1,126 @@
# 任务书 · 咨询追问轮不调排盘工具即整轮失败:提示词与运行合同对齐(2026-09-17)
## 0. 基线
- 基线 commit:`1b508d5e`(`origin/staging` head)。
- 分支:`codex/consult-followup-tool-contract-20260917`,`git worktree add -b codex/consult-followup-tool-contract-20260917 .worktrees/consult-followup-tool-contract-20260917 origin/staging`。
- 范围:只有前端(`frontend/src/mastra/index.ts`、`frontend/src/mastra/consultation-tools.ts`、`frontend/src/app/api/consult/route.ts` 与对应测试)。不动 Python、不动迁移、不动 Skill、不动生时校正 Agent。
- BUG 段:**BUG-922 起**(基线 `docs/BUG_HISTORY.md` 最大号 BUG-921,开工时再核对一次)。
## 1. 事故实证
产品负责人 2026-09-17 在 staging 一个申报出生时段(`declared_birth_window`)会话里连发三轮,三轮都没有得到回答。第三轮的事件流原样如下(已去掉会话与用户标识):
| 事件 | 内容 |
| --- | --- |
| 请求 | `consultationMode: declared_birth_window`,`theme: general`,`question: 你在说什么鬼`;服务端历史只有两条用户消息「请帮我看看我家庭关系的整体模式和特点」「?」,**没有任何助手消息** |
| `skill.started` / `skill.completed` | `jyotish-vedic-astrology` |
| `activity` | `phase: loading-method`,「正在补齐方法与计算步骤」 |
| `run.failed` | `code: runtime_contract_incomplete`,「Agent 未完成必要的方法与计算步骤,本次不会扣点。」 |
| 回执 `steps` | 只有两步:`skill` 与 `validation: runtime-contract-retry`,**没有任何 `tool` 步骤** |
| 回执 `workflow` | `route: declared-birth-window`,`status: blocked`,`techniqueTruth: declared-window` |
代码定位(按符号):
- 门禁:`frontend/src/lib/stream-agent-response.ts` 的 `contractReady()`。`requireTool` 为真时要求 `consultationToolCompleted && consultationToolSuccessCount === 1`。第一轮不满足则调用 `options.retry()` 再跑一轮,仍不满足即 `throw new Error("runtime_contract_incomplete")`,模型已写出的文字全部丢弃(`consumeAttempt` 在契约未绿时不释放文本,见 BUG-286 与测试「text written before the contract completes is dropped」)。
- 申报时段路线:`frontend/src/app/api/consult/route.ts` 中 `shouldRunDeclaredWindowWorkflow` 分支调用 `streamAgentResponse({ requireTool: true, retry, ... })`,`retry` 的追问原文是「运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答」。
- 窗口 Agent 系统指令:`frontend/src/mastra/index.ts` 的 `windowJyotishInstructions`,含这一句:**「For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.」**
- 本命 Agent 系统指令:同文件 `jyotishInstructions`(第 17 行起,含 `natalSpokenReportContract` 之后的第 6 行)含同构的一句:**「For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.」**
- 工具选择:两条路线都是 `toolChoice: "auto"`。本命路线 `consultationNatalPrepareStep`(`frontend/src/mastra/consultation-tools.ts`)在第 0 步只把 `activeTools` 收窄到排盘工具,但没有强制调用;申报时段路线用的是不带 `prepareStep` 的 `streamOptions`。
- 历史为什么只剩用户消息:用户消息在扣点预留 RPC 里就写库(`route.ts` 的 `p_question_message: { role: "user", text: visibleQuestion }`),运行失败不补助手消息。所以前两轮「家庭关系」「?」也都失败了。
## 2. 根因
**系统提示词与运行合同互相矛盾。** 提示词明确允许模型对「简单的对话式追问」不调排盘工具、复用「已有 packet / context」;服务端合同却要求每一次请求都必须恰好一次成功的排盘工具调用,否则整轮作废。像「?」「你在说什么鬼」这类追问,模型按提示词判断为对话式追问、直接用文字回答,门禁判定合同未完成;服务端追问一句后模型仍按同一套系统指令行事,第二轮也不调工具,于是 `runtime_contract_incomplete`。
提示词里的「existing packet / existing context」还是**事实错误**:排盘结果只在单次请求内缓存(`createWindowConsultationTools` / `createConsultationTools` 的闭包变量 `calculation`),跨请求根本没有可复用的 packet。模型信了这句话就会跳过工具。
这两句是 2026-08-21 提交 `9958e00a` 写进去的,门禁是 2026-08-17 提交 `7886629b` 定的,两者从未对齐。Bug 历史里 `runtime_contract_incomplete` 的既有记录(BUG-205 缓存拒绝、BUG-214 失败次数计入门禁、BUG-286 契约未绿文本丢弃)都不是这个原因,防复发措施均仍在,本单是新问题,不是复发。
第一轮「请帮我看看我家庭关系的整体模式和特点」是正经解盘问题,它为什么失败**本单不下结论**,见 T4。
## 3. 决策记录
- 产品负责人 2026-09-17 拍板 **方案 1**:删掉「简单追问可以不调工具」的例外,**每一轮回答前都必须调用排盘工具**。理由:工具在单次请求内有缓存、代价小;每轮都有当轮证据,符合「解盘必须有原始数据」红线。
- **否决方案 2**(保留提示词、让没调工具的轮次走不扣点的纯对话回复):追问轮会没有星盘证据,与 AGENTS.md Part B 的证据要求冲突。
- 不推翻任何既有红线:`contractReady()` 的「恰好一次成功调用」口径不放宽;BUG-214 允许失败尝试后重试成功的语义保持;BUG-286 契约未绿即丢弃文本的语义保持。
- 生时校正 Agent(`agentic-rectification.ts`)不在本单范围,它有自己的合同。
## 4. 硬红线
1. 不改 `contractReady()`;不改 `retry` / `retryForAnswer` 的触发条件;不给失败轮扣点。
2. `page.tsx` 与 `jyotish_api_server.py` 不动(AGENTS.md §6)。
3. 既有测试断言不得静默弱化;改任何断言写「原值 / 新值 / 原因」三栏。
4. 不顺手改 Skill 文件、不 bump Skill 版本(本单只改 Agent 系统指令与 SDK 调用参数)。
5. Bug 历史与进度记录不得写入会话 ID、用户 ID、出生资料。
## 5. 任务分解
### T1 删掉两处「简单追问可跳过工具」例外(BUG-922)
- `windowJyotishInstructions`:把「For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.」改为「Call run-jyotish-window-consultation before answering **every** turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.」
- `jyotishInstructions`:同样改「For questions that require a new chart claim… Simple conversational follow-ups may use the existing context.」为「Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.」
- 全文再 grep 一次 `follow-ups may use`,确认 `frontend/src` 无残留。
- 验收:
- 在 `frontend/tests/consultation-birth-time-mode.test.ts`(它已经读窗口 Agent)或 `consultation-agentic-runtime.test.ts` 加契约:两段指令 `doesNotMatch(/follow-ups may use the existing/)` 且 `match(/before answering every turn/)`。
- 既有「personal Agent exposes the Jyotish Skill and named server tool」等测试原样通过。
### T2 第 0 步强制调用排盘工具(BUG-923)
提示词只是软约束,门禁是硬约束,中间应该有一层确定性的 SDK 参数把二者钉在一起。
- `consultationNatalPrepareStep`:第 0 步在 `activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID]` 之外把 `toolChoice` 改为 `"required"`;后续步骤保持 `"auto"`。
- 申报时段路线:新增 `consultationWindowPrepareStep`(同文件、同形状:第 0 步 `activeTools: ["run-jyotish-window-consultation"]` + `toolChoice: "required"`),并在 `route.ts` 的申报时段分支把 `streamWithOverflowRetry(agent)` 与 `retry` 改为使用带该 `prepareStep` 的选项对象(照本命分支 `natalStreamOptions` 的写法建 `windowStreamOptions`)。
- `retryForAnswer` / `continueAfterLength` / `composeSection` 不带强制(它们是在合同已绿之后跑的,本命分支的 `composeSection` 明确 `toolChoice: "none"`,不动)。
- 开工先确认本仓 Mastra / AI SDK 版本的 `prepareStep` 接受 `toolChoice: "required"`(查 `node_modules/@mastra/core` 与 `ai` 的类型定义,写进进度记录)。
- 验收:
- `consultation-agentic-runtime.test.ts` 既有「natal first step exposes only the chart calculation tool」扩成断言第 0 步 `toolChoice === "required"`、第 1 步 `"auto"`;为窗口 `prepareStep` 加对称的一条。
- 加一条 `route.ts` 源码契约:申报时段分支的 `agent.stream(` 调用与 `retry` 使用带 `prepareStep` 的选项(对齐 `consultation-stream-recovery.test.ts` 那类结构断言的写法)。
- 已有的「a calculation that succeeds only after failed attempts still satisfies the contract」「text written before the contract completes is dropped」原样绿。
### T3 记录
- `docs/BUG_HISTORY.md` 新增 BUG-922、BUG-923:现象、触发条件(申报时段 / 本命模式下发短追问)、根因(提示词例外 vs 合同)、修复、验证、防复发(契约测试 + 第 0 步 required)、关联 BUG-205 / 214 / 286。
- `CHANGELOG.md` 一条:追问、反问、短句也会先重新取回本轮星盘证据再回答,不再整轮失败。
- `docs/tasks/PROGRESS-consult-followup-tool-contract-20260917.md`:测试数字、失败清单与基线逐条比对、gzip 前后、`prepareStep` 类型核对结果。
### T4 第一轮失败原因取证(不占 BUG 号,写进进度记录)
第一轮是正经解盘问题,不能用 T1 的根因解释。回执只写到 web 容器控制台(`frontend/src/lib/agent-observability.ts` 的 `console.info("[agent-observability]", …)`),不进数据库。执行方**做不了**(无 VPS 登录态),把下面的取证方法原样写进进度记录,留给产品负责人或部署侧:
```bash
# 在 VPS 上,以 deploy 用户
cd /opt/jyotisha-production
docker compose -f deploy/docker-compose.server.yml logs web --since 2026-09-17T00:00:00 \
| grep agent-observability | grep '<该会话的 sessionId>' | head
```
三条 `run.failed` 各自的 `steps` 若都没有 `tool` 步骤,即同一根因、本单覆盖;若第一轮有 `tool` 步骤且 `status: failed`,另立单,不得并进本单。
## 6. 让步顺序
1. T2 若本仓 SDK 版本的 `prepareStep` 不接受 `toolChoice: "required"`(类型或运行时报错),退而只做 T1,并在进度记录与 `BLOCKED.md` 写明版本与报错原文;不得顺手升级依赖。
2. T2 若 `required` 让本命第 0 步在 `generalDailyContext`(首页今日)路径误触发(那条路径 `requireTool: false`,用的是 `getGeneralJyotishAgent`,理论上不经过 `natalStreamOptions`),先用测试证明,再决定是否只对申报时段路线加 required。
3. T1、T3 不可让步。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/consult-followup-tool-contract-20260917 .worktrees/consult-followup-tool-contract-20260917 origin/staging
cd .worktrees/consult-followup-tool-contract-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线总数与失败清单
grep -o "BUG-[0-9]\+" ../docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1 # 应为 BUG-921
grep -rn "follow-ups may use" src # 应恰好 2 处
```
验收口径:`tsc` 0 错;lint 0 error;`npm test` 失败清单与基线逐条一致(无 Docker 的那组不算新增)且总数不减;`next build` 后 `/` 仍 Static;首屏 gzip ±2%(本单不动客户端 bundle,应为 0 变化)。
## 8. 环境缺口(留给产品负责人真机)
部署后(先核对 `/api/health` 的 `deployment.gitCommit` 等于本单合入 staging 的 SHA)在申报时段模式的会话里:
1. 先问一个正经问题(例如「看看我事业上的整体特点」),应正常出回答。
2. 紧接着发「?」,再发「你在说什么鬼」。两轮都应先出现「正在比较声明出生窗口内的稳定层」再出回答,**不得**出现「Agent 未完成必要的方法与计算步骤」。
3. 换到本命模式(有出生分钟)的会话重复第 2 步,同样不得整轮失败。
4. 同一会话刷新页面后,历史里应能看到助手回复,不再是只有用户消息。
@@ -0,0 +1,62 @@
# 任务书 · iPhone 上键盘收起 / 刷新后整页上移,顶栏点不到(2026-09-17)
## 0. 基线
- 基线 commit:`73d3c99c`(staging 已部署)。分支:`codex/mobile-viewport-scroll-lock-20260917`。
- 纯前端;不动 `page.tsx`(新逻辑放 `frontend/src/components/` 的一个客户端组件,从 `layout.tsx` 挂载)。
- BUG 段:**BUG-920 起**(基线最大 BUG-919)。
## 1. 事故实证(产品负责人 2026-09-17 09:34 iPhone Safari 截图,staging `73d3c99c`)
- 刷新校正页后,顶栏(侧栏按钮 / 「9月17日 · 生时校正」/ 当前盘面 / 点数)和时间轴都不在屏幕里,第一条可见内容是消息正文;输入框下面空出约 260px 的白底,浏览器工具栏之上什么都没有。产品原话:「点不到 tab 栏,没找到入口,整体上移了」。
- 空白高度 ≈ 整页上移的距离:整个应用壳被 `window` 级滚动推上去了,不是壳内某个容器的问题。
## 2. 根因(代码可证部分 + iOS 行为推断,执行方需真机确认)
1. `globals.css:322` `html, body { height: 100%; overflow: hidden; }`,应用壳靠 `.chat-app` / `.chat-panel` 的 `height: 100%` 撑满;**没有任何代码处理 `window.scrollY`**:`git grep` `visualViewport` / `scrollRestoration` / `window.scrollTo` 在 `frontend/src` 0 命中(`use-billing-panel.ts:195` 的 `scrollIntoView` 是容器内滚动)。
2. iOS Safari 不支持 `viewport` 的 `interactive-widget=resizes-content`(`layout.tsx:26` 只对 Chrome Android 生效)。iOS 上键盘弹出时**布局视口不变、Safari 直接滚动 window** 让聚焦的输入框露出来;`overflow: hidden` 挡不住这次程序滚动,却挡住了用户手指把页面拉回来。键盘收起后 `window.scrollY` 留在原地 → 顶栏在屏幕上方、底部空白。
3. 刷新不能自愈:Safari 对同 URL 的 reload 默认恢复滚动位置(`history.scrollRestoration === "auto"`),`scrollY` 被原样还原,所以「刷新想测试」看到的还是上移后的页面。
4. 与今天上线的两单无关:BUG-919 只改了 `.message-list` 底部留白和 `.conversation` 的 `scroll-padding-block-end`,都在滚动容器内部;BUG-918 只改时间轴内边距。这条是既有缺陷,今天因为在手机上连续输入才被看到。
## 3. 决策记录
- 产品:手机上任何时候顶栏都必须能点到;不接受「用户自己想办法滚回去」。
- 不改 `html, body` 的 `overflow: hidden`(桌面与滚动锚定依赖它),只补 window 级复位。
## 4. 任务分解
### T1 window 级滚动复位(BUG-920)
- 新客户端组件 `frontend/src/components/viewport-scroll-lock.tsx`,在 `layout.tsx` 与 `StaleClientRecovery` 并列挂载,只在 `pointer: coarse` 或 `visualViewport` 存在时启用:
- 挂载时 `history.scrollRestoration = "manual"`,并 `window.scrollTo(0, 0)`;
- 监听 `window.visualViewport` 的 `resize` 与 `scroll`、`window` 的 `pageshow` / `orientationchange`、`document` 的 `focusout`:当 `visualViewport.height` 回到 ≥ `window.innerHeight - 1`(键盘已收)且 `window.scrollY > 0` 时 `window.scrollTo(0, 0)`;键盘打开期间(`visualViewport.height < innerHeight - 100`)不动,让 Safari 把输入框露出来。
- 卸载时移除监听。不用 `setInterval`。
- 验收:源码契约测试(挂载点、`scrollRestoration = "manual"`、监听事件集合、键盘打开期间不复位的判定);jsdom 下模拟 `visualViewport` 对象触发 resize 断言 `scrollTo(0,0)` 被调用一次;`tsc` / lint / `npm test` 失败清单同基线;`/` Static;gzip ±2%。
### T3 顶栏右侧两枚芯片同一套尺寸(BUG-921,产品 2026-09-17「积分块有点扁」)
- 实证:手机顶栏右侧「当前盘面」与积分「✦ 557」并排,积分块看起来又宽又矮。规则出处:`globals.css:885` `.credit-button` `min-height: 44px; min-width: 64px; padding: 0 var(--space-3); font-size: var(--type-caption)`(13px);`:2964-2973` 顶栏里的 `.rectification-board-peek` 同为 `min-height: 44px` 但 `padding: var(--space-2) var(--space-4)`、`strong` 用 `--type-body-sm`(14px)、圆角与边框另一套;两枚都塞在 `.chat-panel` 的 **46px** 顶栏行里(`:855-856`),上下各剩 1px,芯片没有呼吸空间,小字 + 强制 64px 最小宽度就显得扁。
- 修法:顶栏芯片抽一个共用类(或让 `.credit-button` 与顶栏里的 `.rectification-board-peek` 共用同一组声明):同高、同圆角、同边框、同字号(14px,数字 tabular)、同内边距,`min-width` 去掉;手机(`.is-compact` / ≤767px)顶栏行从 46px 放到 52px,芯片视觉高 40px、点击热区仍 ≥44px(用透明 `::before` 扩热区或外层 padding,BUG-695 的触屏命中区规则不得退)。桌面若同样受益可一并做,写进 `DESIGN.md` 顶栏一节。
- 验收:CSS 声明断言两枚芯片高度/字号/圆角一致、`.credit-button` 无 `min-width`;`chat-header` compact 高度断言;触屏热区契约测试(BUG-695 那条)仍绿;真机清单加一条「顶栏两枚芯片等高、不扁」。
### T2 记录
- BUG-921 同上记录。
- BUG-920(状态按真机结果:本机无 iOS 只能写 `resolved` 附「真机待核」或 `investigating`,不得编)、CHANGELOG、`frontend/DESIGN.md` 移动端一节加一句「window 级滚动由 ViewportScrollLock 复位」、`docs/testing/mobile-viewport-scroll-lock-20260917.md`:iPhone Safari 上 (a) 点输入框弹键盘→收起→顶栏仍可点;(b) 弹键盘状态下刷新→顶栏在顶;(c) 横竖屏切换后无空白带;(d) Android Chrome 不受影响。
## 5. 让步顺序
1. 若 `focusout` 时机早于键盘收起动画导致复位被 Safari 再次覆盖,改为在 `visualViewport.resize` 上做,`focusout` 只做兜底延时 300ms 一次。
2. T2 不可让步。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/mobile-viewport-scroll-lock-20260917 .worktrees/mobile-viewport-scroll-lock-20260917 origin/staging
cd .worktrees/mobile-viewport-scroll-lock-20260917/frontend && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20
```
## 7. 环境缺口
- 本机无 iOS / Chrome;根因第 2、3 条是按 iOS Safari 已知行为推断,执行方与产品在真机上按 §4 T2 清单确认。产品临时绕过:键盘收起后把页面往下拉一下不会有效(overflow hidden),需**关掉标签页重新打开**(不是刷新)才能复位。
@@ -34,6 +34,12 @@
- **本单默认按既有口径执行(b)**:分盘风格题不得作为 `distinguish_candidates` 全额计分。D9/D10 判别题只在引擎给出该领域带年份事件探针(`discriminating_event_probes` / `prospective_probes`)时以「某年前后有没有…」出题并计分;没有事件探针时不出风格题,直接走下一条线(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。风格描述只留在既有的平局参考题(±1)与交付卡列。产品若改口为 (a)「允许风格题全额计分」,在本单决策记录追加一行即可,执行方不得自行选边。
- 这条不改变 T1 的重建要求:事件探针来自引擎 state,本就在持久化里;自建探针的重建逻辑仍要做,供 (a) 与将来使用,但默认路径不再产生风格计分题。
## 1d. 决策记录(2026-09-17,产品负责人)
- **§1c 采用 (b)**:分盘风格题不得作为 `distinguish_candidates` 全额计分题。D9/D10 等分层判别题只在引擎给出该领域带年份事件探针时才出题(题面是「某年前后有没有…」,按该事件探针计分);没有事件探针时该题不出,计划直接走下一条线(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。风格描述只留在既有的 ±1 平局参考题与交付卡列。
- T1 的重建要求照做:`withOwnedDistinguishProbes` 在每个读 state 的地方按 receipt 确定性重建,保证 (b) 决策之前已落库的风格焦点可答、有卡、不被 idle 误 supersede。
- 执行方据此删除了「给风格题自建探针再出题」的生产入口(`attachVargaDistinguishIdentity` / `withFollowupOwnedProbe`),保留 `buildVargaDistinguishFields` + `conflictProbeFromFollowup` 供重建与将来 (a) 使用。
## 2. 修法(照 `withNakshatraBoundaryProbe` 的既有模式)
月宿边界探针也不在引擎 state 里,它的做法是三处都从 receipt 重建:`server-focus.ts:124`(盖戳)、`answer-choice.ts:591`(答题)、`inference-adapter.ts:235`(`previousInferenceFromReceipt` 之后)。自建分盘探针照抄:
+2 -2
View File
@@ -16,11 +16,11 @@
## 五个 Tab
6. 登录且出生资料齐全:点「星盘」后先看到外壳(返回、标题),不是白屏。主盘到达前可以出现「这一张盘还没拿到。」,不得出现 spinner、骨架或「正在加载」。
7. 成功页眉标是「主盘直接算 · 分盘按需 · 不消耗点数」。失败页没有眉标。点数余额不应因打开此页而变。
7. 成功页和失败页都没有眉标,没有「主盘直接算 · 分盘按需 · 不消耗点数」。点数余额不应因打开此页而变。
8. Tab 顺序:星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余。都能点。
9. 星盘 Tab:D1–D30 chip 可换;盘面是北印菱形盘;右侧有行星表(星体 / 星座与度数 / 宫位 / 状态)。
10. 桌面中宫是六行参数;把窗口收到手机宽度(≤767)后,中宫只剩岁差和交点两行,完整参数出现在盘下方。
11. 基础信息:每颗星一张卡,含 D1、星宿与足、D9;顶部有「词条式释义,不是对你个人的判断」;没有运势句。
11. 基础信息:每颗星一张卡,含 D1、星宿与足、D9;没有「词条式释义,不是对你个人的判断」;没有运势句。
12. 大运:Vimshottari 与 Chara Dasha(kn_rao 变体)并列,当前段高亮。底部有「只有两条同时指向同一段时间才算证据」。不要出现 Narayana 这个名字。
13. 西洋盘:若端点未上线,看到静态说明(回归黄道、不能换算),整页其余 Tab 仍可用。
14. 七政四余:若端点未上线,静态说明里必须有三条——宿度自角宿起算、计都派别、庙旺未闭合。
+2 -2
View File
@@ -6,7 +6,7 @@
1. 登录后点侧栏「星盘」。**大约 1 秒内**能看到页面外壳:返回对话、标题「星盘」。不要整页白屏等到引擎全部算完。
2. 主盘还没到时,等待句是「这一张盘还没拿到。」没有转圈、骨架或「正在加载」。
3. 主盘到达后北印盘立刻可见。出生资料行出现。成功眉标是「主盘直接算 · 分盘按需 · 不消耗点数」,没有「打开即有」。
3. 主盘到达后北印盘立刻可见。出生资料行出现。没有眉标,没有「打开即有」,没有「主盘直接算 · 分盘按需 · 不消耗点数」。
## 按需加载
@@ -18,7 +18,7 @@
## 失败文案
9. 失败页没有眉标,没有「打开即有」,没有「过一会儿再打开」。
9. 失败页没有眉标,没有「打开即有」,没有「过一会儿再打开」,也没有「不消耗点数」。
10. 若刚好赶上引擎忙(429):「算盘的服务正忙,稍等几秒再打开就好。」稍等再开,有机会好。
11. 其它失败:「这张盘算不出来,我们已经记录下来了。」不要暗示等一会儿就会好。
+1 -1
View File
@@ -21,7 +21,7 @@
2. **看:** 月相日或月宿至少有一项不同。
3. **看:** 五要素下方有一句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」
4. **看:** 当日行运段没有这句。
5. **看:** 页面没有任何运势判断(没有「适合 / 不宜 / 运势」这类话)。
5. **看:** 页面没有任何运势判断(没有「适合 / 不宜 / 运势」这类话)。底部没有「这一页是天象本身,不是对你的判断。」
## C. 事件列表降级形态
@@ -0,0 +1,9 @@
# 真机清单 · 手机顶栏可点、芯片不扁(2026-09-17)
在 iPhone Safari 的 staging 上走。本机无 iOS,下列条目留给产品负责人。
1. 打开一段对话或生时校正,点输入框弹出键盘,再收起键盘:顶栏(侧栏按钮、标题、当前盘面、积分)仍在屏幕顶部,点得到。
2. 键盘还开着时刷新同一 URL:顶栏仍在顶部,底部没有整页上移留下的空白带。
3. 横屏再竖屏:没有空白带,顶栏可点。
4. 顶栏右侧「当前盘面」与积分「✦ N」两枚芯片一样高、一样圆角,积分块不扁。
5. Android Chrome:弹键盘、收起、刷新,顶栏行为与修之前一样,没有新的跳动。
@@ -0,0 +1,15 @@
# 真机清单 · 风格题不再计分、死卡不再同屏(2026-09-17)
在 staging 已登录账号上走,用公开名人或虚构出生资料。上一单事发那段 04:48–04:59 **不要采用**(建立在错分上):补一件带年月的事触发重算,或新开一段校正。
自动化做不到的部分(无登录态、无 Chrome、无模型凭据)都在这里。前六条针对本单改动,第 7 条是旧问题的守门。
1. **风格题不再出现**:走到采集中段(已给 3~6 件带年月的经历),继续答题直到助手换线。正文里不得再出现「亲密关系里,你更接近哪一种相处方式?」「事业上你更接近哪一种」这类只讲性格、不讲年份的判别题。
2. **该问的还在问**:关系 / 事业 / 家人这些线仍然会以「20XX 年前后,有没有…」的形式出现,题干下面必须有可点的 A/B/C/D,点一下要有反应(出现「已记录…」并更新范围)。
3. **没有可点的题时的样子**:如果某一轮拿不到下一个问题,屏幕上应当是「没有拿到下一个问题」+「接着问」按钮,输入框占位是通用的「继续说你记得的人生经历,或回答刚才的问题…」。**不得**出现「灰色的四个选项」与「再说一件带年月的事」同屏。
4. **旧的死卡**(只有 09-16~09-17 之间做过校正的账号能复现):打开那段旧校正,最后一条助手消息里那张灰色四选项应当不再出现;缺口处是「没有拿到下一个问题」+「接着问」。点「接着问」应当拿到新的一问。
5. **手机上重看一遍第 3、4 条**:范围行、输入框占位、缺口提示三处文案必须彼此一致(09-17 的问题是在手机上看出来的)。
6. **跨会话不落轮**:在一台设备上打开同一段校正的旧标签页,另一台继续对话;旧标签页里发一句没有年月的短句(例如「记不清了」),不应当在新会话里多出一条助手回复;应当报会话不匹配或要求刷新。
7. **交付卡守门(上一单)**:若出三列交付卡,每一列性格只有一句;卡头共同点里不得同时出现一对相反的话(例如「希望两边都能说得过去」和「必要时会直接选边」)。
发现异常时请抄下:屏幕上那句话的原文、`GET /api/rectification/cases/<caseId>` 里的 `interview.current_question`、`choice_card`、`interview.session_outcome`、`stop_reason`(去掉出生资料再贴)。
@@ -0,0 +1,18 @@
# 真机清单 · 校正页移动端时间轴读数与「跳到最新」(2026-09-17)
在 staging 用受控账号、真机(不是桌面浏览器缩窗)走一遍生时校正。本机无 Chrome、无真机,以下全部做不了自动化,只能人工确认。不要把真实出生资料写进任何记录。
优先 375 CSS px(iPhone SE 2/3、13 mini),再复查 390(iPhone 12–16 基础款)。若手上只有 320 宽的老机(SE 1 代),第 2 条按「可接受被裁一点」看,见末尾说明。
1. **读数四项完整。** 进入校正、答到出现代表分钟之后,看上方那条时间轴第一行。应完整读出四项:`HH:MM–HH:MM`、`N 分钟`、`代表分钟 HH:MM`(或 `已采用 HH:MM`)、`已答 N 题`。**任何一项都不得出现省略号或半个字**,尤其不得再看到「已…」。
2. **读数不换行、条不变高。** 这一行必须是一行。条的高度在整个流程里不得变化(56px),末尾不得出现第二行或把下面的刻度挤出去。横屏、放大字号(iOS 设置 → 显示与亮度 → 文字大小最大档)各看一次:这两种情况下允许尾部被裁,但**范围和代表分钟必须仍然完整可见**。
3. **左边距与页面同源。** 读数和刻度的左起点应与页面左边距一致(约 16px),不再比正文多缩进一个头像位。桌面(宽 ≥ 768px)打开同一条校正,读数应仍然与助手正文左边线对齐,并且多出第五项「已对照 N 件」——手机上没有这一项是预期的。
4. **读屏软件仍读到五项。** 打开 VoiceOver,聚焦到时间轴的轴本体(`role="img"`),朗读内容应包含范围、宽度、代表分钟、已答题数**和已对照件数**,即手机上看不见的那一项在朗读里仍然存在。
5. **「跳到最新」不压选项。** 出现一张多行选项卡(判别题 A/B/C/D 或交付卡三列)之后,向上轻扫一段让「跳到最新」胶囊出现,再滚回底部。滚到底时:胶囊应消失,选项卡最后一行与输入框之间应有明显空隙(约 28px),最后一个选项**整行可点,中间也能点中**。
6. **胶囊出现时仍能操作。** 胶囊可见(已向上滚过一段)时点它,应直接回到最新内容,且回到底之后立刻能点最后一个选项。胶囊本身仍是居中的胶囊、44px 高、有阴影——位置和出现条件本轮没有改,不应有变化。
7. **回归面:普通对话不受影响。** 打开一段普通咨询对话(不是校正),向上滚出「跳到最新」,确认它的位置、外观和底部留白与改动前一致(本轮只改了校正面的留白)。
## 已知边界
- **320px 宽(iPhone SE 1 代 / iPhone 5s 一档)**:按逐字保守估宽,四项 + 间隙约 292px,可用约 288px,差约 4px。按系统实际字宽算是放得下的,但如果真机上「已答 N 题」尾部被裁一点,属已知边界而非回归——范围和代表分钟仍必须完整。看到就记进进度记录,不要当新 Bug 开单。
- **「已对照 N 件」是否也该从桌面撤掉**:`frontend/DESIGN.md` §10 的禁列表说这一项归交付卡、不上条,而代码后来把它加到了条上。本轮只在手机上把它移出可见行,桌面保留原状,等产品裁决。见 `docs/BUG_HISTORY.md` BUG-918。
+17 -10
View File
@@ -226,7 +226,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Jump to latest
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
- **Visibility:** shown when the reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors.
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
@@ -256,7 +256,8 @@ The birth-time rectification session is the consultation transcript plus a house
| `question-gap`, persisted question | only when no assistant message can carry the prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” |
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled |
| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap | enabled, placeholder “再说一件带年月的事” |
| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap. Never while the latest assistant message still carries an unanswerable tap question — that is the repair exit below (BUG-917) | enabled, placeholder “再说一件带年月的事” |
| `question-gap`, dead card on the last message | the latest assistant message carries a tap question with no answer and no live card (its focus was superseded, or GET has no card): the card is not drawn at all, and the gap is the repair exit “没有拿到下一个问题。” + “接着问”. A greyed, unclickable A/B/C/D never appears next to the collect-wait placeholder | enabled, generic placeholder |
| `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 |
@@ -318,9 +319,11 @@ The birth-time rectification session is the consultation transcript plus a house
### Chat header
- One line, 46px. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
- The chart a session analyses is now a quiet `--color-ink-tertiary` chip beside the title (`.chat-header-chart`), and it drops out below 768px rather than wrapping the header to two rows. "分析对象" was internal vocabulary; the chart name alone carries the same fact.
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring.
- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen.
### Navigation item
@@ -634,14 +637,18 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px;56px 等于既有的 `--rectification-jump-clearance`(44px 触控尺寸 + `space-3`),不新造常数。
读数和轴的水平内边距对齐助手列:桌面 `space-8 + --assistant-content-inset`,767px 以下 `space-4 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
读数和轴的水平内边距在桌面对齐助手列:`space-8 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。**767px 以下只取 `space-4`,不加头像缩进**(BUG-918):条不在消息列里,手机上对齐头像列买不到任何可见收益,而那 44px 每侧要吃掉 375px 里的 88px,读数因此被裁。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。读数四项、一行排完:
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。手机上读数四项、一行排完:
`05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题`
**读数只增宽,不增高。** 条高仍是硬规定二的 64 / 56px,`.rectification-timeline__readout` 因此是 `flex-wrap: nowrap` + `white-space: nowrap`;真放不下时由条本身的 `overflow: hidden` 裁掉尾部,四项里「已答 N 题」排最后、最先被裁——它是背景信息,区间和代表分钟不是。
**一行是有宽度预算的,767px 以下必须按 375px 算,不是「应该放得下」。** BUG-918 的实证:13px 下四项 + 头像缩进已超 375px,第四项被自身 `text-overflow: ellipsis` 显示成「已…」。因此 compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)做三件事:`padding-inline` 去掉头像缩进、读数 `column-gap` 收到 `space-2`、`.rectification-timeline__dated` `display: none`。按逐字保守估宽,手机上四项 + 3×8px 间隙约 292px,375px 可用 343px、390px 可用 358px;320px 只余约 −4px,靠条自身 `overflow: hidden` 兜底。**再往条上加一项,先重算这笔预算,不是直接追加一个 span。** 组件永远无条件渲染全部 span,隐藏只在 CSS 里发生,轴的 `aria-label` 仍由全部标签拼成,读屏软件不因视口宽度丢内容。
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-agentic-chat.tsx` 算一次、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。
@@ -725,7 +732,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
### 结构
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行,眉标「主盘直接算 · 分盘按需 · 不消耗点数」跟在 Tab 行下方作为一行静默说明。等待外壳和失败页只有原因/等待句,没有眉标。不得写「打开即有」或任何速度承诺。
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。
五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。五个等重的药丸按钮会读成五个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。
@@ -755,7 +762,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
- 西洋盘:回归黄道,宫位 Placidus;右栏第一张是边界卡。端点未上线时 Tab 仍在,渲染静态说明,整页不失败。
- 七政四余:宿度自角宿初度起算。必须同时出现计都派别与庙旺未闭合。端点未上线时同样只渲染静态说明。
「传统象征」是词条式释义,带边界句「不是对你个人的判断」,不得写成运势。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。
「传统象征」是词条,不得写成运势。页面上不另写「词条式释义,不是对你个人的判断」——行星卡本身已经是度数、星宿、D9,没有判断句。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。
### 等待态
@@ -773,14 +780,14 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。
- **结构:** 日期条;今日五要素;当日行运;未来九十天;收尾一句。顺序固定。
- **结构:** 日期条;今日五要素;当日行运;未来九十天。顺序固定。页尾不另写定性句。
- **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
- **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。
- 切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」
- **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。
- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
- **出口:** 收尾句「这一页是天象本身,不是对你的判断。」原文不变,但不再是底部第四张卡——它是页尾一行三级色小字。一句话的卡片跟三张装数据的卡片等重是错的。主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。
- **出口:** 主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。不写「这一页是天象本身,不是对你的判断。」
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。
- **文案:** 这一页是天象本身。不得写运势结论,不得把三套坐标系换算或叠加。
- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。不另印「这是天象本身」这类边界说明。
+3 -1
View File
@@ -63,7 +63,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 吻合率低时模型追问要不要把误差放到 ±2 小时 | 按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次? | 放宽卡由服务端出,选项写「前后半小时 / 一小时 / 两小时」,不用偏移、误差、置信度、概率。 |
| 只知道傍晚时直接给分钟或采用卡 | 先切成三段(写起止钟点),比完再按分钟收。分不开就说「时段分不开,直接按分钟比」。 | 超过两小时的窗口先走子段,不在 4~6 小时上铺分钟卡。 |
| 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 |
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 | 主盘直接算 · 分盘按需 · 不消耗点数 | 不承诺打开速度。失败页不写眉标。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
## 服务端探针 → Agent 题干
+9 -4
View File
@@ -48,6 +48,7 @@ import {
consultationContinueGenerationSettings,
consultationGenerationSettings,
consultationNatalPrepareStep,
consultationWindowPrepareStep,
consultationSliceGenerationSettings,
createConsultationAgentContext,
createWindowConsultationAgentContext,
@@ -896,14 +897,18 @@ export async function POST(request: Request) {
...streamOptions,
prepareStep: consultationNatalPrepareStep,
};
const windowStreamOptions = {
...streamOptions,
prepareStep: consultationWindowPrepareStep,
};
async function streamWithOverflowRetry(
agent: {
stream: (
messages: typeof baseMessages,
options: typeof streamOptions | typeof natalStreamOptions,
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions,
) => Promise<{ fullStream: AsyncIterable<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
},
options: typeof streamOptions | typeof natalStreamOptions = streamOptions,
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions = streamOptions,
) {
try {
const result = await agent.stream(baseMessages, options);
@@ -1029,7 +1034,7 @@ export async function POST(request: Request) {
state,
});
const agent = getWindowJyotishAgent(selectedModel, agentContext);
const result = await streamWithOverflowRetry(agent);
const result = await streamWithOverflowRetry(agent, windowStreamOptions);
const retry = async () => {
const retried = await agent.stream([
...baseMessages,
@@ -1037,7 +1042,7 @@ export async function POST(request: Request) {
role: "user" as const,
content: "运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答;不要在工具参数中添加出生分钟。",
},
], streamOptions);
], windowStreamOptions);
usages.push(retried.totalUsage);
return retried.fullStream;
};
+103 -27
View File
@@ -882,7 +882,51 @@ button:disabled { cursor: default; opacity: .45; }
content: "";
}
.chat-header-actions button > svg { width: 14px; height: 14px; }
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); cursor: pointer; font-size: var(--type-caption); font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
/* Header chips share one size. `.chat-header-actions button` is 32px and
more specific than `.credit-button`, which is why the credit pill looked
shorter than 「当前盘面」(BUG-921). */
.chat-header-actions .credit-button,
.chat-header-rectification .rectification-board-peek {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 40px;
min-height: 40px;
padding: 0 var(--space-3);
gap: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-weight: 500;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.chat-header-actions .credit-button::before,
.chat-header-rectification .rectification-board-peek::before {
position: absolute;
inset: -2px;
content: "";
}
.credit-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0 var(--space-3);
cursor: pointer;
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
transition: background-color 120ms ease-out, transform 120ms ease-out;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
color: var(--color-ink-secondary);
font-weight: 500;
}
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
/* Empty state. The greeting is bottom-aligned inside the conversation row and
@@ -1892,8 +1936,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
[data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); }
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
[data-sidebar="rail"] { display: none; }
.chat-panel { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
.conversation.is-empty {
display: block;
@@ -1985,12 +2030,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.chart-page-eyebrow {
width: min(1100px, 100%);
margin: 0 auto var(--space-6);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.chart-page-body,
.ephemeris-body,
.report-center-body,
@@ -2538,9 +2577,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
/* One line, always. The bar's height is fixed on purpose (see above), so the
readout may grow wider but never taller; wrapping would push the axis out of
a box nothing is observing. Four short items clear 375px with room to spare,
and the bar's own `overflow: hidden` clips rather than reflows if they ever
do not. */
a box nothing is observing. The bar's own `overflow: hidden` clips rather
than reflows if the row ever does not fit.
This used to claim "four short items clear 375px with room to spare". They
did not even at four, and a fifth was added later (BUG-918). At 13px, with
the avatar-column inset, a 375px phone left 255px of content box: the four
items needed about 304px, the five about 383px. So `已答 N 题` came through
as `已…` and `已对照 N 件` never appeared at all. The compact block below is
what makes the row fit — it drops the inset and the fifth item there, for
about 292px against 343px. Anything added here has to be re-measured against
that budget, not assumed to fit. */
.rectification-timeline__readout {
margin: 0;
display: flex;
@@ -2656,17 +2703,56 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-workspace__chat {
--rectification-timeline-height: 56px;
}
/* No `--assistant-content-inset` here (BUG-918). The bar is not in the
message list, so on a phone the 44px avatar gutter buys no alignment the
reader can see; it cost 88px of the 375px the readout has to fit into and
the row got clipped instead. The page margin is the honest edge on a
narrow viewport, and the axis reads wider for it. */
.rectification-timeline {
padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
padding-inline: var(--space-4);
}
/* Tighter column rhythm buys another 12px across three gaps; the row is a
list of short figures, not prose, so space-2 still separates them. */
.rectification-timeline__readout {
column-gap: var(--space-2);
}
/* `已对照 N 件` is the one item DESIGN.md §10 never wanted on the bar — it
belongs to the delivery card, and it is a different quantity from
`已答 N 题` (events matched, not questions answered). It is the fifth item
in a row budgeted for four, so on a phone it leaves the visual row. It
stays in the DOM and in the axis `aria-label`, so assistive tech still
reads it, and the range plus the working minute — the two figures the
product requires to be visible at all times — never shorten. */
.rectification-timeline__dated {
display: none;
}
}
/* `--rectification-jump-clearance` is the band the floating 跳到最新 chip
occupies above the composer: its 44px touch target plus the `space-3` the
overlay holds under itself. The transcript reserves that band **plus another
`space-3`** (BUG-919). Reserving exactly the band left the last tappable
option flush against the chip, so a choice card's final row read as covered
and its centre — the only part of the row the chip's `pointer-events: auto`
button actually intercepts — was hard to hit.
With `.conversation`'s own `space-4` the tail now keeps 84px of clear space:
56px of chip band and 28px of air. Eliminating overlap in *every* scroll
position would need `conversationAnchorThreshold` (96px) on top of the band,
i.e. 152px of trailing void under the last message at rest — a whole option
row of dead space on a 667px phone, and the chip only shows once the reader
has scrolled up past that threshold anyway. The chip's own placement and
visibility rule are unchanged (BUG-478 owns them). */
.rectification-workspace__chat .conversation {
--rectification-jump-clearance: calc(44px + var(--space-3));
padding-bottom: var(--space-4);
/* Anything scrolled into view lands above the chip band too. This is inert
for `use-conversation-scroll-anchor`, which assigns `scrollTop` directly
rather than calling a scroll-into-view API. */
scroll-padding-block-end: var(--rectification-jump-clearance);
}
.rectification-workspace__chat .message-list {
width: 100%;
padding-bottom: var(--rectification-jump-clearance);
padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3));
}
.conversation.is-rectification .message-actions {
margin-inline-start: var(--assistant-content-inset);
@@ -2915,15 +3001,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
display: none;
}
.chat-header-rectification .rectification-board-peek {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: auto;
max-width: min(11.5rem, 42vw);
min-height: 44px;
margin: 0;
padding: 0 var(--space-3);
gap: var(--space-2);
}
.chat-header-rectification .rectification-board-peek strong,
.chat-header-rectification .rectification-board-peek span {
@@ -2932,6 +3013,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: inherit;
font-variant-numeric: tabular-nums;
}
@media (max-width: 480px) {
.chat-header-rectification .rectification-board-peek span {
@@ -4762,8 +4845,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
/* ============================================================
Ephemeris page — facts only, own scroll (html/body overflow hidden)
============================================================ */.ephemeris-hero,
.ephemeris-section,
.ephemeris-outro{
.ephemeris-section {
width: min(900px, 100%);
margin-inline: auto;
}
@@ -4915,12 +4997,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.ephemeris-outro {
margin: 0;
padding-bottom: var(--space-4);
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.ephemeris-ask { min-height: 44px; width: fit-content; }
.ephemeris-message {
display: flex;
+2
View File
@@ -3,6 +3,7 @@ import localFont from "next/font/local";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import { ViewportScrollLock } from "@/components/viewport-scroll-lock";
import { themePreferenceBootScript } from "@/lib/theme-preference";
// Vendored Inter latin variable (OFL). Do not switch back to next/font/google;
@@ -53,6 +54,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
</head>
<body>
<StaleClientRecovery />
<ViewportScrollLock />
{children}
<Toaster />
</body>
@@ -4,7 +4,6 @@ import { formatDegree } from "./chart-format";
export function ChartBasicsTab({ view }: { view: ChartViewOk }) {
return (
<div className="chart-page-basics">
<p className="chart-page-boundary">{view.vedic.symbolBoundary}</p>
<div className="chart-page-basics-grid">
{view.vedic.planets.map((planet) => (
<article key={planet.name} className="chart-page-planet-card">
@@ -89,11 +89,6 @@ export function ChartPageView({
</button>
))}
</div>
{/* BUG-717 copy: a cost and speed promise that also prints on the
failure page. It used to sit in the hero as an eyebrow; the hero is
gone, the promise is not. It reads under the tab row now, because
the tab row is what the header hands off to. */}
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
<section className="chart-page-panel" role="tabpanel">
{tab === "vedic" ? (
<ChartVedicTab
@@ -272,11 +272,6 @@ export function EphemerisView(props: {
</ol>
)}
</section>
{/* The closing note used to be a fourth boxed card competing with the
three that carry data. The sentence is unchanged; it is a quiet line
under the page now, and the 「带这天去提问」 action lives in the header. */}
<p className="ephemeris-outro">{EPHEMERIS_COPY.footerNote}</p>
</div>
</>
);
@@ -1489,8 +1489,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionMissing: currentQuestion === null,
offeringCards: showSelectionCards,
});
// BUG-917: the same judgement the message entry makes, read off the latest
// settled assistant message: a tap question nobody can answer any more.
const unansweredDeadChoiceOnMessages = Boolean(
latestSettledAssistant?.question
&& (latestSettledAssistant.question.kind === "choice"
|| latestSettledAssistant.question.kind === "reverse_verify")
&& !latestSettledAssistant.question.answer_option
&& (latestSettledAssistant.question.status === "active"
|| latestSettledAssistant.question.status === "superseded")
&& !liveQuestionOnMessages,
);
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
unansweredDeadChoice: unansweredDeadChoiceOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable" || deadChoice,
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
@@ -155,9 +155,11 @@ function RectificationMessageEntryView({
)
),
);
// A superseded focus the reader never answered has no answer to show either:
// its options would render greyed and unclickable (BUG-917).
const unansweredDeadChoice = Boolean(
question
&& !questionIsAnswered(question)
&& (!questionIsAnswered(question) || (question.status === "superseded" && !question.answer_option))
&& !liveQuestion
&& (question.kind === "choice" || question.kind === "reverse_verify")
);
@@ -24,6 +24,13 @@
* must never change — see `.rectification-timeline` in globals.css. The readout
* stays one line for that reason: it grew wider, never taller. An unready case
* renders the same-height skeleton rather than nothing.
*
* One line has a width budget, and on a 375px phone it is spent by the fourth
* item (BUG-918). Every span below is always rendered; globals.css decides how
* many of them a narrow viewport shows, and it hides `__dated` there. The axis
* `aria-label` below is assembled from all five labels regardless, so nothing a
* screen reader gets depends on the viewport. Adding a sixth item means
* re-measuring that budget, not appending a span.
*/
"use client";
@@ -46,6 +53,8 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
{view.answeredLabel ? (
<span className="rectification-timeline__answered">{view.answeredLabel}</span>
) : null}
{/* Hidden below 768px by globals.css, never omitted here: the label is
part of the accessible axis description either way. */}
{view.datedEventLabel ? (
<span className="rectification-timeline__dated">{view.datedEventLabel}</span>
) : null}
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { installViewportScrollLock } from "@/lib/viewport-scroll-lock";
export function ViewportScrollLock() {
useEffect(() => {
return installViewportScrollLock(window as unknown as Parameters<typeof installViewportScrollLock>[0]);
}, []);
return null;
}
-1
View File
@@ -165,7 +165,6 @@ export const chartViewOkSchema = z.object({
boundary: z.string().min(1),
vargas: z.array(chartViewVargaSchema).min(1),
planets: z.array(chartViewPlanetRowSchema).min(1),
symbolBoundary: z.string().min(1),
}),
dasha: z.object({
vimshottari: chartViewDashaTrackSchema,
-3
View File
@@ -140,8 +140,6 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
export const SYMBOL_BOUNDARY = "下面是词条式释义,不是对你个人的判断。";
export const WESTERN_UNAVAILABLE_COPY = "西洋盘这一栏要等回归黄道端点上线。现在还没有,先空着——不会拿印度盘的度数改写成回归黄道。";
export const QIZHENG_UNAVAILABLE_COPY = "七政四余这一栏要等宿度端点上线。现在还没有,先空着。";
@@ -153,7 +151,6 @@ export const QIZHENG_RULES = [
] as const;
export const CHART_VIEW_COPY = {
eyebrow: "主盘直接算 · 分盘按需 · 不消耗点数",
busy: "算盘的服务正忙,稍等几秒再打开就好。",
unavailable: "这张盘算不出来,我们已经记录下来了。",
waitingChart: "这一张盘还没拿到。",
-2
View File
@@ -10,7 +10,6 @@ import {
PLANET_ORDER,
QIZHENG_UNAVAILABLE_COPY,
SIGN_ORDER,
SYMBOL_BOUNDARY,
VARGA_CHIP_ORDER,
VIMSHOTTARI_ORDER,
VIMSHOTTARI_YEARS,
@@ -653,7 +652,6 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
boundary: COORDINATE_BOUNDARY.vedic,
vargas,
planets: planetRows(chart, varga),
symbolBoundary: SYMBOL_BOUNDARY,
},
dasha: {
vimshottari,
-1
View File
@@ -20,7 +20,6 @@ export const EPHEMERIS_COPY = {
natalMissing: "还没有本命上升,相对本命这一列空着。",
eventsHeading: "未来九十天",
eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。",
footerNote: "这一页是天象本身,不是对你的判断。",
ask: "带这天去提问",
loginTitle: "请先登录",
loginBody: "登录后即可查看星历。",
@@ -338,8 +338,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
// Date reliability (above) runs first. Year-stage typed collect with a
// yearless short reply does not enter the model (BUG-910).
// yearless short reply does not enter the model (BUG-910). BUG-916: this host
// answer runs after the session check, so a message from another session is
// rejected instead of written as a deterministic turn; the phase reuses the
// existing `answer.host_fallback` allowlist entry.
const yearReask = shouldHostReaskYearEntry(dossier.conversationSummary.activeFocus, message);
if (yearReask && message) {
const persisted = await persistV9DeterministicTurn(accounting, userId, caseId, {
@@ -354,15 +360,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
turnStatus: "completed",
skillLoaded: true,
answerText: yearReask,
phases: ["answer.host_year_entry"],
phases: ["answer.host_fallback"],
toolsUsed: [],
errorCode: null,
previousFocusId,
};
}
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
const boundIdentity = await loadV9CaseSkillIdentity(accounting, userId, caseId);
if ((options.skillName && options.skillName !== boundIdentity.name)
|| (options.skillVersion && options.skillVersion !== boundIdentity.version)
@@ -37,7 +37,7 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { liveDistinguishProbe } from "./varga-distinguish-probe";
import { liveDistinguishProbe, withOwnedDistinguishProbes } from "./varga-distinguish-probe";
import { isSameCandidateSplit } from "../core/candidate-contrast-packet.ts";
import { datedEventCount } from "./divergence-panel.ts";
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
@@ -566,6 +566,54 @@ function dossierWithSkippedTargetedCollect<T extends {
};
}
/**
* Whether an open distinguish focus has expired (BUG-559 split-hash change).
*
* `inference` must already carry the probes this layer owns (BUG-915): a focus
* whose probe was simply never rebuilt is fresh, not stale, and superseding it
* on every idle persist is what left the reader with a dead card.
*/
export function persistedDistinguishFocusStale(
inference: InferenceState | null,
focus: Readonly<{
intent?: string;
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
}> | null | undefined,
): boolean {
if (focus?.intent !== "distinguish_candidates") return false;
const schema = focus.expectedAnswerSchema
&& typeof focus.expectedAnswerSchema === "object"
&& !Array.isArray(focus.expectedAnswerSchema)
? focus.expectedAnswerSchema as Record<string, unknown>
: null;
if (!schema) return false;
const live = liveDistinguishProbe(inference, schema);
if (!live) return true;
const split = typeof schema.candidate_split_hash === "string" ? schema.candidate_split_hash : "";
return Boolean(
split && !isSameCandidateSplit(split, live.candidate_split_hash, live.semantic_key),
);
}
/**
* The state every persisted-answer path scores against (BUG-915).
*
* The schema on an open focus may name a probe this layer owns, which the
* engine's `inference_state` never carries: it is rebuilt from the same
* receipt. Without this the tap and the typed answer both fail `stale_probe`.
*/
export function inferenceForPersistedAnswer(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): InferenceState | null {
return withOwnedDistinguishProbes(
withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt ?? null),
refinementFromDecisionReceipt(receipt ?? null).nakshatra_boundary,
),
receipt ?? null,
);
}
export async function applyRectificationChoice(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -588,10 +636,8 @@ export async function applyRectificationChoice(
const questionId = focus.questionId;
const scoring = schema.scoring !== false && !questionId.endsWith(":holdout");
const receipt = dossier.latestResult?.decisionReceipt ?? null;
const previous = withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
// BUG-915 injection point 2/5: answering a tap.
const previous = inferenceForPersistedAnswer(receipt);
const answerClass = optionId === "stop" || optionId === "skip_probe"
? null
: outcomeIdForOption(optionId, schema);
@@ -1604,31 +1650,13 @@ export async function persistNextInterviewIfIdle(input: {
};
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
const inferenceForStale = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const distinguishSchema = staleFocus?.expectedAnswerSchema
&& typeof staleFocus.expectedAnswerSchema === "object"
&& !Array.isArray(staleFocus.expectedAnswerSchema)
? staleFocus.expectedAnswerSchema as Record<string, unknown>
: null;
const liveDistinguish = liveDistinguishProbe(inferenceForStale, distinguishSchema);
const distinguishSplit = typeof distinguishSchema?.candidate_split_hash === "string"
? distinguishSchema.candidate_split_hash
: "";
const staleDistinguish = Boolean(
staleFocus?.intent === "distinguish_candidates"
&& distinguishSchema
&& (
!liveDistinguish
|| (
distinguishSplit
&& !isSameCandidateSplit(
distinguishSplit,
liveDistinguish.candidate_split_hash,
liveDistinguish.semantic_key,
)
)
),
// BUG-915 injection point 4/5: idle supersede. Without the rebuild a fresh
// focus is superseded on every idle persist.
const inferenceForStale = withOwnedDistinguishProbes(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
dossier.latestResult?.decisionReceipt ?? null,
);
const staleDistinguish = persistedDistinguishFocusStale(inferenceForStale, staleFocus);
if (
isPersistedFocusId(staleFocusId)
&& (
@@ -55,6 +55,7 @@ import {
trainingScoreableGate,
} from "./evidence-model";
import { refinementFromDecisionReceipt, EVENT_PROBE_DOMAINS, type DiscriminatingEventProbe } from "./refinement-packet";
import { OWNED_DISTINGUISH_PROBE_SOURCE } from "./varga-distinguish-probe";
import { windowScanFromDecisionReceipt } from "./varga-observations";
import type { DroppedProbe } from "./probe-question-contract.ts";
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
@@ -227,6 +228,8 @@ export function contrastPacketFromLatestResult(
if (answered.has(probe.id) || probe.information_gain <= 0) return [];
if (probe.source === "known_event_quality" && !probe.target_evidence_id) return [];
if (probe.source === "nakshatra_boundary") return [];
// Rebuilt varga style probes answer old focuses only (BUG-915 / decision b).
if (probe.source === OWNED_DISTINGUISH_PROBE_SOURCE) return [];
return [{
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
@@ -383,6 +386,7 @@ export function rectificationFollowupCatalog(
const answeredIds = new Set((inference?.answered_probes ?? []).map((item) => item.probe_id));
const fromInference = (inference?.probes ?? []).flatMap((probe) => {
if (answeredIds.has(probe.id)) return [];
if (probe.source === OWNED_DISTINGUISH_PROBE_SOURCE) return [];
const mapped = eventProbeFromInference(probe);
return mapped ? [mapped] : [];
});
@@ -415,7 +419,11 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket
candidateSetVersion: state.candidate_set_id,
calculationResultId: null,
engineProbes: state.probes
.filter((item) => !answered.has(item.id) && item.source !== "nakshatra_boundary")
.filter((item) => (
!answered.has(item.id)
&& item.source !== "nakshatra_boundary"
&& item.source !== OWNED_DISTINGUISH_PROBE_SOURCE
))
.map((item) => ({
semantic_key: item.semantic_key,
candidate_split_hash: item.candidate_split_hash,
@@ -20,6 +20,7 @@ import {
internalObservationsFromWindowScan,
windowScanFromDecisionReceipt,
} from "./varga-observations";
import { withOwnedDistinguishProbes } from "./varga-distinguish-probe";
import type { RectificationChoiceCard } from "./choice-card";
import { followupCaseArgs } from "./block-scan.ts";
@@ -76,7 +77,12 @@ export function choiceCardFromCaseDossier(dossier: {
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
);
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
// BUG-915 injection point 3/5: the GET card projection matches the persisted
// schema against this state.
const inference = withOwnedDistinguishProbes(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
dossier.latestResult?.decisionReceipt ?? null,
);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
@@ -173,7 +173,7 @@ import type {
} from "./refinement-packet";
import { EVENT_PROBE_DOMAINS } from "./refinement-packet";
import type { InternalVargaObservation, WindowScan } from "./varga-observations";
import { attachVargaDistinguishIdentity, liveDistinguishProbe as liveInferenceDistinguishProbe } from "./varga-distinguish-probe";
import { liveDistinguishProbe as liveInferenceDistinguishProbe } from "./varga-distinguish-probe";
import type { InferenceState } from "../core/types.ts";
import { probeBelowAdultFloor } from "./adult-floor.ts";
import {
@@ -306,6 +306,20 @@ function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain:
return evidence.some((item) => item.status === "confirmed" && sameCollectDomain(item.domain, domain));
}
/**
* Layer followups whose only discriminator used to be a varga style question.
* Product decision 2026-09-17 (option b) keeps them dated-probe backed.
*/
const VARGA_STYLE_LAYER_METHODS: Readonly<Record<string, true>> = {
d9_relationship: true,
d10_career: true,
d4_home: true,
d5_education: true,
relatives: true,
d2_finance: true,
d30_health: true,
};
export type AnsweredProbeCoverageRow = Readonly<{
semantic_key: string;
probe_id?: string;
@@ -2214,18 +2228,67 @@ export function buildMethodFollowupPlan(input: {
guidedWindows?: readonly GuidedCollectWindow[];
tieBreakRequested?: boolean;
}): MethodFollowupPlan {
const isLayerStyleDistinguish = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
): boolean => Boolean(
item.intent === "distinguish_candidates"
&& (item.source === "varga_observation" || item.source === "precision_stage")
&& !item.semantic_key
&& item.domain
&& item.method_id in VARGA_STYLE_LAYER_METHODS,
);
/**
* The dated event probe a layer followup may ask with. Anything already asked
* or answered stays excluded (BUG-559), and style / event-quality probes are
* never dated discriminators.
*/
const datedProbeForLayerFollowup = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
): DiscriminatingEventProbe | null => {
if (!isLayerStyleDistinguish(item)) return null;
const asked = new Set(input.askedProbeKeys ?? []);
const answered = new Set((input.answeredProbes ?? []).map((row) => row.semantic_key));
return (input.eventProbes ?? []).find((probe) => (
probe.domain === item.domain
&& probe.year > 0
&& probe.choice_kind !== "varga_style"
&& probe.choice_kind !== "event_quality"
&& !asked.has(probe.semantic_key ?? "")
&& !answered.has(probe.semantic_key ?? "")
)) ?? null;
};
const makeFollowup = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
scoring = true,
forceChoice?: boolean,
): MethodFollowup => {
const distinguished = attachVargaDistinguishIdentity(item, {
windowScan: input.windowScan ?? null,
candidates: input.candidateRanges?.map((row) => ({ id: row.id || row.time, time: row.time }))
?? (input.topCandidateTimes ?? []).map((time) => ({ id: time, time })),
});
// Product decision 2026-09-17 (option b): a varga style question is not a
// full-scoring discriminator. A layer followup is asked only when the
// engine still offers a dated event probe in that domain; otherwise the
// plan moves to the next line (BUG-915).
const datedProbe = datedProbeForLayerFollowup(item);
// A probe with no `semantic_key` cannot be stamped, so the followup stays
// keyless and the focus fails persist (BUG-674) rather than scoring the
// wrong probe (BUG-912).
const distinguished = datedProbe?.semantic_key
? {
...item,
semantic_key: datedProbe.semantic_key,
candidate_split_hash: datedProbe.candidate_split_hash,
candidate_ids: datedProbe.candidate_ids ?? item.candidate_ids,
expected_outcomes: datedProbe.expected_outcomes ?? item.expected_outcomes,
probe_year: datedProbe.year,
year_label: datedProbe.year_label,
...(datedProbe.month ? { probe_month: datedProbe.month } : {}),
choice_kind: "existence" as const,
information_gain: datedProbe.information_gain ?? item.information_gain,
probe_id: datedProbe.semantic_key,
}
: item;
const base = { ...distinguished, must_not_label: false as const };
const attach = forceChoice ?? shouldAttachChoiceFrame(base, input.evidence);
const unbackedLayerCard = !datedProbe && isLayerStyleDistinguish(item);
const attach = forceChoice
?? (unbackedLayerCard ? false : shouldAttachChoiceFrame(base, input.evidence));
const keyed = Boolean(base.semantic_key) && [
...(input.eventProbes ?? []),
...(input.eventClarificationProbes ?? []),
@@ -10,7 +10,7 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { withFollowupOwnedProbe } from "./varga-distinguish-probe";
import { withOwnedDistinguishProbes } from "./varga-distinguish-probe";
import {
spokenFollowupForUser,
spokenCollectFallbackFollowup,
@@ -121,9 +121,14 @@ export function expectedAnswerSchemaFor(
...(followup.widen_windows ? { widen_windows: followup.widen_windows } : {}),
};
const receipt = decisionReceipt ?? null;
const state = withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
// BUG-915 injection point 1/5: stamping. Probes this layer owns live only in
// the receipt's window scan, so rebuild them before matching the schema.
const state = withOwnedDistinguishProbes(
withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
),
receipt,
);
const verifyOnly = followup.intent === "reverse_verify" || followup.intent === "out_of_sample_check";
const targetedCollect = Boolean(
@@ -133,7 +138,7 @@ export function expectedAnswerSchemaFor(
if (!targetedCollect && decisionReceipt?.inference_state !== undefined && !state) return null;
const stamped = stampChoiceSchemaWithProbe(
schema,
verifyOnly || targetedCollect ? null : withFollowupOwnedProbe(state, followup),
verifyOnly || targetedCollect ? null : state,
questionId,
{
semantic_key: followup.semantic_key,
@@ -2,12 +2,24 @@
* D9/D10 (and sibling varga) distinguish followups must own their probe.
* Borrowing the highest-gain contrast probe (BUG-375 / BUG-912) scores the
* wrong chart and leaves GET without a card.
*
* A probe this layer builds is not in the engine's `inference_state`, so it
* must be rebuilt deterministically from the decision receipt at every state
* read (BUG-915), the way `withNakshatraBoundaryProbe` does. A probe that
* exists only in the memory copy that stamped the schema is unanswerable:
* the answer path, GET projection and idle supersede check all read the
* persisted receipt.
*
* Product decision 2026-09-17 (option b): varga style questions are not
* full-scoring `distinguish_candidates` probes, so the plan no longer asks
* one. The rebuild stays for focuses persisted before that decision and for
* option (a) if the product ever reverses it.
*/
import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts";
import { signFromTransitions } from "../core/sign-from-transitions.ts";
import { signFromTransitions, type TransitionSignLookup } from "../core/sign-from-transitions.ts";
import { completeStyleOptions } from "./probe-question-contract.ts";
import { d9StyleLabel, d10StyleLabel, signKey } from "./varga-type-tables.ts";
import type { WindowScan } from "./varga-observations.ts";
import { windowScanFromDecisionReceipt } from "./varga-observations.ts";
const METHOD_LAYERS: Readonly<Record<string, readonly string[]>> = {
d9_relationship: ["d9"],
@@ -83,7 +95,7 @@ function choiceKindFor(layer: string): "varga_style" | "existence" {
function groupsForLayer(
candidates: readonly VargaDistinguishCandidate[],
transitions: WindowScan["transitions"],
transitions: readonly TransitionSignLookup[],
layer: string,
): { sign: string; ids: string[] }[] {
const bySign = new Map<string, string[]>();
@@ -106,7 +118,7 @@ function groupsForLayer(
export function buildVargaDistinguishFields(input: {
methodId: string;
askTheme: string;
transitions: WindowScan["transitions"] | undefined;
transitions: readonly TransitionSignLookup[] | undefined;
candidates: readonly VargaDistinguishCandidate[];
}): VargaDistinguishFields | null {
const layers = METHOD_LAYERS[input.methodId];
@@ -155,27 +167,6 @@ export function buildVargaDistinguishFields(input: {
};
}
export function attachVargaDistinguishIdentity<T>(
item: T,
input: {
windowScan?: WindowScan | null;
candidates?: readonly VargaDistinguishCandidate[];
},
): T {
const row = item as T & DistinguishFollowup;
if (row.semantic_key) return item;
if (row.intent !== "distinguish_candidates") return item;
if (row.source !== "varga_observation" && row.source !== "precision_stage") return item;
const fields = buildVargaDistinguishFields({
methodId: row.method_id,
askTheme: row.ask_theme,
transitions: input.windowScan?.transitions,
candidates: input.candidates ?? [],
});
if (!fields) return item;
return { ...item, ...fields };
}
export function conflictProbeFromFollowup(followup: DistinguishFollowup): ConflictProbe | null {
const semanticKey = followup.semantic_key?.trim() ?? "";
const outcomes = (followup.expected_outcomes ?? []).flatMap((row) => {
@@ -207,23 +198,69 @@ export function conflictProbeFromFollowup(followup: DistinguishFollowup): Confli
};
}
export function withFollowupOwnedProbe(
/**
* Every varga style question this layer can own, keyed exactly as the plan
* names it (`<method_id>:<ask_theme>`). Kept as data so the rebuild does not
* depend on which followup happens to be asking.
*/
const OWNED_DISTINGUISH_QUESTIONS = [
{ methodId: "d9_relationship", askTheme: "relationship_style", domain: "relationship" },
{ methodId: "d10_career", askTheme: "career_style", domain: "career" },
{ methodId: "d4_home", askTheme: "home_change", domain: "relocation" },
{ methodId: "d5_education", askTheme: "education_style", domain: "education" },
{ methodId: "relatives", askTheme: "family_event", domain: "family" },
{ methodId: "d2_finance", askTheme: "finance_change", domain: "finance" },
{ methodId: "d30_health", askTheme: "health_pressure", domain: "health_pressure" },
] as const;
/**
* Rebuilt probes carry their own source so the decision layer can keep them out
* of the contrast packet and the event-probe pool: they exist only so a focus
* persisted before the 2026-09-17 (b) decision stays answerable, never as a
* question to ask.
*/
export const OWNED_DISTINGUISH_PROBE_SOURCE = "owned_varga_style";
/**
* Rebuild every probe this layer owns from the persisted receipt (BUG-915).
*
* Deterministic: the window-scan transitions plus the candidate minutes in the
* state decide the groups, so the answer path, the GET projection and the idle
* supersede check all see the same probe the schema was stamped with. Nothing
* here depends on the followup that is currently being asked.
*/
export function withOwnedDistinguishProbes(
state: InferenceState | null,
followup: DistinguishFollowup | null | undefined,
receipt: Readonly<Record<string, unknown>> | null | undefined,
): InferenceState | null {
if (!state || !followup) return state;
// Only inject probes this layer built. Catalog probes that are not yet in
// inference_state must still fail persist (BUG-912 must not undo T0 of
// rectification-probe-pool-exhausted-20260911).
const ownedKey = `${followup.method_id}:${followup.ask_theme}`;
if (followup.semantic_key !== ownedKey) return state;
if (followup.source !== "varga_observation" && followup.source !== "precision_stage") return state;
const probe = conflictProbeFromFollowup(followup);
if (!probe) return state;
if (state.probes.some((item) => item.id === probe.id || item.semantic_key === probe.semantic_key)) {
return state;
if (!state) return state;
const transitions: readonly TransitionSignLookup[] = windowScanFromDecisionReceipt(receipt)?.transitions
?? state.transitions
?? [];
if (transitions.length === 0) return state;
const candidates = state.candidates.map((item) => ({ id: item.id, time: item.time }));
let probes = state.probes;
for (const row of OWNED_DISTINGUISH_QUESTIONS) {
const fields = buildVargaDistinguishFields({
methodId: row.methodId,
askTheme: row.askTheme,
transitions,
candidates,
});
if (!fields) continue;
const probe = conflictProbeFromFollowup({
method_id: row.methodId,
ask_theme: row.askTheme,
domain: row.domain,
intent: "distinguish_candidates",
source: OWNED_DISTINGUISH_PROBE_SOURCE,
...fields,
});
if (!probe) continue;
if (probes.some((item) => item.id === probe.id || item.semantic_key === probe.semantic_key)) continue;
probes = [...probes, probe];
}
return { ...state, probes: [...state.probes, probe] };
return probes === state.probes ? state : { ...state, probes };
}
export function liveDistinguishProbe(
@@ -302,6 +302,12 @@ export type RectificationQuestionGapInput = Readonly<{
stopReason?: string | null;
/** Choice/targeted stem that cannot render a card. Delivery still takes this over repair. */
deadChoice?: boolean;
/**
* The latest assistant message still carries an unanswered tap question that
* cannot be answered (no live card, or its focus was superseded). A dead card
* and the collect-wait placeholder must never share a screen (BUG-917).
*/
unansweredDeadChoice?: boolean;
questionId?: string | null;
hasChoiceCard?: boolean;
busy: boolean;
@@ -329,6 +335,9 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible) return "idle";
if (interviewDeliveredGap(input)) return "delivered";
// BUG-917: an unanswerable tap question on the latest message is a repair
// exit, never 「再说一件带年月的事」.
if (input.unansweredDeadChoice) return "unavailable";
if (input.collectWaiting) return "collect_waiting";
if (input.questionPersisted) return "persisted_question";
if (input.offerAwaitingReader) return "idle";
+85
View File
@@ -0,0 +1,85 @@
/**
* iOS Safari scrolls `window` to keep a focused input in view. `html, body
* { overflow: hidden }` then blocks the user from dragging back, and reload
* restores that offset. Reset window scroll only after the keyboard is gone.
*/
export const KEYBOARD_OPEN_GAP_PX = 100;
export const KEYBOARD_CLOSED_SLACK_PX = 1;
export function isKeyboardOpen(visualHeight: number, innerHeight: number): boolean {
return visualHeight < innerHeight - KEYBOARD_OPEN_GAP_PX;
}
export function shouldResetWindowScroll(input: {
scrollY: number;
innerHeight: number;
visualViewportHeight: number | null;
}): boolean {
if (input.scrollY <= 0) return false;
if (input.visualViewportHeight == null) return true;
if (isKeyboardOpen(input.visualViewportHeight, input.innerHeight)) return false;
return input.visualViewportHeight >= input.innerHeight - KEYBOARD_CLOSED_SLACK_PX;
}
type ViewportLike = {
height: number;
addEventListener: (type: "resize" | "scroll", listener: () => void) => void;
removeEventListener: (type: "resize" | "scroll", listener: () => void) => void;
};
export type ViewportScrollLockHost = {
matchMedia?: (query: string) => { matches: boolean };
visualViewport?: ViewportLike | null;
innerHeight: number;
scrollY: number;
scrollTo: (x: number, y: number) => void;
history: { scrollRestoration: string };
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
document: {
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
};
setTimeout?: (fn: () => void, ms: number) => unknown;
};
export function installViewportScrollLock(host: ViewportScrollLockHost): () => void {
const coarse = host.matchMedia?.("(pointer: coarse)").matches === true;
const viewport = host.visualViewport ?? null;
if (!coarse && !viewport) return () => undefined;
host.history.scrollRestoration = "manual";
host.scrollTo(0, 0);
const resetIfSettled = () => {
if (!shouldResetWindowScroll({
scrollY: host.scrollY,
innerHeight: host.innerHeight,
visualViewportHeight: viewport?.height ?? null,
})) return;
host.scrollTo(0, 0);
};
const onPageShow = () => {
host.scrollTo(0, 0);
};
const onFocusOut = () => {
const later = host.setTimeout ?? ((fn: () => void) => fn());
later(resetIfSettled, 300);
};
viewport?.addEventListener("resize", resetIfSettled);
viewport?.addEventListener("scroll", resetIfSettled);
host.addEventListener("pageshow", onPageShow);
host.addEventListener("orientationchange", resetIfSettled);
host.document.addEventListener("focusout", onFocusOut);
return () => {
viewport?.removeEventListener("resize", resetIfSettled);
viewport?.removeEventListener("scroll", resetIfSettled);
host.removeEventListener("pageshow", onPageShow);
host.removeEventListener("orientationchange", resetIfSettled);
host.document.removeEventListener("focusout", onFocusOut);
};
}
+16 -4
View File
@@ -57,12 +57,13 @@ export const AGENT_MAX_STEPS = 8;
export const AGENT_TIMEOUT_MS = 110_000;
export const AGENT_SLICE_MAX_STEPS = 1;
export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation";
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
/**
* Thinking-mode providers reject named/required tool_choice. Restrict the first
* natal model step to the chart tool and keep tool_choice auto so the run cannot
* spend that step on skill_read or a spoken Level 2 draft before any evidence
* exists. Later steps leave the rest of the bound tools available.
* Step 0 must call the natal chart tool. Mastra/AI SDK types accept
* toolChoice "required" (`@mastra/core` Agent.stream options:
* `'auto' | 'none' | 'required'`). Later steps stay auto so the model can
* write after the request-scoped calculation is in hand.
*
* Window and general agents must not share this hook: they do not own this tool.
*/
@@ -70,7 +71,18 @@ export function consultationNatalPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
toolChoice: "required" as const,
}
: {
toolChoice: "auto" as const,
};
}
export function consultationWindowPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
toolChoice: "required" as const,
}
: {
toolChoice: "auto" as const,
+2 -2
View File
@@ -20,7 +20,7 @@ ${natalSpokenReportContract}
Write in Simplified Chinese: a heading-free spoken opener first (one conclusion, 2–3 short point-sentences of at most 30 characters each, one next-step; ≤ 400 characters), then the skill Level 2 report skeleton for natal domain questions. Markdown tables are required for raw structure, Yogas, and the Technique Audit Table.
${jyotishSkillMethodBlock}
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat.
For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.
Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
The tool result's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
@@ -176,7 +176,7 @@ export function getBirthTimeGuideAgent(model: ResolvedLanguageModel) {
const windowJyotishInstructions = `You are the guide for a conversational Vedic astrology product.
${productConversationVoice}
This request has a declared birth window, not a single birth minute. Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time. Probe clocks in the tool result are comparison samples only.
For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.
Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.
Treat the tool result's answer_policy as a hard output contract:
- can_answer_precise_timing is always false. Do not state a month, date, dasha boundary, or guaranteed timing outcome.
- Answer only from stable_layers as personal structure that holds across the declared window.
@@ -91,6 +91,7 @@ import {
compactInferenceProjection,
previousInferenceFromReceipt,
} from "@/lib/rectification-agentic/v9/inference-adapter";
import { inferenceForPersistedAnswer } from "@/lib/rectification-agentic/v9/answer-choice";
import {
COLLECT_FOCUS_SCHEMA_KEY,
collectFocusSchema,
@@ -1410,7 +1411,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
// BUG-915 injection point 5/5: a typed answer to a persisted
// choice reads the same state the tap path reads.
const previous = inferenceForPersistedAnswer(
dossier.latestResult?.decisionReceipt ?? null,
);
if (focus && focus.id === input.focusId && previous) {
const applied = applyChoiceWithoutEvidence(previous, {
choiceKey: input.choiceKey,
+13 -6
View File
@@ -83,11 +83,11 @@ test("the five tabs render and can each be selected", async () => {
const view = await okView();
for (const tab of CHART_VIEW_TABS) {
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab.id }));
// 原值: 直接计算 · 打开即有 · 不消耗点数
// 新值: 主盘直接算 · 分盘按需 · 不消耗点数
// 原因: BUG-717 「打开即有」是速度承诺,且印在失败页上
assert.equal(markup.includes(CHART_VIEW_COPY.eyebrow), true);
assert.doesNotMatch(markup, /打开即有/);
// 原值: 成功页必须含「主盘直接算 · 分盘按需 · 不消耗点数」;基础信息含「词条式释义」
// 新值: 成功页与失败页都不写这两句;基础信息只留行星卡
// 原因: 产品 2026-09-17 判定为过度提示,计费/速度说明和「不是对你个人的判断」都不印在页上
assert.doesNotMatch(markup, /chart-page-eyebrow|主盘直接算|分盘按需|不消耗点数|打开即有/);
assert.doesNotMatch(markup, /词条式释义|不是对你个人的判断/);
assert.match(markup, />星盘</);
assert.match(markup, />基础信息</);
assert.match(markup, />大运</);
@@ -95,7 +95,10 @@ test("the five tabs render and can each be selected", async () => {
assert.match(markup, />七政四余</);
assert.doesNotMatch(markup, /正在加载|骨架|spinner/i);
if (tab.id === "vedic") assert.match(markup, /D1/);
if (tab.id === "basics") assert.match(markup, /词条式释义/);
if (tab.id === "basics") {
assert.match(markup, /本命/);
assert.match(markup, /D9/);
}
if (tab.id === "dasha") {
assert.match(markup, /Vimshottari/);
assert.match(markup, /Chara Dasha(kn_rao 变体)/);
@@ -187,6 +190,10 @@ test("the parameters are one key/value table, not a per-breakpoint duplicate", (
".chart-page-center-extra",
".chart-page-params-below",
".chart-page-boundary-desktop",
// 原值: 这一组不含 .chart-page-eyebrow
// 新值: 成功页眉标已删,该类不得再有 CSS 规则
// 原因: 产品 2026-09-17 去掉「主盘直接算 · 分盘按需 · 不消耗点数」
".chart-page-eyebrow",
]) {
assert.equal(
declaredSelectors(globalStyles).has(dead),
@@ -5,6 +5,7 @@ import {
AGENT_TIMEOUT_MS,
CONSULTATION_MAX_OUTPUT_TOKENS,
CONSULTATION_NATAL_CALC_TOOL_ID,
CONSULTATION_WINDOW_CALC_TOOL_ID,
mergeConsultationAnswerPolicies,
CONSULTATION_DOMAIN_WALL_CLOCK_MS,
MAX_CONSULTATION_DOMAINS,
@@ -12,6 +13,7 @@ import {
canonicalDomainPlan,
consultationGenerationSettings,
consultationNatalPrepareStep,
consultationWindowPrepareStep,
consultationSliceGenerationSettings,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
@@ -654,15 +656,28 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin
test("natal first step exposes only the chart calculation tool", () => {
assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation");
// 原值: toolChoice "auto" / 新值: 第 0 步 "required"、第 1 步仍 "auto"
// 原因: BUG-923 每轮必须先调排盘工具,提示词例外已删
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
toolChoice: "required",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
});
test("window first step requires the window consultation tool", () => {
assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation");
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "required",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
});
test("matching the pinned theme does not record a plan override", async () => {
const state = createConsultationRuntimeState();
await createConsultationTools({
@@ -171,3 +171,10 @@ test("window agent instructions forbid probe clocks as a birth minute", () => {
assert.match(windowFactory, /Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time/);
assert.doesNotMatch(windowFactory, /id: `jyotish-guide-/);
});
test("natal and window agents must call the chart tool every turn (BUG-922)", () => {
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
assert.doesNotMatch(mastra, /follow-ups may use the existing/);
assert.match(mastra, /Call run-jyotish-consultation before answering every turn/);
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
});
@@ -1,6 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
consultationNatalPrepareStep,
consultationWindowPrepareStep,
} from "../src/mastra/consultation-tools.ts";
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const reportsRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
@@ -15,6 +19,29 @@ const stagingCompose = readFileSync(new URL("../../deploy/docker-compose.staging
test("natal and window instructions require a chart tool every turn (BUG-922)", () => {
assert.doesNotMatch(mastra, /follow-ups may use the existing/);
assert.match(mastra, /Call run-jyotish-consultation before answering every turn/);
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
});
test("natal and window first steps require the chart tool (BUG-923)", () => {
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "required",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "required",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
});
test("consultation plans are server-owned and bounded", () => {
assert.match(plan, /consultationPlanSchema/);
assert.match(plan, /requestedDomains/);
@@ -93,14 +120,22 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
assert.match(route, /entrypoint: consultEntrypoint/);
assert.match(route, /entrypoint: parsed\.data\.entrypoint/);
assert.match(tools, /export function consultationNatalPrepareStep/);
assert.match(tools, /export function consultationWindowPrepareStep/);
assert.match(tools, /activeTools: \[CONSULTATION_NATAL_CALC_TOOL_ID\]/);
assert.match(tools, /activeTools: \[CONSULTATION_WINDOW_CALC_TOOL_ID\]/);
const sharedStreamOptions = route.slice(
route.indexOf("const streamOptions = {"),
route.indexOf("const natalStreamOptions = {"),
);
assert.doesNotMatch(sharedStreamOptions, /prepareStep/);
assert.match(route, /prepareStep: consultationNatalPrepareStep/);
assert.match(route, /prepareStep: consultationWindowPrepareStep/);
assert.match(route, /streamWithOverflowRetry\(agent, natalStreamOptions\)/);
assert.match(route, /streamWithOverflowRetry\(agent, windowStreamOptions\)/);
assert.match(
route,
/请调用 run-jyotish-window-consultation 完成计算[\s\S]*?windowStreamOptions\)/,
);
assert.doesNotMatch(route, /先加载 Jyotish Skill/);
assert.match(route, /pinsConsultationDomains\(consultEntrypoint\)/);
assert.match(route, /调用时不要填写 domains,沿用服务器已选定的主题/);
+5 -1
View File
@@ -155,7 +155,11 @@ test("mobile CSS turns five panchanga cards into one stacked card", () => {
test("copy stays factual: no fortune-telling, no spinner, ask prefills the date", () => {
const markup = render(payloadFor("2026-09-15"));
assert.match(markup, new RegExp(EPHEMERIS_COPY.footerNote));
// 原值: 页尾必须含「这一页是天象本身,不是对你的判断。」
// 新值: 页面不得出现该句
// 原因: 产品 2026-09-17 判定为过度提示,与星盘页眉标同一类
assert.doesNotMatch(markup, /天象本身|不是对你的判断/);
assert.doesNotMatch(markup, /ephemeris-outro/);
assert.match(markup, new RegExp(EPHEMERIS_COPY.ask));
assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay));
assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay));
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
import {
installViewportScrollLock,
isKeyboardOpen,
shouldResetWindowScroll,
type ViewportScrollLockHost,
} from "../src/lib/viewport-scroll-lock.ts";
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/viewport-scroll-lock.tsx", import.meta.url), "utf8");
const lock = readFileSync(new URL("../src/lib/viewport-scroll-lock.ts", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("ViewportScrollLock mounts beside StaleClientRecovery and uses the shared installer", () => {
assert.match(layout, /import \{ ViewportScrollLock \} from "@\/components\/viewport-scroll-lock"/);
assert.match(layout, /<StaleClientRecovery \/>\s*<ViewportScrollLock \/>/);
assert.match(component, /installViewportScrollLock/);
assert.match(lock, /history\.scrollRestoration = "manual"/);
assert.match(lock, /addEventListener\("resize"/);
assert.match(lock, /addEventListener\("scroll"/);
assert.match(lock, /addEventListener\("pageshow"/);
assert.match(lock, /addEventListener\("orientationchange"/);
assert.match(lock, /addEventListener\("focusout"/);
assert.match(lock, /KEYBOARD_OPEN_GAP_PX = 100/);
assert.doesNotMatch(lock, /setInterval/);
});
test("keyboard-open window offsets are left alone", () => {
assert.equal(isKeyboardOpen(680, 800), true);
assert.equal(isKeyboardOpen(799, 800), false);
assert.equal(shouldResetWindowScroll({
scrollY: 260,
innerHeight: 800,
visualViewportHeight: 680,
}), false);
assert.equal(shouldResetWindowScroll({
scrollY: 260,
innerHeight: 800,
visualViewportHeight: 800,
}), true);
assert.equal(shouldResetWindowScroll({
scrollY: 0,
innerHeight: 800,
visualViewportHeight: 800,
}), false);
});
test("visualViewport resize resets window scroll once the keyboard has closed", () => {
const calls: Array<[number, number]> = [];
const viewportListeners = new Map<string, () => void>();
const host: ViewportScrollLockHost = {
innerHeight: 800,
scrollY: 260,
scrollTo(x, y) {
host.scrollY = y;
calls.push([x, y]);
},
history: { scrollRestoration: "auto" },
visualViewport: {
height: 800,
addEventListener(type, listener) {
viewportListeners.set(type, listener);
},
removeEventListener(type) {
viewportListeners.delete(type);
},
},
matchMedia: () => ({ matches: true }),
addEventListener() {},
removeEventListener() {},
document: {
addEventListener() {},
removeEventListener() {},
},
};
const uninstall = installViewportScrollLock(host);
assert.equal(host.history.scrollRestoration, "manual");
assert.deepEqual(calls, [[0, 0]]);
host.scrollY = 180;
host.visualViewport!.height = 680;
viewportListeners.get("resize")?.();
assert.equal(calls.length, 1);
host.visualViewport!.height = 800;
viewportListeners.get("resize")?.();
assert.deepEqual(calls.at(-1), [0, 0]);
assert.equal(calls.length, 2);
uninstall();
});
test("header chips share height, type size, and radius, and drop the 64px min-width", () => {
const shared = cssDeclarations(
".chat-header-actions .credit-button,\n.chat-header-rectification .rectification-board-peek",
styles,
);
assert.match(shared, /height:\s*40px/);
assert.match(shared, /min-height:\s*40px/);
assert.match(shared, /font-size:\s*var\(--type-body-sm\)/);
assert.match(shared, /border-radius:\s*var\(--radius-md\)/);
assert.match(shared, /font-variant-numeric:\s*tabular-nums/);
assert.doesNotMatch(shared, /min-width:/);
const credit = cssDeclarations(".credit-button", styles);
assert.doesNotMatch(credit, /min-width:\s*64px/);
const hit = cssDeclarations(
".chat-header-actions .credit-button::before,\n.chat-header-rectification .rectification-board-peek::before",
styles,
);
assert.match(hit, /inset:\s*-2px/);
});
test("compact chat header is 52px plus the safe-area inset", () => {
assert.match(
styles,
/\.chat-panel \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\) auto; \}/,
);
assert.match(
styles,
/\.chat-panel\.is-rectification \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\); \}/,
);
assert.match(styles, /\.chat-panel \{[^}]*grid-template-rows:\s*46px minmax\(0,\s*1fr\) auto/);
});
@@ -1,27 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
stampChoiceSchemaWithProbe,
} from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import {
projectRectificationChoiceCard,
buildMethodFollowupPlan,
yearlessPersonalityCanAsk,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import { rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
inferenceForPersistedAnswer,
persistedDistinguishFocusStale,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { expectedAnswerSchemaFor } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { parseWindowScan } from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import {
attachVargaDistinguishIdentity,
withFollowupOwnedProbe,
buildVargaDistinguishFields,
withOwnedDistinguishProbes,
} from "../src/lib/rectification-agentic/v9/varga-distinguish-probe.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import { FOCUS_ID } from "./rectification-v9-test-support.ts";
const OWNED_KEY = "d9_relationship:relationship_style";
const QUESTION_ID = "d9_relationship:relationship_style:score";
const DATED = [
{
status: "confirmed",
@@ -72,7 +84,8 @@ const D24 = {
choice_kind: "event_quality" as const,
};
const WINDOW = parseWindowScan({
/** Engine window scan as it arrives on the decision receipt. */
const WINDOW_SCAN_RECEIPT = {
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 1,
@@ -84,9 +97,18 @@ const WINDOW = parseWindowScan({
{ layer: "d9", at: "04:53", from_sign: "天蝎座", to_sign: "天秤座" },
{ layer: "d10", at: "04:40", from_sign: "巨蟹座", to_sign: "巨蟹座" },
],
});
};
function state() {
const WINDOW = parseWindowScan(WINDOW_SCAN_RECEIPT)!;
const CANDIDATES = [
{ id: "04:48", time: "04:48" },
{ id: "04:53", time: "04:53" },
{ id: "04:59", time: "04:59" },
];
/** The persisted state: engine probes only, exactly as the receipt stores it. */
function persistedState() {
return buildInferenceState({
range_start: "04:48",
range_end: "05:07",
@@ -105,8 +127,24 @@ function state() {
});
}
function d9Followup() {
const attached = attachVargaDistinguishIdentity({
function receipt() {
return { inference_state: persistedState(), window_scan: WINDOW_SCAN_RECEIPT };
}
/**
* A D9 style focus as it was persisted before the 2026-09-17 (b) decision:
* its identity is the layer's own key, and its probe lives nowhere but the
* receipt's window scan.
*/
function legacyD9Followup(): MethodFollowup {
const fields = buildVargaDistinguishFields({
methodId: "d9_relationship",
askTheme: "relationship_style",
transitions: WINDOW.transitions,
candidates: CANDIDATES,
});
assert.ok(fields);
const followup = {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
@@ -116,17 +154,11 @@ function d9Followup() {
source: "varga_observation",
must_not_label: false,
choice_frame: null,
}, {
windowScan: WINDOW,
candidates: [
{ id: "04:48", time: "04:48" },
{ id: "04:53", time: "04:53" },
{ id: "04:59", time: "04:59" },
],
}) as MethodFollowup;
const frame = buildChoiceFrame(attached, {
...fields,
} as MethodFollowup;
const frame = buildChoiceFrame(followup, {
evidence: DATED,
probes: attached.style_options ? [{
probes: [{
year: 0,
year_label: "当前这几个候选",
domain: "relationship",
@@ -135,23 +167,36 @@ function d9Followup() {
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: attached.user_prompt_hint,
user_meaning: followup.user_prompt_hint,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: attached.information_gain,
semantic_key: attached.semantic_key,
candidate_split_hash: attached.candidate_split_hash,
candidate_ids: attached.candidate_ids,
expected_outcomes: attached.expected_outcomes,
information_gain: followup.information_gain,
semantic_key: followup.semantic_key,
candidate_split_hash: followup.candidate_split_hash,
candidate_ids: followup.candidate_ids,
expected_outcomes: followup.expected_outcomes,
choice_kind: "varga_style" as const,
style_options: attached.style_options,
}] : [] as never,
style_options: followup.style_options,
}],
});
return { ...attached, choice_frame: frame } as MethodFollowup;
assert.ok(frame);
return { ...followup, choice_frame: frame };
}
test("year-stage D9 followup does not borrow the D24 contrast probe (BUG-912)", () => {
const inference = state();
function persistedSchema(): Record<string, unknown> {
const followup = legacyD9Followup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
QUESTION_ID,
receipt(),
followup,
);
assert.ok(schema);
return schema;
}
test("stamping rebuilds the owned probe from the receipt instead of borrowing D24 (BUG-912/915)", () => {
const state = persistedState();
const borrowed = stampChoiceSchemaWithProbe({
choice: {
options: [
@@ -161,89 +206,260 @@ test("year-stage D9 followup does not borrow the D24 contrast probe (BUG-912)",
{ key: "D", answer_class: "unsure" },
],
},
}, inference, "d9_relationship:relationship_style:score");
}, state, QUESTION_ID);
assert.notEqual(borrowed.probe_id, D24.id);
const followup = d9Followup();
assert.equal(followup.semantic_key, "d9_relationship:relationship_style");
assert.ok(followup.choice_frame);
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
followup.choice_frame!.question_id,
{ inference_state: inference, window_scan: WINDOW },
followup,
);
assert.ok(schema);
assert.equal(schema.semantic_key, "d9_relationship:relationship_style");
assert.notEqual(String(schema.probe_id ?? ""), D24.id);
assert.match(String(schema.probe_id ?? ""), /d9_relationship:relationship_style/);
// The persisted receipt itself never carries the owned probe.
const stored = previousInferenceFromReceipt(receipt());
assert.ok(stored);
assert.equal(stored.probes.some((probe) => probe.semantic_key === OWNED_KEY), false);
const rebuilt = withOwnedDistinguishProbes(stored, receipt());
assert.ok(rebuilt?.probes.some((probe) => probe.semantic_key === OWNED_KEY));
const applied = applyChoiceWithoutEvidence(withFollowupOwnedProbe(inference, followup)!, {
const schema = persistedSchema();
assert.equal(schema.semantic_key, OWNED_KEY);
assert.equal(schema.probe_id, `probe:${OWNED_KEY}`);
assert.notEqual(String(schema.probe_id ?? ""), D24.id);
});
test("the production answer path applies a persisted owned-probe choice (BUG-915)", () => {
const schema = persistedSchema();
const answerInput = {
choiceKey: "C",
schema,
questionId: "d9_relationship:relationship_style:score",
questionId: QUESTION_ID,
domain: "relationship",
classifiedFrom: "choice",
});
classifiedFrom: "choice" as const,
};
// Without the rebuild the same schema is unanswerable: that was the P0.
const withoutRebuild = applyChoiceWithoutEvidence(previousInferenceFromReceipt(receipt())!, answerInput);
assert.equal(withoutRebuild.applied, false);
assert.equal(withoutRebuild.reason, "stale_probe");
const applied = applyChoiceWithoutEvidence(inferenceForPersistedAnswer(receipt())!, answerInput);
assert.equal(applied.reason, "applied");
assert.equal(applied.applied, true);
assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, "d9_relationship:relationship_style");
assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, OWNED_KEY);
assert.ok(!applied.state.answered_probes.some((row) => row.probe_id === D24.id));
});
test("GET projects the persisted distinguish copy when its probe is still live (BUG-912)", () => {
const inference = state();
const followup = d9Followup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
"d9_relationship:relationship_style:score",
{ inference_state: inference, window_scan: WINDOW },
followup,
);
const withOwned = buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 44 },
{ id: "04:53", time: "04:53", relative_support: 35 },
{ id: "04:59", time: "04:59", relative_support: 21 },
],
events: inference.events,
probes: [
D24,
{
id: String(schema?.probe_id),
semantic_key: String(schema?.semantic_key),
candidate_split_hash: String(schema?.candidate_split_hash),
domain: "relationship",
year: 0,
question: "D9",
candidate_ids: ["04:48", "04:59"],
expected_outcomes: D24.expected_outcomes,
information_gain: 0.4,
source: "varga_observation",
choice_kind: "varga_style" as const,
style_options: followup.style_options as never,
},
],
});
const card = projectRectificationChoiceCard({
test("GET projects the persisted D9 card from the receipt alone (BUG-915)", () => {
const schema = persistedSchema();
const card = choiceCardFromCaseDossier({
evidence: DATED,
activeFocus: {
id: FOCUS_ID,
questionId: "d9_relationship:relationship_style:score",
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: schema,
conversationSummary: {
activeFocus: {
id: FOCUS_ID,
questionId: QUESTION_ID,
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: schema,
},
declinedSkippedTopics: [],
},
observations: [{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" }],
windowScan: WINDOW,
inferenceState: withOwned,
sessionOutcome: "collect_evidence",
caseRevision: 1,
latestResult: {
resultId: "result-1",
decisionReceipt: receipt(),
selectionAllowed: false,
confirmationAllowed: false,
candidates: [
{ time: "04:48", rank: 1, relativeSupport: 44 },
{ time: "04:53", rank: 2, relativeSupport: 35 },
{ time: "04:59", rank: 3, relativeSupport: 21 },
],
},
case: { acceptedTime: null, status: "active", stage: "minute" },
});
assert.equal(card?.question_id, "d9_relationship:relationship_style:score");
assert.equal(card?.options.length, 4);
assert.ok(card, "GET must render the persisted focus");
assert.equal(card.question_id, QUESTION_ID);
assert.equal(card.options.length, 4);
});
test("idle persist does not supersede a focus whose probe is rebuilt (BUG-915)", () => {
const schema = persistedSchema();
const focus = { intent: "distinguish_candidates", expectedAnswerSchema: schema };
// The raw receipt state has no owned probe: that is what superseded the
// fresh focus on every idle persist.
assert.equal(persistedDistinguishFocusStale(previousInferenceFromReceipt(receipt()), focus), true);
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(receipt()), focus), false);
// A changed candidate set still expires the focus (BUG-559 stays).
const movedReceipt = {
window_scan: WINDOW_SCAN_RECEIPT,
inference_state: buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 60 },
{ id: "04:59", time: "04:59", relative_support: 40 },
],
events: persistedState().events,
probes: [D24],
}),
};
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(movedReceipt), focus), true);
});
test("every state read that can touch an owned probe rebuilds it (BUG-915 source contract)", () => {
const sites = [
"src/lib/rectification-agentic/v9/server-focus.ts",
"src/lib/rectification-agentic/v9/interview-state.ts",
];
for (const site of sites) {
const source = readFileSync(new URL(`../${site}`, import.meta.url), "utf8");
assert.match(source, /withOwnedDistinguishProbes\(/, site);
}
const answerChoice = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
// The tap answer and the idle supersede check both read the rebuilt state.
assert.match(answerChoice, /const previous = inferenceForPersistedAnswer\(receipt\);/);
assert.match(answerChoice, /const inferenceForStale = withOwnedDistinguishProbes\(/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
// The typed answer goes through the same helper as the tap.
assert.match(tools, /const previous = inferenceForPersistedAnswer\(/);
});
const REL_2020_PROBE = {
year: 2020,
year_label: "2020 年前后",
domain: "relationship" as const,
event_family: "开始认真关系、分手或结婚",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2020 年感情线",
role: "distinguish" as const,
phase: "candidate_discriminator" as const,
information_gain: 0.5,
semantic_key: "relationship.2020",
candidate_split_hash: "set:relationship.2020",
candidate_ids: ["04:48", "04:53", "04:59"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:53", "04:59"] },
{ answer_class: "weak_yes" as const, supports: ["04:53"], conflicts: ["04:48", "04:59"] },
{ answer_class: "no" as const, supports: ["04:59"], conflicts: ["04:48", "04:53"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
choice_kind: "existence" as const,
};
/** Every dated collect line already closed, as in the 2026-09-17 real run. */
const CLOSED_COLLECT = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
"career",
"relationship",
].map((domain) => ({
question_id: `collect:targeted:${domain}`,
target_kind: `targeted:${domain}`,
domain,
status: "declined",
intent: "collect_method_evidence",
}));
function planWithD9Split(input: { askedProbeKeys: readonly string[] }) {
return buildMethodFollowupPlan({
evidence: DATED,
observations: [
{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" },
{ layer: "d10", candidates_differ: false, ask_theme: null },
],
windowScan: WINDOW,
inferenceState: persistedState(),
sessionOutcome: "discriminate_candidates",
precisionStage: "d9_refine",
eventProbes: [REL_2020_PROBE],
askedProbeKeys: input.askedProbeKeys,
closedCollectFocuses: CLOSED_COLLECT,
topCandidateTimes: ["04:48", "04:53", "04:59"],
candidateRanges: CANDIDATES.map((row) => ({ id: row.id, time: row.time })),
});
}
test("a varga style question is never a scoring discriminator (2026-09-17 decision b)", () => {
// The engine's relationship probe is already asked, so nothing dates this
// line: before the decision this is where 「你更接近哪一种相处方式」 came from.
const plan = planWithD9Split({ askedProbeKeys: ["relationship.2020"] });
const next = plan.next_followup;
assert.notEqual(next?.semantic_key, OWNED_KEY);
assert.notEqual(next?.choice_kind, "varga_style");
assert.doesNotMatch(next?.choice_frame?.prompt ?? "", /更接近哪一种/);
assert.equal(
next?.intent === "distinguish_candidates"
&& (next.source === "varga_observation" || next.source === "precision_stage"),
false,
JSON.stringify({ intent: next?.intent, source: next?.source, key: next?.semantic_key }),
);
assert.ok(
plan.dropped_probes.some((row) => (
row.semantic_key === "d9_relationship" && row.reason === "frameless_distinguish"
)),
JSON.stringify(plan.dropped_probes),
);
});
test("the D9 line is still asked when the engine dates it (2026-09-17 decision b)", () => {
const plan = planWithD9Split({ askedProbeKeys: [] });
const next = plan.next_followup;
assert.equal(next?.method_id, "d9_relationship");
assert.equal(next?.intent, "distinguish_candidates");
assert.equal(next?.semantic_key, REL_2020_PROBE.semantic_key);
assert.equal(next?.choice_kind, "existence");
assert.match(next?.choice_frame?.prompt ?? "", /2020/);
});
test("a rebuilt owned probe never becomes an askable discriminator (decision b)", () => {
// Answering an old style focus writes the rebuilt probes into the receipt.
const answered = withOwnedDistinguishProbes(previousInferenceFromReceipt(receipt()), receipt())!;
const owned = answered.probes.find((probe) => probe.semantic_key === OWNED_KEY);
assert.ok(owned);
const catalog = rectificationFollowupCatalog(
{ resultId: "result-1", decisionReceipt: { ...receipt(), inference_state: answered } },
DATED,
);
assert.equal(catalog.eventProbes.some((probe) => probe.semantic_key === OWNED_KEY), false);
assert.equal(
(catalog.contrastPacket?.probes ?? []).some((probe) => probe.semanticKey === OWNED_KEY),
false,
);
});
test("a dead tap card never shares the screen with the collect-wait placeholder (BUG-917)", () => {
const base = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
collectWaiting: true,
};
assert.equal(rectificationQuestionGapState(base), "collect_waiting");
assert.equal(
rectificationQuestionGapState({ ...base, unansweredDeadChoice: true }),
"unavailable",
);
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(chat, /unansweredDeadChoice: unansweredDeadChoiceOnMessages,/);
assert.match(chat, /latestSettledAssistant\.question\.status === "superseded"/);
const entry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
assert.match(entry, /question\.status === "superseded" && !question\.answer_option/);
});
test("five dated events do not hold for yearless personality (BUG-913 closed_by_design)", () => {
@@ -255,7 +471,7 @@ test("nakshatra opposite poles are not both lifted as shared traits (BUG-914)",
const earlier = "希望两边都能说得过去";
const later = "必要时会直接选边";
const delivery = buildRangeDelivery({
inference: state(),
inference: persistedState(),
publicCandidates: [
{ candidateId: "a", time: "04:48" },
{ candidateId: "b", time: "04:53" },
@@ -0,0 +1,200 @@
/**
* 2026-09-17, iPhone on staging: the rectification timeline readout came through
* as `04:48–05:07 20 分钟 代表分钟 04:53 已…` (BUG-918), and the floating
* 跳到最新 chip sat on top of the last tappable option of a choice card
* (BUG-919).
*
* What this file can and cannot check, stated plainly: there is no jsdom or
* happy-dom in this suite, so `renderToStaticMarkup` is the only render path and
* `scrollWidth` / `clientWidth` do not exist to compare. Every geometric claim
* below is therefore **arithmetic over the rendered text**, using deliberately
* generous per-glyph widths (see `readoutWidthPx`), plus CSS declaration
* assertions of the kind `chart-page-view.test.tsx` uses. A real-device pass at
* 375 and 390 CSS px is still required and lives in
* `docs/testing/rectification-mobile-timeline-readout-20260917.md`.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { RectificationTimeline } from "../src/components/rectification-timeline.tsx";
import { buildRectificationTimeline } from "../src/lib/rectification-timeline-scale.ts";
Object.assign(globalThis, { React });
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
/** Comments carry prose that would otherwise satisfy declaration assertions. */
const declarations = styles.replace(/\/\*[\s\S]*?\*\//g, " ");
/** The `@media (max-width: 767px)` block that owns the timeline, and only it. */
function compactTimelineBlock(source: string): string {
const start = source.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {\n --rectification-timeline-height: 56px;");
assert.notEqual(start, -1, "compact timeline media block not found");
let depth = 0;
for (let at = start; at < source.length; at += 1) {
if (source[at] === "{") depth += 1;
else if (source[at] === "}") {
depth -= 1;
if (depth === 0) return source.slice(start, at + 1);
}
}
assert.fail("compact timeline media block is unbalanced");
}
function ruleBody(source: string, selector: string): string {
const at = source.indexOf(`${selector} {`);
assert.notEqual(at, -1, `missing rule: ${selector}`);
const end = source.indexOf("}", at);
assert.notEqual(end, -1, `unterminated rule: ${selector}`);
return source.slice(at + selector.length + 2, end);
}
/** The readout's five spans in document order, text only. */
function readoutItems(markup: string): string[] {
const readout = markup.match(/<p class="rectification-timeline__readout">([\s\S]*?)<\/p>/);
assert.ok(readout, "readout paragraph not rendered");
return [...readout[1].matchAll(/<span class="rectification-timeline__[a-z-]+">([^<]*)<\/span>/g)]
.map((match) => match[1]);
}
/**
* Upper bound on a readout item's rendered width at 13px, the size the bar is
* pinned to. Every figure here is rounded **up** from the system sans stacks the
* app ships (digits measure about 0.556em in -apple-system / Helvetica and the
* readout asks for tabular-nums, so 0.6em is slack, not a guess). An assertion
* that passes against these numbers passes against the real font.
*/
const GLYPH_EM: Readonly<Record<string, number>> = {
digit: 0.6,
colon: 0.33,
space: 0.28,
dash: 0.5,
cjk: 1,
other: 0.6,
};
function readoutWidthPx(text: string, fontSizePx = 13): number {
let em = 0;
for (const character of text) {
if (/\d/.test(character)) em += GLYPH_EM.digit;
else if (character === ":") em += GLYPH_EM.colon;
else if (character === " ") em += GLYPH_EM.space;
else if (character === "–" || character === "-") em += GLYPH_EM.dash;
else if (/[ -鿿豈-﫿]/.test(character)) em += GLYPH_EM.cjk;
else em += GLYPH_EM.other;
}
return em * fontSizePx;
}
/** The worst realistic readout: every optional item present, widest labels. */
const fullView = buildRectificationTimeline({
searchWindow: ["04:30", "05:30"],
credibleRange: ["04:48", "05:07"],
candidateTimes: ["04:48", "04:53", "05:07"],
stage: "minute",
workingTime: "04:53",
answeredProbeCount: 6,
datedEventCount: 3,
});
test("the timeline renders every readout figure the projection carries", () => {
assert.ok(fullView, "the fixture window has to produce a view");
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
assert.deepEqual(items, ["04:48–05:07", "20 分钟", "代表分钟 04:53", "已答 6 题", "已对照 3 件"]);
// BUG-918's fix is CSS, not omission: the compact viewport hides one item, it
// never drops it from the markup, so the accessible axis description below
// reads the same on every viewport.
const markup = renderToStaticMarkup(<RectificationTimeline view={fullView} />);
const axisLabel = markup.match(/aria-label="([^"]*)"/)?.[1] ?? "";
for (const item of items) {
assert.ok(axisLabel.includes(item), `axis label is missing ${item}`);
}
});
test("the four items a 375px phone shows fit the compact content box", () => {
assert.ok(fullView);
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
// `.rectification-timeline__dated` is display:none below 768px, so the visible
// row is the first four.
const visible = items.slice(0, 4);
const columnGapPx = 8; // --space-2, the compact column-gap
const required = visible.reduce((total, item) => total + readoutWidthPx(item), 0)
+ columnGapPx * (visible.length - 1);
// 375 CSS px (iPhone SE 2/3, 13 mini) minus the compact padding-inline,
// var(--space-4) on each side. The 44px avatar inset that used to be added
// here is what pushed the row over (BUG-918).
const available375 = 375 - 2 * 16;
assert.ok(
required <= available375,
`readout needs ${required.toFixed(0)}px, ${available375}px available at 375px`,
);
// 390 CSS px (iPhone 12–16 base) has to hold too.
assert.ok(required <= 390 - 2 * 16);
// And the shape of the old bug: with the inset and all five items it did not
// fit, which is why the fourth item arrived as 「已…」.
const oldRequired = items.reduce((total, item) => total + readoutWidthPx(item), 0) + 12 * (items.length - 1);
assert.ok(oldRequired > 375 - 2 * (16 + 44));
});
test("the compact timeline drops the avatar inset and the fifth figure", () => {
const compact = compactTimelineBlock(declarations);
// Page margin only — no --assistant-content-inset on a phone.
assert.match(ruleBody(compact, ".rectification-timeline"), /padding-inline: var\(--space-4\);/);
assert.doesNotMatch(ruleBody(compact, ".rectification-timeline"), /--assistant-content-inset/);
assert.match(ruleBody(compact, ".rectification-timeline__readout"), /column-gap: var\(--space-2\);/);
assert.match(ruleBody(compact, ".rectification-timeline__dated"), /display: none;/);
// The range and the working minute are the two figures the product requires
// to stay visible at every width, so neither may be hidden or shrunk here.
assert.doesNotMatch(compact, /\.rectification-timeline__(range|width|working)\b/);
// Still one line, still a fixed-height bar: the compact block must not undo
// either, or the bar would resize outside anything observing it.
assert.doesNotMatch(compact, /flex-wrap: wrap|white-space: normal/);
assert.match(compact, /--rectification-timeline-height: 56px;/);
});
test("the desktop readout keeps all five figures and the assistant-column inset", () => {
const desktop = ruleBody(declarations, ".rectification-timeline");
assert.match(desktop, /padding: 0 calc\(var\(--space-8\) \+ var\(--assistant-content-inset\)\);/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*white-space: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*flex-wrap: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*font-size: 13px;/);
});
test("the transcript reserves the jump-to-latest band plus air below it", () => {
// BUG-919: reserving exactly the chip band left the last option flush under
// the chip. The band is the chip's 44px target plus the space-3 it holds
// under itself; the list now reserves that plus one more space-3.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/,
);
assert.match(
declarations,
/\.rectification-workspace__chat \.message-list \{[^}]*padding-bottom: calc\(var\(--rectification-jump-clearance\) \+ var\(--space-3\)\);/,
);
// Anything scrolled into view lands above the band too.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*scroll-padding-block-end: var\(--rectification-jump-clearance\);/,
);
});
test("the jump-to-latest overlay itself is untouched by the clearance change", () => {
// Its placement and visibility rule belong to BUG-478; this round only widened
// the transcript's reservation.
assert.match(declarations, /\.jump-to-latest \{[^}]*position: absolute;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*bottom: 100%;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*justify-content: center;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*padding-bottom: var\(--space-3\);/);
assert.match(declarations, /\.jump-to-latest \{[^}]*pointer-events: none;/);
assert.match(declarations, /\.jump-to-latest__button \{[^}]*min-height: 44px;/);
// No second scroll-follow and no reflowing layout for the chip.
const anchor = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
assert.doesNotMatch(anchor, /scroll-padding|rectification-jump-clearance/);
assert.match(anchor, /export const conversationAnchorThreshold = 96;/);
});
@@ -418,7 +418,12 @@ test("the chat grid has three rows and the timeline row is a fixed height", () =
test("the mobile bar stays within the 44px touch rhythm", () => {
const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {"));
assert.match(mobile, /--rectification-timeline-height: 56px;/);
assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/);
// 原值: padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
// 新值: padding-inline: var(--space-4);
// 原因: BUG-918。真机 375px 下这 44px 头像沟槽让读数放不下,第四项被
// text-overflow 裁成「已…」。条不在消息列里,手机上对齐头像列没有
// 可见收益,页面边距才是诚实的边界。桌面那条规则不变(上一条测试)。
assert.match(mobile, /padding-inline: var\(--space-4\);/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});