Compare commits

..
Author SHA1 Message Date
Jesse_ChenandClaude Opus 5.5 28f4df0e07 fix(rectification): adopt/gate turns use uuid request ids; post-adopt checks carry the state probe id (BUG-1149, BUG-1150)
Independent Staging Quality Gate / validate (push) Failing after 13m1s
Independent Staging Quality Gate / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 19:38:10 +08:00
Jesse_ChenandClaude Opus 5.5 dc45fde613 feat(home): warm older-sister greeting voice; daily line says why, plainly
Independent Staging Quality Gate / validate (push) Successful in 12m47s
Independent Staging Quality Gate / publish (push) Successful in 3m47s
Late night no longer remarks on being awake (产品 2026-10-01); every daypart,
weekday and returning line reworded in the same caring voice. Daily line:
each house sentence names where the Moon is in plain words, then one concrete
sentence; no stacked hedges. Sign-fallback moods rewritten the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 19:34:16 +08:00
Jesse_ChenandClaude Opus 5.5 fa6d026f12 fix(consult): Chinese-only terms, one name per thing in answers (BUG-1148)
Independent Staging Quality Gate / validate (push) Successful in 12m47s
Independent Staging Quality Gate / publish (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 19:21:28 +08:00
Jesse_ChenandClaude Opus 5.5 282455cc83 fix(rectification): post-adopt check gets its own message and keeps its buttons; segment bubble copy (BUG-1146, BUG-1147)
Independent Staging Quality Gate / validate (push) Successful in 13m59s
Independent Staging Quality Gate / publish (push) Successful in 3m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 16:59:32 +08:00
Jesse_ChenandClaude Opus 5.5 c0f059125b fix(consult): progress timeline shows only real steps, not the answer outline (BUG-1145)
Independent Staging Quality Gate / validate (push) Successful in 14m43s
Independent Staging Quality Gate / publish (push) Successful in 3m46s
The answer outline (think.plan / thinking.section) was drawn as four think
rows and ticked done as soon as the chart calculation started. The reducer
now keeps the outline only as calculate-row detail; live and history views
share it. Product chose option B on 2026-10-01.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 16:40:27 +08:00
Jesse_ChenandClaude Opus 5.5 f1a169cb93 feat(rectification): segment-gain order on by default up to 61 minutes after the health-focus fix; records and replay evidence (BUG-1143, BUG-1144)
Independent Staging Quality Gate / validate (push) Successful in 13m28s
Independent Staging Quality Gate / publish (push) Successful in 3m31s
The 77-case persisted replay after the BUG-1143 fix keeps every truth segment
at both 21 and 61 minutes with segment order on, so the default envelope moves
from 21 to 61 minutes. Bug numbers follow staging (BUG-1142 was taken).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 13:15:36 +08:00
Jesse_ChenandClaude Opus 5.5 564eede340 fix(rectification): health distinguish focuses persist again; segment-gain order on by default for windows <= 21 minutes (BUG-1142, BUG-1143)
BUG-1142: distinguish focuses wrote the planner domain verbatim, and
health_pressure is not in the focus table's target_domain check. The write
failed inside a swallowed retry and the session went straight to delivery
although tap cards remained. Every focus write now goes through
focusTargetDomain (persistableFocusDomain, BUG-672), and focus-to-probe
matching uses sameCollectDomain.

BUG-1143: segmentOrderEnabledFor turns segment-gain probe order on by default
when the scanned window is at most 21 minutes, where the 77-case persisted
replay gained head hits with no lost truth segment. RECTIFICATION_SEGMENT_ORDER
=off disables it; =on keeps the 61-minute research envelope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 11:47:04 +08:00
34 changed files with 243176 additions and 145 deletions
+34
View File
@@ -1,5 +1,39 @@
# 印度占星 Skill 更新日志
## 2026-10-01 — 采用后的核对题可以作答了;交付收尾那句话不再丢
- 修复:采用后的核对题点任何选项都提示「这次没提交上」。现在能正常作答(BUG-1150)。
- 修复:采用后的单独消息、交付时补写的那句收尾话之前都没能存进数据库(编号格式不对,被拒后没有报错)。现在都能存下,刷新也在(BUG-1149)。
- 修复前已经出现的核对题仍答不了,可点「这题跳过」或新开一次校正。Skill 版本不变;不改数据库结构。
## 2026-10-01 — 首页开场语换成知心姐姐口吻;今日星语说清为什么
- 开场语:深夜不再说「夜猫子」「还没睡呀」,改成「这会儿很安静」「夜里好,{名字}」「慢慢说,{名字}」;其余时段同样改成关心的口吻,例如「中午好,记得吃饭」「今天辛苦了」「周五了,松口气」。
- 今日星语:每句先交代原因(今天月亮走到你盘上管哪类事的位置),再给一句具体的话,不再叠「更容易」「被放大」;没确认出生时间时按星座写的那句也改成具体建议。
- 不走模型、不改数据库结构;Skill 版本不变。
## 2026-10-01 — 普通对话不再夹英文,名词叫法统一
- 回答里不再出现 sade sati、vargottama、rupas 这类英文或梵文音译;计都不再写成「克图」;大运下面的时段统一叫子运、小运;行星力量只说强、中、弱(BUG-1148)。
- Skill 版本不变;不改数据库结构。
## 2026-10-01 — 生时校正点「采用」后的核对题有按钮了;回复不再念分钟排名
- 修复:点「采用」后出现的核对题(例如「那次升学结果更接近哪一种」)只有题目、没有选项按钮,刷新也不出。现在采用后会单独出一条消息,核对题挂在它下面,四个按钮可点,刷新后仍在(BUG-1146)。
- 盘型口径下:答题后的回复不再说「某段分钟领先、某段落后」;交付时正文不再说「这几个候选分不开」(和卡片上的「较可信」打架);「这只是代表性候选…」只在卡片上说一次(BUG-1147)。
- Skill 版本不变;不改数据库结构。
## 2026-10-01 — 普通对话进度栏不再提前打勾
- 发问后进度栏不再一下子出现四行「先回答你问的这件事 / 把判断钉在盘上 / 时间窗口怎么看 / 这周可以做什么」并全部打勾(那时盘还没算完)。现在只显示真实发生的步骤:读取分析方法 → 计算本命盘 → 正在写(按正文实际写到的标题)→ 完成;打开历史对话也一样(BUG-1145)。
- Skill 不 bump;不改数据库结构;服务端事件不变。
## 2026-10-01 — 生时校正不再答两三题就提前结束;默认按「哪道题最能分开盘型」出题
- 修复:轮到「健康」方面的选择题时,这道题一直写不进去,系统就直接给出结果,后面还能问的题都不问了。修好后平均每次多问约 2 题(前后 10 分钟:2.8 → 4.6 题;前后 30 分钟:3.6 → 5.4 题),D9 / D10 判对的例数明显上升,没有一例把真实时间排除在外(BUG-1143)。
- 「按盘型挑题」默认开启(范围在前后 30 分钟以内时):优先问最能区分 D9 / D10 上升的题。77 例回放:前后 30 分钟 D9 判对 46 → 51 例、D10 52 → 55 例(BUG-1144)。
- Skill 版本不变;不改数据库结构。
## 2026-10-01 — 普通对话写回答最多可以等 5 分钟(原来 70 秒)
- 推理较重的模型(例如 deepseek-v4-pro)写长回答不再在最后一节被截断;5 分钟只用来防模型卡死,不限制回答长度(BUG-1142)。
+120
View File
@@ -15347,3 +15347,123 @@
- 相关记录:BUG-1051、BUG-1053、BUG-944。
- 复发自:无
- 修复版本:`codex/consult-answer-clock-20261001`
## BUG-1143 | 健康类区分选择题写不进焦点表,流程静默提前交付
- 状态:resolved(2026-10-01 Claude 直接执行;77 例真库持久回放修前修后对比;未部署时以部署后 health 为准)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`frontend/src/lib/rectification-agentic/v9/server-focus.ts`(区分类焦点写 `targetDomain`)、`answer-choice.ts::persistFocusAfterChoice`(写入失败被吞)、所有生时校正用户。
- 用户现象:答完两三道选择题就直接出交付卡,后面还有家人、感情、搬迁等可答的题却不再问;与「按盘型挑题」开关无关。
- 触发条件:任一轮的区分题落在健康领域(引擎领域名 `health_pressure`)。
- 根因:区分类焦点把计划层领域名原样写入 `agentic_rectification_conversation_focuses.target_domain`;该列检查约束只收 `education/career/relationship/relocation/finance/health/family/other`。写入抛 `target_domain_check` 违约,被 `persistFocusAfterChoice` 的 catch 吞掉(日志仅 `retry next focus failed reason=tool_failed`),流程落到 exhaustion 出口 `terminalNote` 交付。采集类焦点早经 `persistableFocusDomain` 映射,09-13 `530f260f`(BUG-661~663)的区分类写入分支漏掉。假数据库测试不校验约束,所以一直没测出来。
- 修复:新增 `focusTargetDomain`,所有意图的焦点写入都经 `persistableFocusDomain`;`method-followup.ts` 三处焦点与探针的领域比较改走 `sameCollectDomain`(BUG-672 规则);不改数据库约束。
- 验证:`frontend/tests/rectification-focus-target-domain-20261001.test.ts`(引擎全部领域名 × 四种意图映射后都在迁移的约束清单内;健康区分焦点存 `health` 且与 `health_pressure` 探针同域;源码合同禁止原样写领域名与 `probe.domain === focus.targetDomain`)。真库(PostgreSQL 17 替身)持久回放 77 例 × ±10/±30(`docs/research/varga_resolution_persisted_onoff_{before,after}_fix_2026_10_01.json`):平均问题数 ±10 2.82→4.56、±30 3.58→5.43;问满 6 题 21→50、30→69;默认顺序下 D9 / D10 头段命中 ±10 65→70 / 64→65、±30 38→46 / 39→52;真值段保留全部 76/76;修后日志 `retry next focus failed` 0 条。
- 防复发:焦点写入只能经 `focusTargetDomain`;引擎领域与表约束的对照由测试逐值锁定。
- 相关记录:BUG-672(同族:健康 / 职业别名)、BUG-586、BUG-661~663、BUG-1144。
- 复发自:BUG-672(别名未经归并,写入侧新分支漏掉)。
- 修复版本:`codex/rectification-segment-order-20261001`。
## BUG-1144 | 「按盘型挑题」默认关闭
- 状态:resolved(2026-10-01;默认开启窗口 ≤ 61 分钟)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`core/segment-probe-order.ts`、`v9/score-persist.ts`。
- 用户现象:盘型口径实现(BUG-1116)把按段选题做成环境变量 `RECTIFICATION_SEGMENT_ORDER=on` 才启用,生产默认按引擎信息增益出题,分盘头段命中低于研究「按段」列。
- 根因:实现单允许 T5 延后;执行方因真库持久回放未跑通而默认关闭。
- 修复:`segmentOrderEnabledFor(windowMinutes, flag)`:默认窗口 ≤ 61 分钟开启,`off` 关闭,`on` 为研究包络。
- 验证:修复 BUG-1143 后的 77 例真库持久回放,开 vs 关:±10 D9 70→71、D10 65→67;±30 D9 46→51、D10 52→55;真值段保留四格全部 76/76。修 BUG-1143 前 ±30 开启曾丢 3 例真值段(`…before_fix…json`),根因即 BUG-1143(开启后健康题被排到第一位,更早撞上写入失败)。`frontend/tests/rectification-segment-order-default-20261001.test.ts`。
- 防复发:放宽到 > 61 分钟前须用同一回放脚本在对应窗口重跑并逐格核对真值段保留。
- 相关记录:BUG-1116、BUG-1143、BUG-1105。
- 复发自:无
- 修复版本:`codex/rectification-segment-order-20261001`。
## BUG-1145 | 普通对话进度栏提前打勾:回答提纲在算盘前就显示为「已完成」
- 状态:resolved(2026-10-01 Claude 直接执行;单测复现修前失败、修后通过;未部署时以部署后 health 为准)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`frontend/src/lib/consultation-run-timeline.ts`(普通对话进度栏的实时与历史重建共用同一个 reducer);生时校正进度栏不用这个 reducer,不受影响。
- 用户现象:发问后,「先回答你问的这件事」「把判断钉在盘上」「时间窗口怎么看」「这周可以做什么」四行立刻全部打勾,而这时「正在计算本命盘…」还在转,正文一个字都没写。
- 触发条件:任何本命普通对话首轮(通用 / 每日 / 申报时段的计划节同理)。
- 根因:服务端在第一个流块就下发回答提纲(`think.plan` + 每节一条 `thinking.section`);reducer 把每节做成一行 live 的 think 行,而 `completeLiveThink` 在下一个事件(下一节、`tool.started`)到来时把所有 live think 行标成完成。提纲是「回答将怎么写」,不是已走过的步骤,却按步骤渲染,于是算盘一开始四行就假完成。自 09-18 进度栏设计(`94c1e81f`)起即存在;BUG-1132 之后回答按人分段、「盘上依据」「时间怎么看」可省,提纲与真实正文进一步对不上。
- 修复:产品 10-01 选 B——提纲不再成行。`think.plan` 不改状态;`thinking.section` 只把领域与对照项暂存为计算行的明细(`calculateHints`),在计算行出现时补上(实时与历史一致);正文开始(`answer.delta`)时计算行收口为完成;历史重建的思考文本改走 `thinking.delta` 行,不再挂在提纲行上。服务端仍下发 `think.plan` / `thinking.section`(公共事件合同与存档字段不变,供 `applyThinkingSectionProgress` 等使用),客户端只是不再把它们画成行。
- 验证:`frontend/tests/consultation-run-timeline.test.ts` 新增三条(算盘前提纲不成行、只有「读取分析方法」完成;整轮结束无提纲行、无 live 行;历史重建与实时同形且计算行仍有明细),三条在修前代码上失败、修后通过;改写一条既有断言(三栏见测试注释)。
- 防复发:进度栏只画真实发生的步骤(方法、计算、写作、模型推理);回答提纲若要再上屏,必须由正文标题驱动、不能用「下一事件到来即完成」的规则。
- 相关记录:BUG-942~944(进度 / 思考 / 正文分通道)、BUG-1074(发出即显示排队行)、BUG-1132(按人分段)。
- 复发自:无
- 修复版本:`codex/consult-timeline-no-plan-20261001`。
## BUG-1146 | 采用后的核对题只有题干、没有选项按钮,刷新也不出;题挂在上一题的旧回复下
- 状态:resolved(2026-10-01 Claude 直接执行;单元与客户端回归测试。**第一版 `282455cc` 真机仍挂错位置**:采用轮次的 requestId 不是 uuid,真库拒写、被吞(BUG-1149),补修后真库重放通过;真机复核以 `docs/testing/rectification-post-adopt-card-20261001.md` 为准)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/method-followup.ts`(已采用时承接活焦点的 keep 分支)、`candidates/accept/route.ts`、`v9/answer-choice.ts::writeAdoptFollowupTurn`(新)、`rectification-chat-accept-run.ts`。所有点「采用」后出现核对题的校正,盘型口径之前也会。
- 用户现象:真机点「采用」后出现「2016 年 9 月那次升学…」题干,下面没有四个按钮;刷新后仍然没有。题干挂在答上一题那条消息(「已记录,范围没变;…」)下面。
- 触发条件:采用成功,`persistNextInterviewIfIdle` 落了一道 reverse_verify 核对题;且采用发生在交付后 60 秒内(BUG-596 的 `already_delivered` 窗口)。
- 根因:(1) 已采用时 keep 分支按活焦点重建 followup,丢了探针身份 `semantic_key` / `candidate_split_hash`;重建出的卡片 id 是 `reverse_verify:<主题>:score`,而落库题号是探针身份 `probe:<key>`,`projectRectificationChoiceCard` 按「不串题」规则返回 null,GET 每次都这样重建,所以刷新也没有按钮。(2) 采用接口只落题、不写对话轮次,丢掉了 `hostNarration`;客户端随后的只读运行在 60 秒窗口内被判 `already_delivered`,也不写轮次,题没有自己的消息,GET 把它挂到了最近一条助手消息(上一题的回复)下。
- 为什么旧测试没拦住:BUG-498 的回归用例里活焦点 `questionId` 与重建 id 本来就一致或为空,走不到 id 比较这一步;采用路径测试只测接口返回,不测采用后的对话轮次。
- 修复:keep 分支从已落库焦点带回 `semantic_key` 与 `candidate_split_hash`(BUG-498 的「无 semantic_key 时 id 必须相等」校验不放宽);采用接口在非幂等采用后用 `hostNarration` 写一条确定性轮次(requestId `<req>:adopt`;有核对题时开头是「已采用 HH:MM。再核对一件过去的事。」,因为题干在挂卡后会从正文去掉),把未挂轮次的 reverse_verify / out_of_sample_check 焦点挂到这条轮次,并标记交付;接口返回 `adopt_turn_id`,客户端拿到后用快照把这条消息换掉进行中那一行,不再发只读运行;幂等采用或无话可说时保持原来的只读续跑。
- 验证:`frontend/tests/rectification-post-adopt-card-20261001.test.ts`(带年份核对题与已知事件质量题两种:持久化 → GET 重建均出 4 个选项,修前两例都红;采用轮次的 requestId、`p_user_message = null`、开头句、焦点挂到新轮次、交付标记;无话可说不写轮次;客户端有 `adopt_turn_id` 时不发只读运行且进行中行被替换,幂等采用仍续跑)。
- 防复发:已采用的 keep 分支必须保留探针身份;采用后出题必须有自己的轮次,不得依赖只读运行补写。
- 相关记录:BUG-498(同一症状:采用后核对卡为空)、BUG-536~538、BUG-596、BUG-678、BUG-1135。
- 复发自:BUG-498(当时修的是 id 前缀不一致;之后核对题落库改用探针身份,keep 分支没跟上)。
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
## BUG-1147 | 盘型口径的交付与答题回复仍念分钟排名,并与卡片自相矛盾
- 状态:resolved(2026-10-01 Claude 直接执行;单元测试;真机复核同 BUG-1146 清单)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/choice-action.ts::composeChoiceNarration`、`v9/answer-choice.ts::adoptHostNarration`、`user-copy.ts::deliveryTurnNarration`;只影响盘型口径(Case 有 `rectificationDomain` 且推断态有 `segment_summary`)。
- 用户现象:同一屏里,答题回复说「范围没变;04:52–04:53 领先、05:00–05:05 落后」;交付卡写「D9 天蝎(较可信)」,旁边的话却说「这几个候选按现有信息分不开」;「这只是代表性候选,不是已确认的唯一出生分钟」卡片里一次、正文里又一次。
- 根因:三处分钟口径文案没有按盘型口径分支:答题回复的分数移动句、交付旁白末尾追加的分钟候选提示(`rangeNarrowHint` 交付分支)、交付正文固定追加的边界句(卡片已有)。
- 修复:盘型口径下答题回复不带分数移动句(只说「已记录,范围没变。」);交付旁白不再追加分钟候选提示;交付正文不重复边界句。分钟口径的文案不变。
- 验证:`frontend/tests/rectification-post-adopt-card-20261001.test.ts`(盘型口径回复无「领先 / 落后」、分钟口径仍有;盘型交付正文不含边界句且含「D9 上升:天蝎(较可信)」,分钟口径仍以边界句结尾)。
- 防复发:盘型口径的用户可见文案不得出现分钟排名;边界句由卡片负责。
- 相关记录:BUG-1115~1117(盘型口径实现)、BUG-1085、BUG-1086。
- 复发自:无
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
## BUG-1148 | 普通对话回答夹英文 / 梵文音译,同一个东西几种叫法
- 状态:resolved(2026-10-01 Claude 直接执行;单元测试 + 真实模型复测;真机复核以部署后 `docs/testing/consult-plain-answer-20261001.md` 为准)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:普通对话所有领域的首轮与追问(本命 / 无出生分钟 / 申报时段共用 `product-voice.ts` 与 `consultation-thinking-plan.ts` 的形状说明)。
- 用户现象:产品要求「其他领域不说人话就改」。10-01 用产品提供的临时 DeepSeek key(deepseek-flash)对公开名人盘跑婚恋、财富、健康、子女、学业、迁移、年运、时机、综合九个领域:整体已是人话、分段、空宫看宫主;但正文出现 sade sati、vargottama、「6.61 rupas」「Sade Sati 未启动」等英文 / 梵文音译,计都写成「克图」,大运下面一层时叫子运、时叫中运 / 副运 / 小段 / 小运。
- 触发条件:证据卡与引擎字段是英文 / 梵文名,形状说明只要求「术语首次用白话套住」,没规定用哪个中文名、也没禁音译。
- 根因:BUG-1132 的人话自检只管「括号外看不看得懂」,没有名词表。
- 修复:形状说明(`product-voice.ts` ANSWER SHAPE、`NATAL_ANSWER_SHAPE_SUMMARY`、`natalAnswerShapeBody()`、追问轮指令)加一条:名词只用中文、全篇一个叫法(罗睺、计都;大运 → 子运 → 小运),不写英文或梵文音译(sade sati、vargottama、rupas 等,给出中文替代说法),力量只说强 / 中 / 弱。
- 验证:`consultation-thinking-plan.test.ts`、`consultation-voice-contract.test.ts` 各加一条;真实模型复测同 5 个问题:英文 / 音译 6 处 → 0,「克图」4 处 → 0,子运混称 5 处 → 1(那一处是在解释「子运是大运里的一小段」)。
- 防复发:名词规则与人话自检同处定义;新增术语先进这条名词表。
- 相关记录:BUG-1132(人话自检)。
- 复发自:无
- 修复版本:`codex/consult-term-glossary-20261001`。
## BUG-1149 | 采用轮次与交付收尾轮次的请求号不是 uuid,真库拒写且被吞
- 状态:resolved(2026-10-01 Claude 直接执行;本机真 PostgreSQL 17 重放:修前报 uuid 语法错误,修后采用轮次写入成功)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/answer-choice.ts::writeAdoptFollowupTurn`(BUG-1146 新增)、`exhaustionGateRequestId` → `persistExhaustionGateTurn`(`turn-exit.ts`、`agent-run-finish.ts`、`ensureNonTerminalTurnExit` 三处调用)。
- 用户现象:BUG-1146 部署后真机点「采用」,核对题仍挂在上一题「已记录,范围收到…」下,没有单独的采用消息;交付收尾那句(无轮次时补写的旁白)在库里从来没有,刷新或重挂后就不见了。
- 触发条件:每次采用;每次走交付收尾补写。
- 根因:`append_agentic_rectification_turn` 现行(V10)重载的 `p_request_id` 是 uuid;`<请求号>:adopt` 与 `<轮次号或案例号>:gate` 都不是 uuid,PostgreSQL 报 `invalid input syntax for type uuid`。两处调用方都在 try/catch 里只打一行 warn,交付标记却已经打上,所以只读续跑也不写轮次。假数据库测试不校验类型(BUG-1146 的单元测试断言的正是错误的 `req-1:adopt`)。
- 修复:`deterministicTurnRequestId(seed)` 用 sha256 派生 v5 形 uuid(同一种子同一 uuid,重试仍幂等);`adoptTurnRequestId`、`exhaustionGateRequestId` 改用它。
- 验证:`frontend/tests/rectification-post-adopt-card-20261001.test.ts`(两种请求号符合 uuid 格式且同种子稳定;采用轮次断言改为 `adoptTurnRequestId("req-1")`——原值 `req-1:adopt` / 新值派生 uuid / 原因:原值在真库必然失败)。本机 PG17 + 真引擎重放虚构案例:修前 `invalid input syntax for type uuid: "<uuid>:adopt"`,修后 `adopt_turn` 写入。
- 防复发:写轮次的请求号一律 uuid;新增确定性轮次必须在真库(PG 替身)至少跑一次。
- 相关记录:BUG-1146、BUG-596、BUG-1150。
- 复发自:无(`:gate` 自 BUG-596 一侧引入后一直未在真库写成功过)
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
## BUG-1150 | 采用后核对题点任何选项都报「这次没提交上」
- 状态:resolved(2026-10-01 Claude 直接执行;本机真 PostgreSQL 17 重放:修前 `stale_probe`,修后作答成功并收尾)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/server-focus.ts::expectedAnswerSchemaFor`(reverse_verify / out_of_sample_check 的探针身份)。所有计分的采用后核对题。
- 用户现象:采用后核对题四个按钮出现了,点任一个都弹「这次没提交上,请再点一次。」,再点也一样。
- 触发条件:采用后核对题计分(`scoring` 为真),且推断态里该探针的 id 带拆分哈希。
- 根因:核对题盖章时不看推断态(BUG-912 防串题),`probe_id` 写成 `probe:<语义键>`;推断态里同一探针的 id 是 `probe:<语义键>:<拆分哈希>`。作答时前端层按语义键找到探针、用带哈希的 id 写转移,数据库 `persist_agentic_rectification_inference_transition` 发现它与活焦点 schema 的 `probe_id` 不同,拒为 `agentic_rectification_stale_probe`,接口回非 200。
- 修复:核对题盖章时,推断态里若有**同语义键、同拆分哈希**的那一道,就写它的 id;找不到时保持原样(仍不盖无关的高增益探针,BUG-912 不放宽)。
- 验证:`frontend/tests/rectification-post-adopt-card-20261001.test.ts`「a scoring post-adopt check carries the state's probe id」(修前红:实际 `probe:career.2018.dasha_activation`)。本机 PG17 重放虚构案例:修前 `stale_probe`,修后 `applied: true`、收尾句「前事核对到这里…」。
- 防复发:计分题的 schema `probe_id` 必须与推断态 id 同形;采用后流程的回归需在真库走到「作答」一步。
- 已存在的卡:修复前已落库的核对题仍是旧 id,作答仍会被拒;可点「这题跳过」,或新开一次校正。
- 相关记录:BUG-912、BUG-498、BUG-1146、BUG-1149。
- 复发自:无
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
# PROGRESS · 普通对话进度栏去掉回答提纲行(2026-10-01)
任务书:`docs/tasks/TASK-consult-timeline-no-plan-20261001.md`;BUG-1145。
## 实现
- `frontend/src/lib/consultation-run-timeline.ts`:
- `think.plan` 直接返回原状态(不再生成提纲行)。
- `thinking.section` 只把领域与对照项累积到新增的 `calculateHints`,经 `applyCalculateHints` 补到计算行;`tool.started` 建计算行时也补上(提纲在算盘前下发,原 `enrichCalculate` 在实时流里因计算行尚不存在而落空,历史重建里才有明细;现在两边一致)。
- `answer.delta` 同时把计算行收口为完成(原来由 `thinking.section` 顺手收口)。
- `consultationTimelineFromSettled`:有思考文本就发一条 `thinking.delta`,删掉「把思考文本挂到最后一条提纲行」的补丁。
- 服务端 `think.plan` / `thinking.section` 照发(公共事件合同、`consultation-agentic-runtime` 测试锁定 `think.plan` 存在;客户端已不再用它画行)。`thinkPlanSteps` 仍被服务端与测试使用,未删。
## 改动的断言(原值 / 新值 / 原因)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `consultation-run-timeline.test.ts` 首条(改名为 “…then the first write (no outline row)”) | 行种类 `["method","calculate","think","write"]`,第 3 行「先回答你问的这件事」已完成 | 行种类 `["method","calculate","write"]`,不存在该标签行;写作行下标 3→2 | BUG-1145:提纲不是步骤,产品选 B 去掉 |
新增三条:算盘前提纲不成行且只有方法行完成;整轮结束无 think 行、无 live 行;历史重建同形且计算行有明细。三条与改写的一条在修前代码上均失败(已实测:4 fail),修后通过。
## 门禁(Node 22.14)
| 项 | 基线 `f1a169cb` | 分支 |
| --- | --- | --- |
| tsc | — | 0 |
| lint | — | 0 error / 126 warning |
| npm test | 4,851 条,fail 24 | 4,854 条,fail 24(同名) |
| build `/` | — | Static |
| `.next/static` js gzip 合计 | 1,632,680 B | 1,632,662 B(−18 B) |
## 环境缺口
- 浏览器级(真机看进度栏)留给 `docs/testing/consult-plain-answer-20261001.md` 第 10 步。
@@ -0,0 +1,57 @@
# PROGRESS:采用后核对题无按钮(2026-10-01,BUG-1146~1147)
- 模式:直接执行(产品 10-01「保留 然后你修」:盘型口径下保留采用后核对题,Claude 直接修并验收)。
- 基线:`origin/staging` `f1a169cb`;分支 `codex/rectification-post-adopt-verify-20261001`。
- BUG 编号:开工时 staging 最大 1144;1145 已被另一会话(consult-timeline-no-plan)占用,本单用 1146~1147。
## 根因(已用测试复现)
1. 已采用时 keep 分支重建 followup 丢了探针身份,卡片 id 与落库题号 `probe:<key>` 对不上,GET 返回空卡(BUG-498 同症状复发)。
2. 采用接口不写轮次;随后的只读运行在交付后 60 秒内被判 `already_delivered`(BUG-596),也不写;题挂到上一题回复下。
3. 盘型口径下三处分钟文案未分支(BUG-1147)。
## 改动
| 文件 | 改动 |
| --- | --- |
| `v9/method-followup.ts` | keep 分支带回 `semantic_key` / `candidate_split_hash` |
| `v9/answer-choice.ts` | 新 `writeAdoptFollowupTurn`(写轮次 + 挂焦点 + 交付标记);盘型口径下答题回复无分数移动句、交付旁白不追加分钟候选提示 |
| `candidates/accept/route.ts` | 非幂等采用后写采用轮次,返回 `adopt_turn_id` |
| `rectification-chat-accept-run.ts` | 有 `adopt_turn_id` 时用快照替换进行中行,不发只读运行 |
| `user-copy.ts` | `postAdoptCheckLead`;盘型交付正文不重复边界句 |
| `v9/choice-action.ts` | `segmentProduct` 参数 |
不改数据库结构;所用 RPC(`append_agentic_rectification_turn`、`set_agentic_rectification_conversation_focus`)采用后状态 `accepted` 不属终止态,既有只读续跑路径已在采用后调用过。
## 验收
见本文件末尾「门禁」一节(与基线 `f1a169cb` 逐条比对)。真机:`docs/testing/rectification-post-adopt-card-20261001.md`。
## 门禁(基线 `f1a169cb` vs 本分支,同机同 node_modules)
| 项 | 基线 | 本分支 |
| --- | --- | --- |
| `tsc --noEmit` | 0 | 0 |
| `npm run lint` | 0 error | 0 error |
| `npm test` 用例名 | 4833 | 4841(+8 新增,缺失 0) |
| 失败清单(无 Docker 的 DB / 部署套件) | 24 | 24,逐条一致 |
| cancelled | 0 | 0 |
| `next build` `/` | ○ Static | ○ Static |
| 首屏 gzip JS | 555,622 | 555,659(+0.007%) |
| 按镜像 COPY 清单的构建 | — | 通过 |
| `tests/test_repo_privacy_markers.py` | — | 通过 |
Python 快速门:本单不改 `scripts/**`、`tests/**`,未跑。`test:db`:不改表与 SQL,所用 RPC 采用后已有调用路径,未跑;部署后真机清单覆盖。
## 第二轮(真机回报后,BUG-1149~1150)
真机:采用后核对题仍挂在上一题下面;点任何选项都「这次没提交上」。本机真 PostgreSQL 17 + 真引擎重放虚构案例复现两处:
| 步骤 | 修前 | 修后 |
| --- | --- | --- |
| 写采用轮次 | `invalid input syntax for type uuid: "<uuid>:adopt"`(被吞) | 写入 |
| 作答核对题 | `agentic_rectification_stale_probe`(schema `probe:<键>` ≠ 推断态 `probe:<键>:<哈希>`) | `applied: true`,收尾句 |
同根问题:交付收尾补写轮次的 `<id>:gate` 请求号也不是 uuid,一并修。真机反映的「选中后旁白和卡片消失一会儿」本机无浏览器未复现;采用路径现在不再发只读续跑、收尾句能落库,待真机复测,复现则请录屏。
第二轮门禁(`52495df7`,基线 staging `fa6d026f`):tsc 0、lint 0 error、用例 4866(失败 24 条与 f1a169cb 基线逐条一致、cancelled 0;唯一「缺失」名为 c0f05912 改名)、`/` ○ Static、首屏 gzip JS 555,642、镜像 COPY 布局构建通过;真库重放 8 条中可采用的 1 条:采用轮次写入、作答成功。
@@ -0,0 +1,49 @@
# PROGRESS · 生时校正:健康区分题写入失败 + 按盘型挑题默认开启(2026-10-01)
执行:Claude 直接执行(产品负责人 2026-10-01「同意」:前后 10 分钟先开、修复后重测前后 30 分钟,过红线再放开)。分支 `codex/rectification-segment-order-20261001`。无单独任务书;背景见 `TASK-rectification-varga-resolution-20260930.md` T5 与 `docs/research/rectification_varga_resolution_2026_09_30.md` §4。BUG-1143、BUG-1144。
## 经过
1. 77 例 × ±10 / ±30 真库持久回放(PostgreSQL 17 替身;`frontend/tests/research/rectification-segment-persisted-replay.ts` 经 `scripts/research/varga_resolution_persisted_replay.py`)比较按段选题关 / 开:±10 开启头段多命中 6 例、真值段 0 丢失;±30 开启丢 3 例真值段(`docs/research/varga_resolution_persisted_onoff_before_fix_2026_10_01.json`)。
2. 逐例插桩(临时,未提交):丢失不是排序删题。区分类健康题写焦点时 `target_domain=health_pressure` 违反表约束,写入失败被吞,流程从 exhaustion 出口提前交付;开启排序只是把健康题排到第一位、更早撞上。默认顺序下同样发生(BUG-1143,BUG-672 同族复发)。
3. 修 BUG-1143 后重跑(`…after_fix_2026_10_01.json`)。
## 77 例真库持久回放(修前 → 修后)
| 窗口 / 顺序 | 平均问题数 | 问满 6 题 | D9 头段命中 | D10 头段命中 | 真值段保留 D9 / D10 |
| --- | --- | --- | --- | --- | --- |
| ±10 默认 | 2.82 → 4.56 | 21 → 50 | 65 → 70 | 64 → 65 | 76/76 → 76/76 |
| ±10 按段 | 2.87 → 4.61 | 22 → 52 | 68 → 71 | 67 → 67 | 76/76 → 76/76 |
| ±30 默认 | 3.58 → 5.43 | 30 → 69 | 38 → 46 | 39 → 52 | 76/76 → 76/76 |
| ±30 按段 | 3.53 → 5.43 | 29 → 69 | 41 → 51 | 40 → 55 | 74/75 → 76/76 |
修后日志 `retry next focus failed`:0 条。±60 未回放(排序包络上限 61 分钟,>61 不启用)。
## 改动
| 文件 | 改动 |
| --- | --- |
| `v9/server-focus.ts` | 新增 `focusTargetDomain`,所有意图焦点写入经 `persistableFocusDomain` |
| `v9/method-followup.ts` | 三处焦点 ↔ 探针领域比较改 `sameCollectDomain` |
| `core/segment-probe-order.ts` | `segmentOrderEnabledFor(window, flag)`:默认 ≤ 61 分钟开、`off` 关、`on` 研究包络 |
| `v9/score-persist.ts` | 用上面函数代替 `RECTIFICATION_SEGMENT_ORDER === "on"` |
| 测试 | `rectification-focus-target-domain-20261001.test.ts`(3)、`rectification-segment-order-default-20261001.test.ts`(2) |
既有断言改动:无。
## 门禁(对 `origin/staging`,替身真库除外均为本机)
| 检查 | 基线 | 本分支 |
| --- | --- | --- |
| tsc / lint | 0 / 0 error、126 warning | 0 / 0 error、126 warning |
| npm test | 4846 / fail 24 | 4851 / fail 24;失败清单逐名相同,基线名 0 丢失,新增 5 |
| 按镜像 COPY 清单构建 | — | exit 0 |
| `/` / 首屏 gzip | Static / JS 555,622、CSS 68,813 | Static / 相同 |
| 快速门 | — | Python 1036 passed / 1 skipped / 0 failed |
默认窗口由 21 放宽到 61 后:受影响 6 个测试文件 322 / 322、tsc 0。
## 缺口
- 真库证据为 PostgreSQL 17 替身;本次不改数据库结构。
- 真机:部署后在 staging 走一次前后 30 分钟窗口的校正,确认能问到健康题、能问满几题。
+3
View File
@@ -264,6 +264,8 @@
| `TASK-rectification-message-cleanup-20261001.md` | `PROGRESS-rectification-message-cleanup-20261001.md` | **校正消息清理**:题干两遍(作废旧焦点仍打印题干 + 独立问题块再打印一次)→ 一屏一题干;证据轮正文改为「记下了 N 件事」+ 服务端清单可展开;校正结算后不显示「已完成 N 步」与「1.」泄漏。推翻 BUG-917 作废题留题干、VOICE 逐条复述 | 已验收(Claude 10-01:全套门禁对 staging 209352eb 一致;「1.」来源为推断——收起的有序列表复制补号,结算后整块不渲染已消除;真机待产品) | BUG-1135~1137 |
| `TASK-rectification-chart-tier-caveats-20261001.md` | — | **报告与聊天标注判断不了的分盘**:采用后读 `active_birth_provenance`(segment-v1)→ 结果 `segment_summary` 档位,blocked/indistinct 的 D9/D10 在聊天 answer_policy 与一句说明、报告章节顶部提示 + 主题降级;旧资料逐字节不变;不改库、不改 consult/route.ts | 待领取 | BUG-1138~1139 |
| `TASK-rectification-angle-timing-research-20261001.md` | `PROGRESS-rectification-angle-timing-research-20261001.md` | **角度类时间技法研究(离线)**:行运土木罗压本命上升 / 天顶度数、次限推运角、太阳弧角(1° ≈ 4 分钟出生时间),77 例 v5;乱序日期对照 ≥ 50 次必做、留一法、不得挤出真值;显著才叠加到六题后验比头段命中与区间宽度 | 已实现待验收(0/9 预登记检验显著,BUG-1141 closed_by_design;分支 codex/rectification-angle-timing-research-20261001,未推送) | BUG-1141 |
| (直接执行,无任务书) | `PROGRESS-rectification-segment-order-20261001.md` | **健康区分题写入失败(BUG-1143)+ 按盘型挑题默认开启(BUG-1144)**:区分类焦点把 `health_pressure` 原样写入、违反表约束、写入被吞后提前交付(BUG-672 同族);修后 77 例真库回放平均问题数 ±10 2.8→4.6、±30 3.6→5.4,D10 头段 ±30 39→52;按段选题 ≤61 分钟默认开,±30 D9 46→51、D10 52→55,真值段 76/76 | 已验收(Claude 10-01 直接执行) | 分支 codex/rectification-segment-order-20261001 |
| (直接执行,无任务书) | `PROGRESS-rectification-post-adopt-card-20261001.md` | **采用后核对题无按钮(BUG-1146)+ 盘型口径回复念分钟排名(BUG-1147)**:keep 分支丢探针身份致 GET 空卡(BUG-498 复发);采用接口不写轮次、只读运行被 60 秒交付窗口挡掉,题挂到上一题回复下;改为采用接口写「已采用 HH:MM。再核对一件过去的事。」轮次并挂题。盘型口径下去掉「领先 / 落后」、交付旁白不再说「分不开」、边界句只说一次 | 已验收(Claude 10-01 直接执行);真机回报后补修 BUG-1149(请求号非 uuid 被真库拒写)、BUG-1150(核对题作答 stale_probe),本机真库重放通过;真机清单 `docs/testing/rectification-post-adopt-card-20261001.md` | 分支 codex/rectification-post-adopt-verify-20261001 |
| `TASK-rectification-typed-event-scoring-research-20260929.md` | `PROGRESS-rectification-typed-event-research-20260929.md` | **打字经历按选择题规则计分(离线研究,不上线)**:计分通道不对称 + 已入账年份挡题;R0 学业质量题措辞 / 年精度显示成 1 月(冻结文件,需重新冻结) | 已验收关单(Claude 10-01:三类技法 9 项检验 0 显著,真值平均排位 0.49~0.59;Claude 补正对照——埋入信号排位 0.077、挪年 0.502——流程有效;BUG-1141 closed_by_design) | BUG-1088、1089 |
| `TASK-report-reader-polish-20260929.md` | `PROGRESS-report-reader-polish-20260929.md` | **报告页打磨**:生成入口挪进页面主体(删标题栏按钮)、详情页到底部按钮(懒渲染一次到底)、导出按钮带文字、目录一级/二级分层、去掉「字段」与 RL/NL 缩写表头、状态列同义重复去重、报告表格淡底色、分块导出显示真实文件大小 | Claude 直接执行并自验(tsc 0、lint 0 error、全量 fail 与基线同 24 条、`/` Static、gzip +7 B、快速门 Python 1000 passed),已部署 staging `d7772011`,真机欠 | BUG-1092~1094 |
| `TASK-report-english-edition-20260929.md` | `PROGRESS-report-english-edition-20260929.md` | **报告中英两版**:同一次引擎计算渲染 zh/en 两遍(不用模型翻译),瑜伽库 477 条补英文、模板与前端表头英文化;中文逐字节不变、英文零汉字、两版数字序列一致;阅读页 `?lang=en` 切换,导出当前语言 + 「问 AI 建议导出英文版」提示;旧报告不补英文 | Claude 直接执行(含两个 fork 子代理)并自验(tsc 0、lint 0 error、全量 fail 与基线同 24 条、`/` Static、gzip +0.06%、Python 定向全绿),已部署 staging `d7772011`,真机欠 | — |
@@ -399,3 +401,4 @@
- **实现合入 `staging` 的同一次推送里,必须同时把状态板那一行改掉。** 2026-09-16 的对账发现 10 份早已合入的单仍写着「待领取 / 待验收」,会导致重复派活。
| `TASK-consult-plain-answer-20261001.md` | `PROGRESS-consult-plain-answer-20261001.md` | 普通对话「还是废话」:09-17 四步开场形状(格局名 → 谁推谁修 → 扮演哪个象)逼出谜语,问父母时爸妈被揉成一段;产品授权推翻该形状,改成先答 + 按问题里的对象分段 + 人话自检 + 空宫不单独下结论 + 父母卡标 mother/father(BUG-1132~1134) | **已实现,待 Claude 验收**(fork 子代理直接执行);未推 staging、未部署;模型对比为环境缺口,真机清单 `docs/testing/consult-plain-answer-20261001.md` | 分支 `codex/consult-plain-answer-20261001` |
| `TASK-consult-answer-clock-20261001.md` | `PROGRESS-consult-answer-clock-20261001.md` | 普通对话答题时钟 70 s 容不下推理模型(v4-pro 77 s),无出生分钟路线只受 110 s 工具钟管;产品定 5 分钟防卡死(BUG-1142) | **已实现,待 Claude 验收**(直接执行) | 分支 `codex/consult-answer-clock-20261001` |
| `TASK-consult-timeline-no-plan-20261001.md` | `PROGRESS-consult-timeline-no-plan-20261001.md` | 普通对话进度栏提前打勾:回答提纲在算盘前就作为四行步骤显示并全部打勾(BUG-1145);产品选 B 去掉提纲行,只显示真实步骤 | **实现完成,待 Claude 验收**(直接执行) | 分支 `codex/consult-timeline-no-plan-20261001` |
@@ -0,0 +1,31 @@
# TASK · 普通对话进度栏去掉回答提纲行(2026-10-01)
- 基线:`origin/staging` `f1a169cb`
- 分支 / worktree:`codex/consult-timeline-no-plan-20261001` / `.worktrees/consult-timeline-no-plan-20261001`
- 模式:直接执行(产品 10-01 选 B 后明确要求直接做),Claude 派子代理实现、Claude 独立验收
- BUG:BUG-1145
## 事故实证
产品 10-01 真机截图:问「我跟我父母的关系怎么样」,进度栏「读取分析方法」之后立刻出现「先回答你问的这件事 / 把判断钉在盘上 / 时间窗口怎么看 / 这周可以做什么」四行且全部打勾,最下面「正在计算本命盘…」仍在转。
## 根因
`frontend/src/lib/stream-agent-response.ts` `flushThinkingPlan` 在第一个流块就下发回答提纲(`think.plan` + 每节 `thinking.section`);`frontend/src/lib/consultation-run-timeline.ts` `reduceConsultationTimeline` 把每节做成 live think 行,`completeLiveThink` 在下一个事件到来时把它们全标完成。提纲不是走过的步骤,却按步骤渲染。
## 决策记录
产品 10-01 在两案中选 **B:去掉提纲行,只显示真实步骤**(读取分析方法 → 计算本命盘 → 正在写 → 完成)。未选 A(提纲等算完再出现、按正文标题逐条打勾),原因:BUG-1132 之后回答按人分段、两节可省,提纲与正文对不上;产品偏好「多余入口宁可删除」。
## 硬红线
1. 只改渲染层(timeline reducer);服务端事件、`thinkingPlan`、`applyThinkingSectionProgress`、存档字段、API / DB 结构不动。
2. 生时校正进度栏不动(它不用这个 reducer,只共用行类型与组件)。
3. `page.tsx` 不增长;不加 spinner / 骨架。
## 任务与验收
- T1 reducer:`think.plan` 不改状态;`thinking.section` 只暂存计算行明细并在计算行出现时补上;正文开始时计算行收口。验收:新增回归测试在修前失败、修后通过。
- T2 历史重建与实时同形(`consultationTimelineFromSettled` 走同一 reducer;思考文本改走 `thinking.delta` 行)。验收:历史重建测试。
- T3 记录:BUG_HISTORY、CHANGELOG、DESIGN.md、真机清单第 10 步、本任务书与进度记录、索引。
- 门禁:tsc 0;lint 0 error;`npm test` 失败名单与基线同名;`next build` `/` Static;首屏 gzip ±2%。
@@ -13,3 +13,4 @@
| 7 | 第 1 步的回答如果明显被截断(最后一节没写完就停了) | 记下时间和模型名报给 Claude——这是取消字数上限后要盯的风险(见进度记录「长度副作用核对」) |
| 8 | (BUG-1142,答题时钟改 5 分钟后)选 `deepseek-v4-pro` 新开对话,问「我和父母关系如何,他们怎么对待我」 | 回答完整写到「这周可以做的一件事」,不出现「回答未完成」;记下从发出到写完大约多少秒 |
| 9 | 用没填出生分钟的人物档案、选 `deepseek-v4-pro` 问一个长问题(例如「完整讲讲我今年的事业和感情」) | 回答写完,不出现「回答未完成」 |
| 10 | (BUG-1145)新开对话问任一问题,盯住回答上方的进度栏 | 只出现「读取分析方法」「计算本命盘」「正在写…」这类真实步骤;不再出现「先回答你问的这件事 / 把判断钉在盘上 / 时间窗口怎么看 / 这周可以做什么」四行,也没有在算盘时就打上的勾;刷新后打开这条历史对话,进度栏一样 |
@@ -0,0 +1,17 @@
# 真机清单:采用后的核对题(2026-10-01,BUG-1146~1147)
> staging 部署后,用自己的账号在手机(375px 宽)走一遍。用虚构经历即可。**请新开一次校正**——修复前已出现的核对题仍答不了(可点「这题跳过」)。
1. **走到交付**:新开一次生时校正,答到出现交付卡(每张盘的上升 + 档位)。
- 交付卡旁边的话里**没有**「这几个候选按现有信息分不开」;
- 「这只是代表性候选,不是已确认的唯一出生分钟」整屏只出现一次(在卡片底部)。
2. **答题回复**:交付前每答一道选择题,回复里**没有**「某某分钟领先、某某分钟落后」。
3. **采用**:在交付卡上点「更像这个」。
- 出现一条新的助手消息「已采用 HH:MM。再核对一件过去的事。」,不是挂在上一题「已记录…」下面;
- 如果有核对题,题目下有**四个按钮**,可以点。
4. **刷新**:刷新页面,核对题还在、四个按钮还在、仍挂在那条采用消息下面。
5. **答核对题**:点一个按钮,不出现「这次没提交上」,能进入下一题或看到「前事核对到这里…」。
5a. **选中时**:点「更像这个」后,上面的旁白和卡片不应消失;如果仍消失一下,请录屏发给 Claude。
6. **旧校正会话**:从会话列表打开一个以前的校正会话,能打开、不报错。
有任何一项不符,截图发给 Claude。
+3 -3
View File
@@ -378,7 +378,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. A subtitle “最可能 HH:MM” (the representative minute) sits under the title (D3, 2026-09-26), only when the percentages are shown (first column ≥ 5 points ahead). The card appears once the refresh, the skip retry and the seven targeted kinds have been asked, or the reader said there is nothing more to add. Guided boundary windows are asked when they come up (at most two per Case, D1 2026-09-26) but an unasked window no longer holds the card (D2 2026-09-26). The precision gate (range ≤ 10 minutes, top-two gap > 3 points, no exact tie) is reported as `precision_gate_met` but never gates the card on its own: meeting it skips no remaining line, and failing it adds no marker to the card once there is nothing left to ask. No free-text invite. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered, once the precision gate or the “nothing more” stop is met. | enabled |
| `guided-collect` | a named yes/no card then an entry card. A window whose boundary track carries a domain (D9 / D10 Narayana) asks “YYYY 年 M 到 M 月之间,有没有<那一类事>?”; every other window asks openly — “YYYY 年 M 到 M 月之间,有没有什么事,比如<two or three still-open kinds>?” — and one window is asked once, whatever label it carries. The entry card is seven type chips (an open window defaults to the first still-open kind) plus a month-granularity date picker (day optional, years from birth year to this year); submitting writes “YYYY 年 M 月(D 日),<领域标签>方面有一件事”, never the question’s example list. Composer stays enabled. | enabled (typing still records), stop visible |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible |
| `adopting` | “正在采用 HH:MM…” until the adopt message arrives (server-written with the first post-adopt check, BUG-1146; idempotent accept still runs the follow-up turn) | enabled (typing queues), stop visible |
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
| stopped | the row settles with what streamed; a grey caption “已停止,已生成的内容保留;本次不会扣点。” under the body, never `role="alert"` | enabled |
@@ -482,7 +482,7 @@ The birth-time rectification session is the consultation transcript plus a house
#### Streaming states
Every assistant reply moves through the same states on both chat surfaces, and each state has exactly one visual. The step timeline (`ConsultationRunTimeline`) is the only activity surface. Consultation thinking is complete `think.step` items (v1 `thinking.delta` still renders for one release). Rectification never shows thinking text: provider reasoning is dropped at the public boundary.
Every assistant reply moves through the same states on both chat surfaces, and each state has exactly one visual. The step timeline (`ConsultationRunTimeline`) is the only activity surface. Its rows are only steps the run has actually taken — reading the method, calculating the chart, writing (one row per heading the answer has written), and model reasoning when present. The answer outline the server plans (`think.plan` / `thinking.section`) is never a row: it only details the calculate row (2026-10-01, BUG-1145; it used to show four outline rows that ticked done the moment the calculation started). Consultation thinking is complete `think.step` items (v1 `thinking.delta` still renders for one release). Rectification never shows thinking text: provider reasoning is dropped at the public boundary.
| State | When | Visible | Transition in |
|---|---|---|---|
@@ -883,7 +883,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
**普通对话发出即有反馈(2026-09-28,TASK-consult-first-frame-and-pacing,BUG-1074)。** 按下发送的那一帧,回答行就带着步骤栏,唯一一行是排队行「收到,正在看你的问题…」(`QUEUED_TIMELINE_ROW`,行内 `InlineSpinner` + shimmer,summary 写「正在分析」)。服务端流的第一个字节也是这句(`activity` 事件,phase `received`),所以那一帧到达时行不换字。不再有「分类结果回来之前整行静默」:只有服务端判定为寒暄的回复(`run.completed` 带 `responseKind: "smalltalk"`)才把步骤栏收掉、只留一句话,寒暄前会闪约一秒这一行,属于流式生成中的正常形态。首事件之后的行照旧由服务端事件推动;不新增第二种 live 标记,不加 spinner 面板或骨架。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。打字回答的 live 行从发出那一帧起就是阶段句「收到,正在对照你的档案…」,按服务端 `turn.progress` 换成「正在记下这件事…」「正在重新对照盘面…」「正在准备下一个问题…」,属于流式生成中(BUG-1047)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。打字回答的 live 行从发出那一帧起就是阶段句「收到,正在对照你的档案…」,按服务端 `turn.progress` 换成「正在记下这件事…」「正在重新对照盘面…」「正在准备下一个问题…」,属于流式生成中(BUG-1047)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后若还有前事核对,服务端写一条自己的采用消息(「已按 HH:MM 排盘…」),核对题的四个选项挂在这条消息下,进行中那一行由它替换,不挂到上一题的回复下(BUG-1146)。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户菜单的弹窗项(`/?account=`)或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、其余一律当前人物的空白首页,同 BUG-1052),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。
+2 -2
View File
@@ -310,7 +310,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件和底部提问提示卡。「带这天去提问」在页面最底部的提示卡里(2026-09-26 从顶栏挪下来)。不另印定性句。 |
| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 |
| 9月14日 · 生时校正 / 生时校正 · 9月14日 / 生时校正 · 09:35(创建钟点) | 生时校正 · 05:07 / 生时校正 · 05:00–05:15 / 生时校正 | 校正标题写结果:已确认优先于已采用,写 `生时校正 · HH:MM`;否则当前范围写 `生时校正 · HH:MM–HH:MM`(间隔号,不是连字符);都没有则只写「生时校正」。今日节奏仍是 `今日节奏 · M月D日`。同日不再用墙钟去重。副标题仍是最后活动时间。旧标题按结果批量改;手改过的名字不动。 |
| 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 还没睡呀,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符 |
| 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 夜里好,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符。2026-10-01 起开场语是知心姐姐的口吻:只打招呼、关心,不评论用户(不写「夜猫子」「还没睡呀」——深夜打开的人可能失眠、可能心里有事,不需要被点评) |
| 你更适合把已经积累的专业能力做成长期事业,而不是频繁换赛道。眼下更值得把现有工作做深,而不是立刻另起一摊。 | 今年工作上机会不少,但真正决定你走多远的是规矩和身体,不是人缘。<br>## 盘上依据<br>木星今年走过你的事业宫,机会和帮你的人都在(木星行运 10 宫);同时土星压在你的工作宫,事情多、要求严,身体也容易被拖累(土星行运 6 宫)。<br>## 时间怎么看<br>9 月木星换宫之前,机会多但不稳;之后再谈升职更有把握。 | 坏的那句像顾问报告:只有结论,没有盘上依据。好的那段先答、再给依据和时间,每句删掉括号后都看得懂(盘上数据是示意)。 |
| (用户说「你在说什么鬼」)抱歉让你困惑了,我再解释一下:从星盘来看,您的事业宫…… | 说直接点:这份工作还值得留,但你得换个位置,不是换公司。哪句没看懂,指给我。 | 追问轮不重开一遍报告,也不道歉铺垫。一句白话结论 + 一句把球踢回去。 |
@@ -414,7 +414,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 今日星语句库(2026-09-30,BUG-1109)
- 首页问候下面的今日星语来自固定句库 `frontend/src/lib/daily-starlanguage-library.ts`,不走模型。改句子只改这个文件,全部句子的审阅表在 `docs/tasks/PROGRESS-daily-line-library-20260930.md`。
- 一句 trend = 宫位句(说今天的心思落在哪类事上)+ 月相尾句(说今天适合推进还是收尾)。宫位句不写「第几宫」「宫主」「相位」「大运」,用生活里的词:工作、家、钱、朋友、休息。
- 一句 trend = 宫位句 + 月相尾句。宫位句先用半句交代为什么(「今天月亮走到你盘上管钱的位置」),再给一句具体的判断或建议(「买东西、谈价钱前多算一遍,今天容易花多」);一句里不叠「可能」「更容易」「被放大」(2026-10-01 产品:「含含糊糊」)。不写「第几宫」「宫主」「相位」「大运」,用生活里的词:工作、家、钱、朋友、休息。月相尾句说今天适合推进还是收尾。
- action 是一件今天就能做完的具体小事;caution 是一件要留意的事,不吓人、不断言会发生什么。
- 不写健康诊断、投资建议、法律意见;不写「一定」「必然」「注定」。
- 出生时间没确认时不用宫位,只按月亮所在星座写,caution 固定为「出生时间尚未确认,这里只看大方向,不依赖具体分钟。」
@@ -163,15 +163,31 @@ export async function POST(request: Request, context: RouteContext) {
}
const result = row as Record<string, unknown>;
const civil = parseSavedCivilFields(result);
let adoptTurnId: string | null = null;
try {
const { persistNextInterviewIfIdle } = await import(
const { persistNextInterviewIfIdle, writeAdoptFollowupTurn } = await import(
"@/lib/rectification-agentic/v9/answer-choice"
);
await persistNextInterviewIfIdle({
const next = await persistNextInterviewIfIdle({
accounting,
userId: user.id,
caseId,
});
// BUG-1146: the post-adopt check gets its own adopt message (not the previous answer's).
if (result.idempotent !== true) {
const after = await loadV9CaseDossier(accounting, user.id, caseId);
const adoptTurn = await writeAdoptFollowupTurn({
accounting,
userId: user.id,
caseId,
requestId: parsed.data.requestId,
resultId: after.latestResult?.resultId ?? parsed.data.resultId,
hostNarration: next.hostNarration,
focus: after.conversationSummary.activeFocus,
savedTime: typeof result.saved_time === "string" ? result.saved_time : null,
});
adoptTurnId = adoptTurn?.turnId ?? null;
}
} catch {
// Accept already committed; the next successful turn-exit will replace the stale collect.
}
@@ -188,6 +204,7 @@ export async function POST(request: Request, context: RouteContext) {
result_id: result.result_id,
case_status: result.case_status,
idempotent: result.idempotent === true,
...(adoptTurnId ? { adopt_turn_id: adoptTurnId } : {}),
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
+38 -35
View File
@@ -30,6 +30,12 @@ export type ConsultationTimelineRow = Readonly<{
export type ConsultationTimelineState = Readonly<{
rows: readonly ConsultationTimelineRow[];
answer: string;
/**
* What the run's planned sections say the calculation looks at, held until
* the calculate row exists: the plan is sent before the tool starts, and it
* only ever details that row (BUG-1145).
*/
calculateHints?: Readonly<{ queries: readonly string[]; sources: readonly string[] }>;
}>;
const METHOD_ID = "method";
@@ -55,12 +61,13 @@ export function reduceConsultationTimeline(
return completeRow(state, METHOD_ID, CONSULTATION_DONE_SKILL_LABEL);
}
if (event.type === "tool.started") {
return upsertRow(completeLiveThink(state), {
const created = upsertRow(completeLiveThink(state), {
id: CALCULATE_ID,
kind: "calculate",
status: "live",
label: event.label || CONSULTATION_CHART_CALCULATION_LABEL,
});
return applyCalculateHints(created);
}
if (event.type === "activity") {
if (event.phase === "chart-calculation") {
@@ -88,18 +95,12 @@ export function reduceConsultationTimeline(
if (event.type === "tool.failed") {
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
}
if (event.type === "think.plan") {
let next = completeLiveThink(state);
for (const step of event.steps) {
next = upsertRow(next, {
id: `think-${step.id}`,
kind: "think",
status: "live",
label: consultationThinkTitle(step.title),
});
}
return next;
}
// The answer outline (think.plan / thinking.section) is what the answer
// will be written as, not a step the run has taken, so it never becomes a
// row: it was shown up front and ticked done as soon as the chart calculation
// started, before a word of the answer existed (BUG-1145). The headings the
// answer actually writes show as write rows below.
if (event.type === "think.plan") return state;
if (event.type === "think.step") {
const id = `think-${event.id}`;
const existing = state.rows.find((row) => row.id === id);
@@ -127,14 +128,7 @@ export function reduceConsultationTimeline(
heading: event.heading,
steps: event.steps,
};
const withCalc = enrichCalculate(completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL), section);
const withoutOpen = completeLiveThink(withCalc);
return upsertRow(withoutOpen, {
id: `think-${section.id}`,
kind: "think",
status: "live",
label: consultationThinkTitle(section.title),
});
return applyCalculateHints(addCalculateHints(state, section));
}
if (event.type === "thinking.delta") {
const think = lastRow(state.rows, (row) => row.kind === "think" && row.status === "live");
@@ -152,7 +146,11 @@ export function reduceConsultationTimeline(
});
}
if (event.type === "answer.delta") {
const next = { ...completeLiveThink(state), answer: event.replace ? event.text : `${state.answer}${event.text}` };
// Answer text only comes after the calculation, so the calculate row is
// done by now even if its own completion event has not been seen (BUG-1145:
// the planned sections used to close it).
const calculated = completeRow(completeLiveThink(state), CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
const next = { ...calculated, answer: event.replace ? event.text : `${state.answer}${event.text}` };
return syncWriteRows(next, false);
}
if (event.type === "run.completed" || event.type === "run.failed") {
@@ -209,7 +207,7 @@ export function consultationTimelineFromSettled(input: {
for (const section of sections) {
events.push({ type: "thinking.section", ...section });
}
if (input.thinkingText?.trim() && sections.length === 0) {
if (input.thinkingText?.trim()) {
events.push({ type: "thinking.delta", text: input.thinkingText });
}
if (text) events.push({ type: "answer.delta", text });
@@ -228,14 +226,7 @@ export function consultationTimelineFromSettled(input: {
workflow: { route: "settled", status: "ready", preciseTiming: "blocked", missingLayers: [] },
},
});
let state = reduceConsultationTimelineEvents(events);
if (input.thinkingText?.trim() && sections.length > 0) {
const lastThink = [...state.rows].reverse().find((row) => row.kind === "think");
if (lastThink) {
state = upsertRow(state, { ...lastThink, thinkingText: input.thinkingText.slice(0, 4_000) });
}
}
return state.rows;
return reduceConsultationTimelineEvents(events).rows;
}
export function consultationThinkTitle(title: string): string {
@@ -390,13 +381,25 @@ function syncWriteRows(state: ConsultationTimelineState, settled: boolean): Cons
return next;
}
function enrichCalculate(
function addCalculateHints(
state: ConsultationTimelineState,
section: PublicThinkingSection,
): ConsultationTimelineState {
const hints = state.calculateHints;
return {
...state,
calculateHints: {
queries: uniqueLabels([...(hints?.queries ?? []), ...queriesFromSection(section)]).slice(0, 8),
sources: uniqueLabels([...(hints?.sources ?? []), ...sourcesFromSection(section)]).slice(0, 8),
},
};
}
function applyCalculateHints(state: ConsultationTimelineState): ConsultationTimelineState {
const calculate = state.rows.find((row) => row.id === CALCULATE_ID);
if (!calculate) return state;
const queries = uniqueLabels([...(calculate.queries ?? []), ...queriesFromSection(section)]).slice(0, 8);
const sources = uniqueLabels([...(calculate.sources ?? []), ...sourcesFromSection(section)]).slice(0, 8);
const hints = state.calculateHints;
if (!calculate || !hints) return state;
const queries = uniqueLabels([...(calculate.queries ?? []), ...hints.queries]).slice(0, 8);
const sources = uniqueLabels([...(calculate.sources ?? []), ...hints.sources]).slice(0, 8);
return upsertRow(state, { ...calculate, queries, sources });
}
@@ -28,7 +28,7 @@ export const REPORT_HEADING = {
* answer-20261001 D2/D7/D8): every English prompt that names the shape quotes
* this, and the Chinese user-turn instruction below says the same thing.
*/
export const NATAL_ANSWER_SHAPE_SUMMARY = `On the first natal answer of a session: a heading-free opener of one to three plain sentences whose first sentence is the conclusion, with no action list in the opener; then one H2 section per person or sub-question the user named, titled with the user's own words (## 你和妈妈, ## 你和爸爸, ## 他们怎么对你), each in plain words with its chart evidence in parentheses; then ## ${REPORT_HEADING.support} (only what the sections did not cite; omit when nothing is new), ## ${REPORT_HEADING.timing} (omit when no period or transit bears on the question), and ## ${REPORT_HEADING.action} (one to three actions, once). A question about one person or one thing is not split. There is no length cap: write until it is clear and say each thing once. Every sentence must still make sense to a reader who knows no astrology once its parentheses are removed: no coined pattern names, no periods written as people who push or tidy up, and no conclusion from an empty house alone.`;
export const NATAL_ANSWER_SHAPE_SUMMARY = `On the first natal answer of a session: a heading-free opener of one to three plain sentences whose first sentence is the conclusion, with no action list in the opener; then one H2 section per person or sub-question the user named, titled with the user's own words (## 你和妈妈, ## 你和爸爸, ## 他们怎么对你), each in plain words with its chart evidence in parentheses; then ## ${REPORT_HEADING.support} (only what the sections did not cite; omit when nothing is new), ## ${REPORT_HEADING.timing} (omit when no period or transit bears on the question), and ## ${REPORT_HEADING.action} (one to three actions, once). A question about one person or one thing is not split. There is no length cap: write until it is clear and say each thing once. Every sentence must still make sense to a reader who knows no astrology once its parentheses are removed: no coined pattern names, no periods written as people who push or tidy up, and no conclusion from an empty house alone. Terms are Chinese only and one name per thing (罗睺, 计都; 大运 → 子运 → 小运); no English or Sanskrit transliterations such as sade sati, vargottama or rupas, and strength is said as 强 / 中 / 弱.`;
/** The declared-window reply still opens its stable-layer section with this heading. */
export const WINDOW_OPEN_HEADING = "先回答你的问题";
@@ -384,7 +384,7 @@ export function natalAnswerShapeBody(): string {
"每段先用人话讲清楚这个人是什么样、你们怎么相处、事情会怎么走,再在括号里给撑住这段判断的盘上依据。",
`然后依次写:## ${REPORT_HEADING.support}(只写上面没引过、判断又需要的依据,表格与正文二选一;没有新依据就省掉这一节)、## ${REPORT_HEADING.timing}(卡里没有相关大运或行运就省掉)、## ${REPORT_HEADING.action}(一到三条行动,只在这里出现一次)。`,
"不限字数,讲清楚为准;同一件事只说一遍。",
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。",
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。名词只用中文、全篇一个叫法(罗睺、计都;大运、子运、小运),不写英文或梵文音译(sade sati、vargottama、rupas 之类),力量只说强、中、弱。",
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
}
@@ -404,7 +404,7 @@ export function natalAnswerShapeInstruction(): string {
export function followUpAnswerShapeInstruction(): string {
return [
ANSWER_FROM_EVIDENCE,
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。",
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。",
`只有用户明确换了领域或要求完整看一下,才回到完整形状:口语开场、按对象分段,再接二级标题(## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action})。`,
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
+52 -52
View File
@@ -21,72 +21,72 @@ export type LunarPhase = "new" | "waxing" | "full" | "waning";
export const HOUSE_LINES: Readonly<Record<number, Line3>> = {
1: [
"今天注意力更多回到自己身上,精神和身体的状态都更容易被察觉。",
"今天适合照顾自己的节奏,先把状态调顺,再去应付别的事。",
"今天你的想法和情绪都更直接,别人也更容易注意到你。",
"今天月亮走到你盘上管自己的位置,身体和心情都更敏感,先把自己照顾好。",
"月亮今天回到管你自己的那一块,想做的事今天开头最顺手。",
"今天月亮落在管你自己的位置,情绪会直接写在脸上,累了就说累。",
],
2: [
"今天钱和说话这两件事更容易被放大,花出去的和说出口的都值得多想一下。",
"今天适合盘一盘手头的资源,也适合把想说的话说得更稳一点。",
"今天对价格、收入这些具体数字更敏感,适合算清楚再决定。",
"今天月亮走到你盘上管钱的位置,买东西、谈价钱前多算一遍,今天容易花多。",
"月亮今天在管钱和说话的那一块,想说的话先在心里过一遍,再开口。",
"今天月亮落在管收入的位置,适合把这个月的账理一理,心里会踏实很多。",
],
3: [
"今天行动力不错,适合跑腿、联络,把拖着的小事一件件办掉。",
"今天消息往来多,短途出行和身边的兄弟朋友都可能找上你。",
"今天适合主动一点,写、说、发,都比等别人来找更有效。",
"今天月亮走到你盘上管沟通和跑腿的位置,拖着的消息和小事,今天最好办。",
"月亮今天在管联络的那一块,想找谁就主动找,别等对方先开口。",
"今天月亮落在管出门办事的位置,短途走一趟、见个人,都比待着有收获。",
],
4: [
"今天心思更容易回到家里,情绪起伏也更容易被家人察觉。",
"今天适合把住处或自己的情绪收拾一下,里面安定了,外面的事才顺。",
"今天更需要一个让自己安心的角落,别把所有事都排得太满。",
"今天月亮走到你盘上管家的位置,心思会往家里跑,给家人打个电话会很安心。",
"月亮今天在管家和情绪的那一块,把住的地方收拾一下,人也会跟着静下来。",
"今天月亮落在管安全感的位置,别把日程排太满,给自己留个角落。",
],
5: [
"今天适合做点让自己开心的事,创作、玩乐或和喜欢的人相处都更顺。",
"今天表达欲更强,适合把一个想法做成小作品拿出来。",
"今天和孩子、恋人或兴趣有关的事,更容易带来好心情。",
"今天月亮走到你盘上管开心和恋爱的位置,做点纯粹喜欢的事,不用有用。",
"月亮今天在管表达的那一块,有个想法就写下来、画出来,今天手感好。",
"今天月亮落在管心动和孩子的位置,和喜欢的人待一会儿,心情会亮起来。",
],
6: [
"今天适合处理日常杂事和待办清单,按部就班反而最省力。",
"今天身体和作息的小信号更明显,适合把一个习惯调整回来。",
"今天工作里的琐碎事会多一些,一件件处理掉就好。",
"今天月亮走到你盘上管日常琐事的位置,按清单一件件来,反而最省力。",
"月亮今天在管身体小信号的那一块,哪里不舒服别硬扛,早点休息。",
"今天月亮落在管工作杂事的位置,事会多一点,先做完最烦的那件。",
],
7: [
"今天身边的人更重要,合作和相处里的细节更容易被放大。",
"今天适合和伴侣或合作方把事情对齐,一起商量比自己硬扛好。",
"今天别人的态度会影响你的心情,先听清对方想要什么。",
"今天月亮走到你盘上管伴侣和合作的位置,有话想跟对方说,今天开口比憋着好。",
"月亮今天在管身边人的那一块,别人的态度会影响你,先听完再回应。",
"今天月亮落在管合作的位置,两个人商量着来,比一个人硬扛轻松。",
],
8: [
"今天情绪可能更深一点,适合面对一件一直不愿碰的事。",
"今天容易想得多,把担心写下来,比在脑子里打转轻松。",
"今天适合处理共同的钱物或旧账,慢一点、细一点。",
"今天月亮走到你盘上管心里压力的位置,想得会多一点,写下来比憋着好受。",
"月亮今天在管共同钱物的那一块,和别人有关的账,今天适合慢慢对清楚。",
"今天月亮落在管深处情绪的位置,适合面对一件一直不想碰的小事。",
],
9: [
"今天适合学点新东西,读一篇长文或听一次课都会有收获。",
"今天视野更容易打开,远方的人和事可能带来新想法。",
"今天适合想想更长远的方向,别只盯着眼前这一件。",
"今天月亮走到你盘上管学习和远方的位置,读点东西、听一堂课,都会有收获。",
"月亮今天在管长远打算的那一块,适合想想半年后的自己,别只盯着眼前。",
"今天月亮落在管老师和长辈的位置,拿不准的事,去问问比你有经验的人。",
],
10: [
"今天工作和对外的事更显眼,你的表现更容易被人看到。",
"今天适合把一项成果推进一步,让该知道的人知道。",
"今天责任感会重一点,先做最要紧的那件,别被杂事拉走。",
"今天月亮走到你盘上管工作的位置,你做的事更容易被看见,把成果拿出来。",
"月亮今天在管事业的那一块,先做最要紧的那件,别被杂事拉走。",
"今天月亮落在管对外形象的位置,说话做事稍微正式一点,会给你加分。",
],
11: [
"今天朋友和圈子更活跃,一次交流可能带来有用的消息。",
"今天适合联系老朋友或参加一次聚会,关系会带来机会。",
"今天心里的愿望更容易说出口,也更容易得到别人的回应。",
"今天月亮走到你盘上管朋友的位置,和朋友聊一聊,会听到有用的消息。",
"月亮今天在管圈子和心愿的那一块,想要什么就说出来,会有人接住。",
"今天月亮落在管人脉的位置,联系一个很久没聊的人,正是时候。",
],
12: [
"今天更需要休息和独处,把节奏放慢不是偷懒。",
"今天适合收尾和整理,不急着开新头。",
"今天花钱和精力都容易悄悄流走,留意一下去向。",
"今天月亮走到你盘上管休息的位置,精力偏低,重要的事放明天,今晚早点收工。",
"月亮今天在管独处的那一块,想一个人待着很正常,不用解释。",
"今天月亮落在管花销的位置,钱和精力都容易悄悄流走,留意一下去向。",
],
};
export const PHASE_TAILS: Readonly<Record<LunarPhase, Line2>> = {
new: ["新月前后,适合定一个小开头。", "月亮刚要重新变圆,先想清楚再动手。"],
waxing: ["月亮渐盈,适合往前推一步。", "月亮渐盈,做加法比做减法顺。"],
full: ["满月前后,情绪容易满,先稳住再回应。", "满月前后,适合把结果摆出来看看。"],
waning: ["月亮渐亏,适合收尾和清理。", "月亮渐亏,做减法比开新头顺。"],
new: ["这两天是新月,适合开个小头。", "这两天是新月,先想清楚再动手。"],
waxing: ["月亮正在变圆,往前推一步会顺。", "月亮正在变圆,做加法比做减法顺。"],
full: ["这两天是满月,情绪容易满,先缓一缓再回话。", "这两天是满月,适合把结果摆出来看看。"],
waning: ["月亮正在变缺,适合收尾和清理。", "月亮正在变缺,做减法比开新头顺。"],
};
export const HOUSE_ACTIONS: Readonly<Record<number, Line2>> = {
@@ -126,18 +126,18 @@ const SIGN_ZH: Readonly<Record<string, string>> = {
/** Used when the house cannot be counted (birth time not confirmed, or no ascendant). */
export const SIGN_MOODS: Readonly<Record<string, string>> = {
Aries: "整体节奏偏快,适合先动起来。",
Taurus: "整体节奏偏稳,适合把手上的事做扎实。",
Gemini: "消息和想法都多,适合沟通和学习。",
Cancer: "情绪更柔软,适合照顾家和身边的人。",
Leo: "表现欲更强,适合把自己的想法拿出来。",
Virgo: "适合整理细节,把清单一件件勾掉。",
Libra: "更在意关系和分寸,适合商量和协调。",
Scorpio: "感受更深,适合专注处理一件要紧事。",
Sagittarius: "心情更开阔,适合学习或出门走走。",
Capricorn: "更务实,适合推进需要耐心的事。",
Aquarius: "更想跳出常规,适合换个角度想问题。",
Pisces: "更感性也更容易累,适合放慢一点。",
Aries: "做事会急一点,想到就先做一小步,别一口气全揽下。",
Taurus: "人会想要安稳,把手上的事做扎实,比开新事舒服。",
Gemini: "消息和想法都多,适合聊天、学东西,重要的事记下来。",
Cancer: "心会软一点,照顾一下家人,也照顾一下自己。",
Leo: "想被看见很正常,把你做好的东西拿出来给人看。",
Virgo: "适合整理细节,列个清单一件件勾掉,心里会清爽。",
Libra: "会更在意别人怎么想,有分歧时先商量,不急着定。",
Scorpio: "感受会深一点,专心做一件要紧事,别分心太多处。",
Sagittarius: "心情会开阔,出门走走或学点新东西,都很合适。",
Capricorn: "人会务实一点,适合推进需要耐心的事,一步一步来就好。",
Aquarius: "想跳出常规,换个角度看老问题,会有新办法。",
Pisces: "更感性也更容易累,今天对自己宽松一点。",
};
const SIGN_ACTIONS: Line2 = ["把今天要做的事收成一件,做完再看下一件。", "给自己留一段安静的时间,想清楚再动。"];
@@ -62,3 +62,19 @@ export function orderProbesBySegment(input: {
return input.probes.map((probe, index) => ({ probe, index, gain: segmentInformationGain(probe, weights, input.segments, input.offsets) }))
.sort((a, b) => b.gain - a.gain || a.index - b.index).map((row) => row.probe);
}
/**
* Production default for segment-gain probe order (BUG-1144). 77-case persisted replay
* (2026-10-01, after the health-focus write fix BUG-1143): at 21 and 61 minutes it gained
* D9/D10 head hits with every truth segment kept (76/76). Before that fix the 61-minute cell lost
* truth segments, which is why the first cut stopped at 21. Wider windows were not replayed.
* "off" disables; "on" is the research envelope used by the persisted replay harness.
*/
export const SEGMENT_ORDER_DEFAULT_MAX_WINDOW = 61;
export const SEGMENT_ORDER_RESEARCH_MAX_WINDOW = 61;
export function segmentOrderEnabledFor(windowMinutes: number | null | undefined, flag: string | undefined): boolean {
if (flag === "off" || !windowMinutes || windowMinutes <= 0) return false;
const limit = flag === "on" ? SEGMENT_ORDER_RESEARCH_MAX_WINDOW : SEGMENT_ORDER_DEFAULT_MAX_WINDOW;
return windowMinutes <= limit;
}
@@ -185,6 +185,12 @@ export function rangeDeliveryCollectClosedSubject(candidateCount?: number): stri
return null;
}
/** BUG-1146: the adopt message that carries the first post-adopt check (VOICE 采用后一行). */
export function postAdoptCheckLead(savedTime: string | null | undefined): string {
const clock = /^\d{2}:\d{2}/.exec(savedTime?.trim() ?? "")?.[0];
return clock ? `已采用 ${clock}。再核对一件过去的事。` : "再核对一件过去的事。";
}
export function rangeDeliveryCollectClosed(candidateCount?: number): string {
const subject = rangeDeliveryCollectClosedSubject(candidateCount);
return subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。";
@@ -582,7 +588,8 @@ export function deliveryTurnNarration(input: RangeNarrationInput = {}): string {
const sentence3 = deliveryRangeUnnarrowed(input.openingRange, input.credibleRange)
? RECTIFICATION_USER_COPY.rangeDeliveryNotNarrowed
: "";
return `${sentence1}${sentence2}${sentence3}${REPRESENTATIVE_MINUTE_DISCLAIMER}`;
// BUG-1147: the segment delivery card prints the same boundary line; say it once.
return `${sentence1}${sentence2}${sentence3}${input.segmentSummary ? "" : REPRESENTATIVE_MINUTE_DISCLAIMER}`;
}
function deliveryRangeUnnarrowed(
@@ -6,6 +6,7 @@
* returns a fallback narration in one receipt.
*/
import { createHash } from "node:crypto";
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { productDeliveryForDomain } from "./product-delivery.ts";
import { nextProbe } from "../core/build-state";
@@ -26,6 +27,7 @@ import {
postAdoptVerifyDoneCopy,
RECTIFICATION_USER_COPY,
withLastSuccessfulCompareNotice,
postAdoptCheckLead,
} from "../user-copy.ts";
import {
isTargetedCollectFollowup,
@@ -187,7 +189,7 @@ function exhaustionCarrierNarration(range: string | null | undefined, gate: stri
}
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
return `${askedTurnId ?? caseId}:gate`;
return deterministicTurnRequestId(`gate:${askedTurnId ?? caseId}`);
}
export async function persistExhaustionGateTurn(input: {
@@ -343,6 +345,9 @@ function adoptHostNarration(input: {
productDelivery: productDeliveryForDomain(input.dossier.case.rectificationDomain),
openingRange: openingRangeFromDossier(input.dossier),
});
// BUG-1147: the segment card already says which chart is credible and which cannot be told
// apart; the minute-candidate hint 「这几个候选按现有信息分不开」 contradicts it.
if (input.dossier.case.rectificationDomain !== null && inference?.segment_summary) return delivered;
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const hint = rangeNarrowHint(
catalog.remainingLayers,
@@ -932,6 +937,8 @@ export async function applyRectificationChoice(
credibleBefore: previous.credible_range,
credibleAfter: applied.state.credible_range,
excludedRanges: excludedClusterRanges(previous.candidates, applied.state.candidates),
segmentProduct: dossier.case.rectificationDomain !== null
&& Boolean(applied.state.segment_summary ?? previous.segment_summary),
});
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
@@ -1615,6 +1622,62 @@ export async function persistCollectDenialTurn(input: {
return { streamText, turnId: turn.turnId };
}
/**
* `append_agentic_rectification_turn` keys idempotency on a uuid `p_request_id`; a suffixed
* id (「<uuid>:adopt」「<uuid>:gate」) is rejected by PostgreSQL (BUG-1149). Same seed → same uuid,
* so a retried request still lands on the same turn.
*/
export function adoptTurnRequestId(requestId: string): string {
return deterministicTurnRequestId(`adopt:${requestId}`);
}
export function deterministicTurnRequestId(seed: string): string {
const hex = createHash("sha256").update(`rectification-turn:${seed}`).digest("hex");
const variant = ((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
/**
* BUG-1146: after 采用 the first post-adopt check is persisted by persistNextInterviewIfIdle, but the
* accept route wrote no turn. The read_only agent call that follows short-circuits as
* already_delivered within a minute of delivery, so the check had no message of its own and the
* client hung its stem under the previous answer's 「已记录,…」 narration. Write the adopt turn
* here, hang the check on it, and mark the delivery so the follow-up read_only call stays quiet.
*/
export async function writeAdoptFollowupTurn(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
resultId: string | null;
hostNarration: string | null;
focus: ConversationFocus | null;
savedTime?: string | null;
}): Promise<{ turnId: string } | null> {
const narration = input.hostNarration?.trim();
if (!narration) return null;
const focus = input.focus;
const check = Boolean(focus && !focus.askedTurnId
&& (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"));
// The stem leaves the body once the card attaches; the lead is what the message says.
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: adoptTurnRequestId(input.requestId),
userMessage: null,
assistantMessage: check ? `${postAdoptCheckLead(input.savedTime)}${narration}` : narration,
});
if (focus && check) {
await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus,
askedTurnId: turn.turnId,
});
}
if (input.resultId) markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
return { turnId: turn.turnId };
}
export function isStalePreAdoptFocus(
acceptedTime: string | null | undefined,
focus: { intent?: string | null } | null | undefined,
@@ -107,6 +107,8 @@ export function composeChoiceNarration(input: {
credibleAfter?: readonly [string, string] | null;
/** Clusters that left the delivered range with this answer (BUG-1086). */
excludedRanges?: readonly (readonly [string, string])[];
/** Segment (盘型) product: the minute leaderboard is not this product's language (BUG-1147). */
segmentProduct?: boolean;
}): string {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
@@ -129,7 +131,7 @@ export function composeChoiceNarration(input: {
// (or the range split into segments). That is a change, never 「范围没变」.
const excluded = explainExcludedRanges(input.excludedRanges ?? []);
if (excluded) return `已记录,${excluded}。`;
const movement = explainScoreMovement(input.deltasByCluster ?? []);
const movement = input.segmentProduct ? "" : explainScoreMovement(input.deltasByCluster ?? []);
if (movement) return `已记录,范围没变;${movement}。`;
return "已记录,范围没变。";
}
@@ -821,14 +821,14 @@ function liveDistinguishProbe(
isValidDistinguishProbe({ ...probe, role: "distinguish" })
&& (schemaKey
? probe.semantic_key === schemaKey
: (!focus.targetDomain || probe.domain === focus.targetDomain))
: (!focus.targetDomain || sameCollectDomain(probe.domain, focus.targetDomain)))
);
const fromEvents = (eventProbes ?? []).find(matchEvent) ?? null;
if (fromEvents) return fromEvents;
const matchContrast = (probe: CandidateDiscriminatorProbe) => (
schemaKey
? probe.semanticKey === schemaKey
: (!focus.targetDomain || probe.domain === focus.targetDomain)
: (!focus.targetDomain || sameCollectDomain(probe.domain, focus.targetDomain))
);
const fromContrast = (contrastProbes ?? []).find(matchContrast);
if (!fromContrast) return null;
@@ -2580,7 +2580,7 @@ export function buildMethodFollowupPlan(input: {
: themeFromDomain ?? (focus.intent === "out_of_sample_check" ? "oos_blind" : "education_style")
) as MethodFollowup["ask_theme"];
const matchingVerify = (input.eventProbes ?? []).find((probe) => (
probe.domain === focus.targetDomain
sameCollectDomain(probe.domain, focus.targetDomain)
|| REVERSE_VERIFY_THEME[probe.domain as keyof typeof REVERSE_VERIFY_THEME] === keepAskTheme
));
const schema = focus.expectedAnswerSchema;
@@ -2588,9 +2588,18 @@ export function buildMethodFollowupPlan(input: {
? schema.probe_year
: undefined;
const keepProbeYear = matchingVerify?.year || schemaYear;
// BUG-1146: the persisted focus carries the probe identity; the rebuilt followup must
// too, or its frame id ("reverse_verify:<theme>:score") never equals the stored
// question id ("probe:<key>") and the GET card comes back null after 采用.
const keepSemanticKey = persistedFocusProbeKey(focus);
const keepSplit = typeof schema?.candidate_split_hash === "string" && schema.candidate_split_hash.trim()
? schema.candidate_split_hash.trim()
: "";
return {
method_id: keepMethodId,
intent: focus.intent || "reverse_verify",
...(keepSemanticKey ? { semantic_key: keepSemanticKey } : {}),
...(keepSplit ? { candidate_split_hash: keepSplit } : {}),
ask_theme: keepAskTheme,
domain: focus.targetDomain,
kind_hint: collectKindFromFocus(focus),
@@ -5,6 +5,7 @@
* write a fresh inference_state, but with an empty answer ledger.
*/
import { shouldForceMinuteAfterSubBlocks } from "./block-scan.ts";
import { segmentOrderEnabledFor } from "../core/segment-probe-order.ts";
import { scanCaseSegments } from "./segment-scan.ts";
import { targetChartsForDomain } from "../core/segment-summary.ts";
import { buildProductCaseInferenceState } from "./product-inference.ts";
@@ -450,7 +451,7 @@ export async function scoreAndPersistCurrentEvidence(input: {
: undefined;
const inference = buildProductCaseInferenceState({
segmentMinutes, segmentTargets, segmentScanComplete: Boolean(segmentMinutes),
segmentOrderEnabled: process.env.RECTIFICATION_SEGMENT_ORDER === "on",
segmentOrderEnabled: segmentOrderEnabledFor(segmentMinutes?.length, process.env.RECTIFICATION_SEGMENT_ORDER),
range: candidateRange,
candidates: score.candidates,
evidence: scorable,
@@ -149,6 +149,14 @@ export function expectedAnswerSchemaFor(
if (typeof followup.probe_year === "number" && followup.probe_year > 0) {
stamped.probe_year = followup.probe_year;
}
// BUG-1150: a scoring post-adopt check names its own probe. When the state holds that probe
// (same semantic key and split), carry the state's id — split-hashed ids are
// 「probe:<key>:<split>」 and the transition RPC rejects the bare 「probe:<key>」 as stale.
if (verifyOnly && state && followup.semantic_key) {
const own = state.probes.find((probe) => probe.semantic_key === followup.semantic_key
&& (!followup.candidate_split_hash || probe.candidate_split_hash === followup.candidate_split_hash));
if (own) stamped.probe_id = own.id;
}
if (verifyOnly || targetedCollect) return stamped;
return state && stamped.scoring !== false && !schemaProbeId(stamped) ? null : stamped;
}
@@ -272,6 +280,20 @@ export function persistableFocusDomain(domain: string | null | undefined): strin
return null;
}
/**
* The domain a focus row may store. Every intent goes through persistableFocusDomain:
* the table only accepts education/career/relationship/relocation/finance/health/family/other,
* while planner domains include health_pressure and occupation (BUG-672). A raw
* health_pressure on a distinguish focus failed the check constraint, the write was
* swallowed and the session ended early (BUG-1143).
*/
export function focusTargetDomain(followup: Pick<MethodFollowup, "intent" | "domain">): string | null {
return persistableFocusDomain(followup.domain)
?? (followup.intent === "collect_method_evidence"
? persistableFocusDomain(collectQuestionDomain(followup.domain))
: null);
}
function clampFocusTargetKind(kind: string | null | undefined): string | null {
const value = kind?.trim() || "";
if (!value) return null;
@@ -672,10 +694,7 @@ async function persistServerOwnedFocusCore(input: {
questionId,
intent: followup.intent,
targetEvidenceId: followup.date_reliability_evidence_id ?? null,
targetDomain: followup.intent === "collect_method_evidence"
? persistableFocusDomain(followup.domain)
?? persistableFocusDomain(collectQuestionDomain(followup.domain))
: followup.domain,
targetDomain: focusTargetDomain(followup),
targetKind: followup.intent === "collect_method_evidence"
? collectFocusTargetKind(followup)
: null,
@@ -24,6 +24,8 @@ import type {
RectificationTurnContinuation,
} from "@/lib/rectification-chat-turn-run";
import type { RenderMessage } from "@/components/rectification-message-entry";
import { messagesFromTurns, type PersistedTurn } from "@/lib/rectification-chat-messages";
import { applySnapshotTurnsToMessages } from "@/lib/rectification-snapshot-messages";
export type RectificationCandidateAcceptDeps = Readonly<{
caseId: string;
@@ -155,6 +157,21 @@ export async function runRectificationCandidateAccept(
setSavedStatus("accepted");
onSaved?.(payload.saved_time, "accepted");
onCompleted?.();
if (typeof payload.adopt_turn_id === "string" && payload.adopt_turn_id) {
// BUG-1146: the server wrote the adopt message with the first post-adopt check hung on it;
// show that turn in place of the live row. The read_only run would only be
// already_delivered and leave the check under the previous answer.
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current.filter((message) => message.renderKey !== assistantRenderKey),
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
conversationAnchor.pinLatestTurn();
return;
}
await loadCaseSnapshot();
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
} catch (caught) {
+13 -10
View File
@@ -1,7 +1,9 @@
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
/**
* One line, two to five words, no follow-up question.
* One line, two to five words, no follow-up question. Since 2026-10-01 the voice
* is a warm older sister's (知心姐姐): it greets and cares, never comments on the
* user (no 夜猫子 / 还没睡呀).
*
* The homepage opening used to be a salutation plus a question ("这么晚还醒着,
* {名字}。直接说说最在意的问题吧。") — an office voice that answered for the user
@@ -11,26 +13,27 @@ type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night
const NAME_TOKEN = "{名字}";
const periodGreetings: Record<GreetingPeriod, readonly string[]> = {
morning: ["早上好", "早上好,{名字}", "早呀,{名字}"],
noon: ["中午好", "中午好,{名字}"],
afternoon: ["下午好", "下午好,{名字}"],
evening: ["晚上好", "晚上好,{名字}", "今天过得怎么样?"],
"late-night": ["夜猫子,晚上好", "还没睡呀,{名字}", "晚安前再聊一会"],
morning: ["早安", "早安,{名字}", "早呀,今天慢慢来"],
noon: ["中午好,记得吃饭", "午安,{名字}"],
afternoon: ["下午好,歇口气吧", "下午好,{名字}"],
evening: ["今天辛苦了", "晚上好,{名字}", "晚上好,歇一歇"],
// Late night never remarks on being awake (产品 2026-10-01: 「你管别人睡不睡」).
"late-night": ["这会儿很安静", "夜里好,{名字}", "慢慢说,{名字}"],
};
/** Keyed by `Date#getDay()`. Days with nothing worth saying stay out of the pool. */
const weekdayGreetings: Readonly<Record<number, readonly string[]>> = {
0: ["周日,慢一点"],
1: ["周一了,{名字}"],
5: ["周五快乐"],
6: ["周末愉快,{名字}"],
1: ["周一了,别太累"],
5: ["周五了,松口气"],
6: ["周末好好歇歇"],
};
/** Only reachable once the account has at least one session behind it. */
const returningGreetings: readonly string[] = [
"又见面了,{名字}",
"你来啦,{名字}",
"欢迎回来",
"嗨,{名字}",
"最近还好吗,{名字}?",
];
+1
View File
@@ -33,6 +33,7 @@ ANSWER SHAPE (natal / general / declared-window 三种模式共用)
- 把星名当形容词的主人(「土星的冷」「月亮的照看」);
- 括号外每段最多一个占星术语,第一次出现时用白话套住(例:「现在这十年(月亮大运)」「4 宫(母亲宫)」)。
推断边界:空宫不单独下结论——要说这个宫,就说它的宫主落在哪、什么状态。只用卡上和补取到的事实。
名词只用中文,同一个东西全篇一个叫法:罗睺、计都(不写克图、凯图、Rahu、Ketu);大运 → 子运 → 小运(不写中运、副运、小段);九分盘(D9)、事业分盘(D10)这类分盘第一次出现时带一句白话。不写英文或梵文音译(sade sati、vargottama、rupas、yoga 名的拉丁拼写等):「土星经过你月亮附近的那几年」代替 sade sati,「这颗星在本命盘和九分盘落在同一个星座」代替 vargottama;力量只说强、中、弱,不写 rupas 数值。
降级路线(形状照给,只换依据):
- 申报时段(declared birth window):依据只取窗口内稳定的那一层——行星落座、公开日历;上升、宫位、大运、分盘不作依据,也不写月份。
- 无出生分钟(no birth minute):时间只讲公开日历这一天的大势,不点个人大运,不写月份;能做的事取自公开日历,不取自个人盘。
@@ -9,7 +9,7 @@ import {
reduceConsultationTimelineEvents,
} from "../src/lib/consultation-run-timeline.ts";
test("career and wealth events append method, calculate 1/2, think, then the first write", () => {
test("career and wealth events append method, calculate 1/2, then the first write (no outline row)", () => {
const [question] = natalConsultationThinkingPlan({ domains: ["career", "wealth"] });
assert.ok(question);
const afterProgress = reduceConsultationTimelineEvents([
@@ -39,13 +39,15 @@ test("career and wealth events append method, calculate 1/2, think, then the fir
{ type: "answer.delta", text: "外松内紧。\n## 盘里支持这个判断的地方\n事业宫被土星压着。\n" },
], afterProgress);
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "think", "write"]);
// 原值: 行种类 ["method", "calculate", "think", "write"],第 3 行是计划节「先回答你问的这件事」且已勾
// 新值: 行种类 ["method", "calculate", "write"],计划节不成行;计算行由正文开始收口
// 原因: TASK-consult-timeline-no-plan-20261001(BUG-1145):回答提纲不是已走过的步骤,产品 10-01 选 B 去掉提纲行
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "write"]);
assert.equal(state.rows[1]?.status, "done");
assert.equal(state.rows[2]?.label, "先回答你问的这件事");
assert.equal(state.rows[2]?.status, "done");
assert.equal(state.rows[3]?.id, "write-盘里支持这个判断的地方");
assert.equal(state.rows[3]?.status, "live");
assert.match(state.rows[3]?.label ?? "", /正在写盘里支持这个判断的地方/);
assert.equal(state.rows.some((row) => row.label === "先回答你问的这件事"), false);
assert.equal(state.rows[2]?.id, "write-盘里支持这个判断的地方");
assert.equal(state.rows[2]?.status, "live");
assert.match(state.rows[2]?.label ?? "", /正在写盘里支持这个判断的地方/);
});
test("thinking after a tool starts a new think row instead of reopening the first", () => {
@@ -107,3 +109,62 @@ test("settled natal messages hydrate a calculate row from domain sections", () =
const calculate = rows.find((row) => row.kind === "calculate");
assert.ok(calculate?.queries?.some((item) => item.includes("事业") || item.includes("D10") || item.includes("财富")));
});
// BUG-1145 (TASK-consult-timeline-no-plan-20261001): the answer outline was
// shown as four think rows and ticked done the moment the calculation started.
test("the answer outline sent before the calculation adds no row and ticks nothing", () => {
const plan = natalConsultationThinkingPlan({ domains: ["parents"] });
const state = reduceConsultationTimelineEvents([
{ type: "run.started", runId: "run-1", requestId: "req-1" },
{ type: "skill.started", name: "jyotish-vedic-astrology" },
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
{ type: "think.plan", steps: plan.map((section) => ({ id: section.id, title: section.title })) },
...plan.map((section) => ({ type: "thinking.section" as const, ...section })),
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算本命盘…" },
]);
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate"]);
assert.deepEqual(state.rows.filter((row) => row.status === "done").map((row) => row.kind), ["method"]);
assert.equal(state.rows[1]?.status, "live");
for (const section of plan) {
assert.equal(state.rows.some((row) => row.label === section.title), false, section.title);
}
// The outline still details what the calculation looks at.
assert.ok((state.rows[1]?.queries?.length ?? 0) + (state.rows[1]?.sources?.length ?? 0) > 0);
});
test("a finished run leaves no outline row and nothing live", () => {
const plan = natalConsultationThinkingPlan({ domains: ["parents"] });
const state = reduceConsultationTimelineEvents([
{ type: "skill.started", name: "jyotish-vedic-astrology" },
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
{ type: "think.plan", steps: plan.map((section) => ({ id: section.id, title: section.title })) },
...plan.map((section) => ({ type: "thinking.section" as const, ...section })),
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算本命盘…" },
{ type: "answer.delta", text: "你和父母不疏远。\n## 你和妈妈\n她操心多。\n## 这周可以做的一件事\n- 先问她最近怎样。\n" },
{
type: "run.completed",
receipt: {
runId: "run-1",
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
steps: [],
workflow: { route: "parents", status: "ready", preciseTiming: "blocked", missingLayers: [] },
},
},
]);
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "write", "write"]);
assert.equal(state.rows.every((row) => row.status === "done"), true);
assert.equal(state.rows.some((row) => row.kind === "think"), false);
});
test("a stored natal answer rebuilds the same rows as live: no outline rows", () => {
const plan = natalConsultationThinkingPlan({ domains: ["parents"] });
const rows = consultationTimelineFromSettled({
text: "你和父母不疏远。\n## 你和妈妈\n她操心多。\n## 你和爸爸\n他话少。\n",
thinkingSections: plan,
});
assert.deepEqual(rows.map((row) => row.kind), ["method", "calculate", "write", "write"]);
assert.equal(rows.some((row) => row.kind === "think"), false);
assert.equal(rows.every((row) => row.status === "done"), true);
assert.ok((rows[1]?.queries?.length ?? 0) + (rows[1]?.sources?.length ?? 0) > 0);
});
@@ -298,3 +298,14 @@ test("a follow-up turn answers directly and the thinking bar says so (TASK-consu
const settled = applyThinkingSectionProgress(plan, "不是。4 宫主落 10 宫,她把心力投在你的前途上。", { settled: true });
assert.equal(settled.every((section) => section.steps.every((step) => step.status === "done")), true);
});
test("answers name things in Chinese only, one name per thing (BUG-1148)", () => {
// 10-01 real-model run over ten domains: sade sati, vargottama, rupas and
// Ketu leaked into the body, and 计都 / 子运 had three spellings each.
for (const instruction of [natalAnswerShapeInstruction(), followUpAnswerShapeInstruction()]) {
assert.match(instruction, /名词只用中文/);
assert.match(instruction, /不写英文或梵文音译/);
}
assert.match(natalAnswerShapeInstruction(), /罗睺、计都/);
assert.match(natalAnswerShapeInstruction(), /大运、子运、小运/);
});
@@ -275,3 +275,11 @@ test("the voice answers the sentence asked: no motive guessing, yes/no first, co
// BUG-1134: the empty-house rule is also in the natal Never list.
assert.match(voice, /Draw a conclusion from an empty house alone\. Say where its lord sits and in what state instead/);
});
test("the voice pins Chinese-only terms and one spelling per thing (BUG-1148)", () => {
assert.match(voice, /名词只用中文,同一个东西全篇一个叫法/);
for (const term of ["克图", "凯图", "中运", "sade sati", "vargottama", "rupas"]) {
assert.ok(voice.includes(term), `the voice names ${term} as a spelling not to use`);
}
assert.match(voice, /力量只说强、中、弱/);
});
+33 -20
View File
@@ -30,7 +30,8 @@ test("terminal and fallback greetings follow the current profile name", () => {
assert.match(greeting, /周宁/);
assert.doesNotMatch(greeting, /林遥/);
}
assert.equal(terminalGreeting, "早上好,周宁");
// 原值 "早上好,周宁" / 新值 "早安,周宁" / 原因:开场语改知心姐姐口吻(产品 2026-10-01)。
assert.equal(terminalGreeting, "早安,周宁");
assert.equal(fallbackGreeting, "欢迎,周宁");
});
@@ -41,36 +42,46 @@ test("the starter greeting is one line drawn from daypart, weekday and returning
// 原因:产品把开场语改成 claude.ai 式的单行问候,追问句移到输入框占位符,
// 「招呼语 + 问句」两半这个形状本身被取消,问句池不再存在。
const noon = WEDNESDAY(12);
assert.equal(createStartGreetingParts("周宁", noon, 0).text, "中午好");
assert.equal(createStartGreetingParts("周宁", noon, 0.5).text, "中午好,周宁");
// 原值 "中午好" / "中午好,周宁" / 新值 "中午好,记得吃饭" / "午安,周宁"
// / 原因:开场语改知心姐姐口吻(产品 2026-10-01);下标与池长不变。
assert.equal(createStartGreetingParts("周宁", noon, 0).text, "中午好,记得吃饭");
assert.equal(createStartGreetingParts("周宁", noon, 0.5).text, "午安,周宁");
assert.equal(createStartGreeting("周宁", noon, 0.5), createStartGreetingParts("周宁", noon, 0.5).text);
// 边界不越界:1 与 0.999999 都落在池内最后一条。
assert.equal(createStartGreetingParts("周宁", noon, 1).text, "中午好,周宁");
assert.equal(createStartGreetingParts("周宁", noon, 0.999999).text, "中午好,周宁");
assert.equal(createStartGreetingParts("周宁", noon, 1).text, "午安,周宁");
assert.equal(createStartGreetingParts("周宁", noon, 0.999999).text, "午安,周宁");
});
test("late night has its own three lines and none of them asks a follow-up", () => {
test("late night has its own three lines and none of them remarks on being awake", () => {
// 原值 "夜猫子,晚上好" / "还没睡呀,周宁" / "晚安前再聊一会"
// / 新值 "这会儿很安静" / "夜里好,周宁" / "慢慢说,周宁"
// / 原因:产品 2026-10-01「夜猫子、还不睡,用户会觉得你管别人睡不睡」;断言加严:深夜池任何一条都不得提睡觉。
const lateNight = WEDNESDAY(2);
assert.equal(createStartGreetingParts("周宁", lateNight, 0).text, "夜猫子,晚上好");
assert.equal(createStartGreetingParts("周宁", lateNight, 1 / 3).text, "还没睡呀,周宁");
assert.equal(createStartGreetingParts("周宁", lateNight, 0.7).text, "晚安前再聊一会");
assert.equal(createStartGreetingParts("周宁", lateNight, 0).text, "这会儿很安静");
assert.equal(createStartGreetingParts("周宁", lateNight, 1 / 3).text, "夜里好,周宁");
assert.equal(createStartGreetingParts("周宁", lateNight, 0.7).text, "慢慢说,周宁");
// 23 点与 4 点同属深夜。
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(23), 1 / 3).text, "还没睡呀,周宁");
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(4), 1 / 3).text, "还没睡呀,周宁");
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(23), 1 / 3).text, "夜里好,周宁");
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(4), 1 / 3).text, "夜里好,周宁");
for (let step = 0; step < 100; step += 1) {
assert.doesNotMatch(createStartGreetingParts("周宁", lateNight, step / 100).text, /睡|夜猫子|晚安/);
}
});
test("a blank name leaves only the lines that carry no name", () => {
const lateNight = WEDNESDAY(2);
assert.equal(createStartGreetingParts("", lateNight, 0).text, "夜猫子,晚上好");
assert.equal(createStartGreetingParts(" ", lateNight, 0.9).text, "晚安前再聊一会");
// 原值 "夜猫子,晚上好" / "晚安前再聊一会" / 新值 "这会儿很安静"(深夜池唯一不带称呼的一条)
// / 原因:深夜池换句(产品 2026-10-01)。
assert.equal(createStartGreetingParts("", lateNight, 0).text, "这会儿很安静");
assert.equal(createStartGreetingParts(" ", lateNight, 0.9).text, "这会儿很安静");
// 周一的星期条目带称呼,名字为空时整条不可达;回访池同理。
// 原值:断言名字为空时不出现「周一了」/ 新值:只断言带称呼的回访条目不可达
// / 原因:周一条目改成不带称呼的「周一了,别太累」,名字为空也该出现;带称呼条目不可达的断言保留。
for (let step = 0; step < 100; step += 1) {
for (const hasSessions of [false, true]) {
const text = createStartGreetingParts("", MONDAY(9), step / 100, hasSessions).text;
assert.doesNotMatch(text, /周一了/);
assert.doesNotMatch(text, /又见面了|嗨,|最近还好吗/);
assert.doesNotMatch(text, /又见面了|你来啦|最近还好吗/);
assert.ok(text.length > 0);
assert.doesNotMatch(text, /\{名字\}/);
assert.doesNotMatch(text, /,$|^,/);
@@ -82,22 +93,24 @@ test("the weekday pool only opens on its own day", () => {
const friday = FRIDAY(9);
const fridayLines = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", friday, step / 100).text));
assert.ok(fridayLines.has("周五快乐"));
// 原值 "周五快乐" / "周一了,周宁" / 新值 "周五了,松口气" / "周一了,别太累" / 原因:知心姐姐口吻(产品 2026-10-01)。
assert.ok(fridayLines.has("周五了,松口气"));
const wednesdayLines = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", WEDNESDAY(9), step / 100).text));
assert.ok(!wednesdayLines.has("周五快乐"));
assert.ok(!wednesdayLines.has("周五了,松口气"));
assert.equal(wednesdayLines.size, 3);
assert.ok(new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", MONDAY(9), step / 100).text)).has("周一了,周宁"));
createStartGreetingParts("周宁", MONDAY(9), step / 100).text)).has("周一了,别太累"));
assert.ok(new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", SUNDAY(9), step / 100).text)).has("周日,慢一点"));
});
test("the returning pool needs at least one session behind the account", () => {
const evening = WEDNESDAY(20);
const returning = ["又见面了,周宁", "欢迎回来", "嗨,周宁", "最近还好吗,周宁?"];
// 原值 "嗨,周宁" / 新值 "你来啦,周宁" / 原因:知心姐姐口吻(产品 2026-10-01);池长不变。
const returning = ["又见面了,周宁", "你来啦,周宁", "欢迎回来", "最近还好吗,周宁?"];
const firstVisit = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", evening, step / 100, false).text));
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { sameCollectDomain } from "../src/lib/rectification-agentic/v9/domain-alias.ts";
import { focusTargetDomain } from "../src/lib/rectification-agentic/v9/server-focus.ts";
// BUG-1143: a distinguish focus wrote the planner domain verbatim. health_pressure is not in the
// focus table's target_domain check, the write failed inside a swallowed retry, and the session
// went straight to delivery although tap cards remained (BUG-672 alias family).
const migration = readFileSync(new URL("../supabase/migrations/20260814020000_rectification_v10_runtime.sql", import.meta.url), "utf8");
const allowedBlock = /target_domain is null or target_domain in \(([^)]*)\)/.exec(migration);
assert.ok(allowedBlock, "focus table target_domain check is readable");
const allowed = new Set([...allowedBlock[1]!.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]!));
// Every domain the planner, engine catalog or collect pool can put on a followup.
const plannerDomains = [
"education", "relocation", "relationship", "career", "finance", "health_pressure", "family",
"appearance", "occupation", "health", "other", "horary", "unknown", "active_focus",
];
test("every followup domain maps to a value the focus table accepts, for every intent", () => {
for (const intent of ["distinguish_candidates", "collect_method_evidence", "reverse_verify", "out_of_sample_check"] as const) {
for (const domain of plannerDomains) {
const stored = focusTargetDomain({ intent, domain } as never);
assert.ok(stored === null || allowed.has(stored), `${intent}/${domain} -> ${stored}`);
}
}
});
test("a health distinguish focus stores health and still matches health_pressure probes", () => {
assert.equal(focusTargetDomain({ intent: "distinguish_candidates", domain: "health_pressure" } as never), "health");
assert.equal(focusTargetDomain({ intent: "distinguish_candidates", domain: "occupation" } as never), "other");
assert.equal(focusTargetDomain({ intent: "distinguish_candidates", domain: "relationship" } as never), "relationship");
assert.equal(sameCollectDomain("health_pressure", "health"), true);
});
test("focus writes and focus-to-probe matching never use a raw planner domain", () => {
const serverFocus = readFileSync(new URL("../src/lib/rectification-agentic/v9/server-focus.ts", import.meta.url), "utf8");
assert.doesNotMatch(serverFocus, /:\s*followup\.domain,\s*\n/, "no raw followup.domain passed as targetDomain");
assert.match(serverFocus, /targetDomain: focusTargetDomain\(followup\)/);
const followup = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8");
assert.doesNotMatch(followup, /probe\.domain === focus\.targetDomain/);
});
@@ -0,0 +1,228 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { expectedAnswerSchemaFor, persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import { adoptTurnRequestId, exhaustionGateRequestId, inferenceForPersistedAnswer, writeAdoptFollowupTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeChoiceNarration } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { alreadyDelivered, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { deliveryTurnNarration, REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
import type { SegmentSummary } from "../src/lib/rectification-agentic/core/segment-summary.ts";
import { fakeAccounting } from "./rectification-v9-test-support.ts";
// Real device 2026-10-01 (BUG-1146): after 采用 the first post-adopt check persisted, but the
// case GET rebuilt no card, so the stem showed without buttons and refresh did not help. Fixtures
// that left activeFocus.questionId unset never reached the question-id comparison.
const EVIDENCE = [{
status: "confirmed", domain: "career", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null,
}, {
status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null,
eventKind: "education_start", summary: "fictional enrolment",
}];
const DATED_CHECK: DiscriminatingEventProbe = {
year: 2018, year_label: "2018 年前后", domain: "career", event_family: "入职、升职或职责明显加重",
source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。", role: "reverse_verify",
semantic_key: "career.2018.dasha_activation", candidate_split_hash: "career.2018.dasha_activation",
candidate_ids: ["05:00", "05:04"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:04"] },
{ answer_class: "no", supports: ["05:04"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
} as never;
const QUALITY_CHECK: DiscriminatingEventProbe = {
year: 2016, year_label: "2016 年 9 月", domain: "education", event_family: "学业或考试发挥失常、压力特别大",
source: "known_event_quality", role: "distinguish", choice_kind: "event_quality",
tracks: ["vimshottari"], tracks_agree: true, unique_minute_claim: false,
user_meaning: "2016 年 9 月那次升学,结果更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。",
semantic_key: "education.2016.09.known_event_quality", candidate_split_hash: "education.2016.09.known_event_quality",
target_evidence_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
candidate_ids: ["05:00", "05:04"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:04"] },
{ answer_class: "no", supports: ["05:04"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
} as never;
for (const [label, probe] of [["dated", DATED_CHECK], ["quality", QUALITY_CHECK]] as const) {
test(`post-adopt ${label} check: the card persisted after 采用 is rebuilt on GET`, async () => {
const input = {
evidence: EVIDENCE as never, accepted: true, sessionOutcome: "adopt_representative" as const,
selectionAllowed: true, proposeAllowed: true, eventProbes: [probe],
};
const plan = buildMethodFollowupPlan(input as never);
const followup = plan.next_followup;
assert.ok(followup, `the ${label} probe yields a post-adopt check`);
assert.equal(followup.intent, "reverse_verify");
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: "abababab-abab-4bab-8bab-abababababab", case_id: "x", question_id: args.p_question_id, intent: args.p_intent,
target_evidence_id: null, target_domain: args.p_target_domain, target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema, status: "active",
asked_at: "2026-10-01T00:00:00.000Z", resolved_at: null, idempotent: false,
}),
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client, userId: "11111111-1111-4111-8111-111111111111",
caseId: "22222222-2222-4222-8222-222222222222", activeFocus: null, decisionReceipt: null, followup,
});
assert.equal(persisted.status, "created");
assert.ok(persisted.focus?.questionId, "a real focus always carries a question id");
const card = projectRectificationChoiceCard({ ...input, activeFocus: persisted.focus } as never);
assert.ok(card, `refresh rebuilds the ${label} post-adopt card (question id ${persisted.focus!.questionId})`);
assert.equal(card!.options.length, 4);
});
}
const USER = "11111111-1111-4111-8111-111111111111";
const CASE = "22222222-2222-4222-8222-222222222222";
const RESULT = "33333333-3333-4333-8333-333333333333";
test("采用 writes its own turn, hangs the post-adopt check on it and quiets the read_only run", async () => {
resetDeliveryTurnGuardForTests();
const accounting = fakeAccounting({
append_agentic_rectification_turn: (_fn, args) => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false, args }),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: "abababab-abab-4bab-8bab-abababababab", case_id: CASE, question_id: args.p_question_id, intent: args.p_intent,
target_evidence_id: null, target_domain: args.p_target_domain, target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_turn_id: args.p_asked_turn_id,
asked_at: "2026-10-01T00:00:00.000Z", resolved_at: null, idempotent: true,
}),
});
const focus = {
id: "abababab-abab-4bab-8bab-abababababab", questionId: "probe:career.2018.dasha_activation", intent: "reverse_verify",
targetDomain: "career", targetKind: "event", targetEvidenceId: null, expectedAnswerSchema: { semantic_key: "career.2018.dasha_activation" },
status: "active", askedTurnId: null,
};
const out = await writeAdoptFollowupTurn({
accounting: accounting.client, userId: USER, caseId: CASE, requestId: "req-1", resultId: RESULT,
hostNarration: "2018 年前后,有没有入职或职责明显加重?", focus: focus as never, savedTime: "05:00:00",
});
assert.deepEqual(out, { turnId: "44444444-4444-4444-8444-444444444444" });
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_request_id, adoptTurnRequestId("req-1"));
assert.equal(turn?.args.p_user_message, null);
assert.equal(turn?.args.p_assistant_message, "已采用 05:00。再核对一件过去的事。2018 年前后,有没有入职或职责明显加重?");
const link = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.equal(link?.args.p_asked_turn_id, "44444444-4444-4444-8444-444444444444");
assert.equal(alreadyDelivered({ caseId: CASE, resultId: RESULT, hasUserMessage: false }), true);
resetDeliveryTurnGuardForTests();
});
test("采用 with nothing to say writes no turn", async () => {
const accounting = fakeAccounting({});
const out = await writeAdoptFollowupTurn({
accounting: accounting.client, userId: USER, caseId: CASE, requestId: "req-2", resultId: RESULT,
hostNarration: " ", focus: null,
});
assert.equal(out, null);
assert.equal(accounting.calls.length, 0);
});
// BUG-1147: in the segment (盘型) product the bubble neither reads a minute leaderboard nor
// repeats the boundary line the card already prints.
test("segment product: choice ack has no minute leaderboard", () => {
const base = {
optionId: "A" as const, scoring: true, appliedInference: true, answerClass: "yes" as const,
credibleBefore: ["04:52", "05:05"] as const, credibleAfter: ["04:52", "05:05"] as const,
deltasByCluster: [
{ range: ["04:52", "04:53"], delta: 0.4 }, { range: ["05:00", "05:05"], delta: -0.4 },
] as never,
};
const minute = composeChoiceNarration(base as never);
const segment = composeChoiceNarration({ ...base, segmentProduct: true } as never);
assert.equal(segment, "已记录,范围没变。");
assert.doesNotMatch(segment, /领先|落后/);
assert.match(minute, /领先/, "the minute product still reads the movement");
});
test("segment product: delivery bubble leaves the boundary line to the card", () => {
const summary: SegmentSummary = {
targets: ["D1", "D9"], window_minutes: 21, scan_complete: true, no_rectification_needed: false, adoption_minute: null,
charts: [
{ chart: "D1", sign: 3, tier: "certain", alternatives: [], tied: false },
{ chart: "D9", sign: 7, tier: "credible", alternatives: [], tied: false },
],
} as never;
const segment = deliveryTurnNarration({ credibleRange: ["04:52", "05:05"], segmentSummary: summary });
assert.ok(!segment.includes(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.match(segment, /D9 上升:天蝎(较可信)/);
const minute = deliveryTurnNarration({ credibleRange: ["04:52", "05:05"] });
assert.ok(minute.endsWith(REPRESENTATIVE_MINUTE_DISCLAIMER));
});
test("client: an adopt turn from the accept POST replaces the live row; no read_only run", async () => {
const { chatRunRecorder, offerableCandidateResult, jsonResponse, runRectificationCandidateAccept, withFakeFetch } = await import("./rectification-chat-run-fixtures.ts");
const recorder = chatRunRecorder([{ role: "assistant", text: "已记录,范围没变。", renderKey: "persisted-prev-0", state: "settled", turnId: "prev" } as never]);
recorder.candidateResult.set(offerableCandidateResult());
const adoptTurn = { id: "adopt-turn", role: "assistant", text: "已按 04:53 排盘。再核对一件事。", status: "completed" };
await withFakeFetch(() => jsonResponse({
ok: true, status: "accepted", saved_time: "04:53:00", result_id: "result-1", idempotent: false, adopt_turn_id: "adopt-turn",
}), async () => {
await runRectificationCandidateAccept(recorder.acceptDeps({
loadCaseSnapshot: async (merge) => { merge?.([{ id: "prev", role: "assistant", text: "已记录,范围没变。", status: "completed" }, adoptTurn]); return undefined; },
}), "88888888-8888-4888-8888-888888888881");
});
assert.equal(recorder.error.get(), "");
assert.equal(recorder.sends.length, 0, "no read_only run after the server wrote the adopt turn");
const transcript = recorder.messages.get();
assert.deepEqual(transcript.map((message) => message.turnId), ["prev", "adopt-turn"]);
assert.ok(transcript.every((message) => message.state === "settled"), "the live row is gone");
});
test("client: no adopt turn (idempotent accept) keeps the read_only continuation", async () => {
const { chatRunRecorder, offerableCandidateResult, jsonResponse, runRectificationCandidateAccept, withFakeFetch } = await import("./rectification-chat-run-fixtures.ts");
const recorder = chatRunRecorder();
recorder.candidateResult.set(offerableCandidateResult());
await withFakeFetch(() => jsonResponse({ ok: true, status: "accepted", saved_time: "04:53:00", result_id: "result-1", idempotent: true }),
async () => { await runRectificationCandidateAccept(recorder.acceptDeps(), "88888888-8888-4888-8888-888888888881"); });
assert.equal(recorder.error.get(), "");
assert.equal(recorder.sends.length, 1);
assert.equal(recorder.sends[0]!.action, "read_only");
});
// BUG-1149: real PostgreSQL types p_request_id as uuid; 「<id>:adopt」/「<id>:gate」 raised 22P02,
// the accept route swallowed it, and the gate narration never persisted.
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
test("deterministic turn request ids are uuids and stable per seed", () => {
const adopt = adoptTurnRequestId("66666666-6666-4666-8666-666666666666");
assert.match(adopt, UUID);
assert.equal(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666"));
assert.notEqual(adopt, adoptTurnRequestId("77777777-7777-4777-8777-777777777777"));
const gate = exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE);
assert.match(gate, UUID);
assert.equal(gate, exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE));
assert.match(exhaustionGateRequestId(null, CASE), UUID);
assert.notEqual(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666:x"));
});
// BUG-1150: post-adopt checks were stamped 「probe:<key>」 while the state holds the split-hashed
// 「probe:<key>:<split>」; the transition RPC rejected every answer as stale_probe.
test("a scoring post-adopt check carries the state's probe id", () => {
const plan = buildMethodFollowupPlan({
evidence: EVIDENCE as never, accepted: true, sessionOutcome: "adopt_representative",
selectionAllowed: true, proposeAllowed: true, eventProbes: [DATED_CHECK],
} as never);
const followup = plan.next_followup!;
assert.equal(followup.intent, "reverse_verify");
const stateId = "probe:career.2018.dasha_activation:career.2018.dasha_activation";
const inference = {
algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "04:58-05:06:05:00,05:04", revision: 6,
phase: "discrimination", result_status: "discriminating", range_start: "04:58", range_end: "05:06",
candidates: ["05:00", "05:04"].map((time, index) => ({ id: time, time, cluster_range: ["04:58", "05:06"], prior_score: 10,
posterior_score: 10, probability: 0.5, status: "active", rank: index + 1, strong_conflict_count: 0 })),
events: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "05:00", credible_range: ["04:58", "05:06"],
probes: [{ ...DATED_CHECK, id: stateId, question: "2018", information_gain: 0.5, year: 2018 }],
};
const receipt = { inference_state: inference };
assert.ok(inferenceForPersistedAnswer(receipt)?.probes.some((probe) => probe.id === stateId), "fixture state parses");
const schema = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, receipt, followup);
assert.equal(schema?.probe_id, stateId);
const bare = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, null, followup);
assert.equal(bare?.probe_id, "probe:career.2018.dasha_activation", "no state: the named key, as before");
});
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { segmentOrderEnabledFor, SEGMENT_ORDER_DEFAULT_MAX_WINDOW } from "../src/lib/rectification-agentic/core/segment-probe-order.ts";
// BUG-1144: segment-gain order is on by default only where the 77-case persisted replay lost no truth segment.
test("segment order is on by default for windows up to 61 minutes and off beyond", () => {
assert.equal(SEGMENT_ORDER_DEFAULT_MAX_WINDOW, 61);
assert.equal(segmentOrderEnabledFor(21, undefined), true);
assert.equal(segmentOrderEnabledFor(11, ""), true);
assert.equal(segmentOrderEnabledFor(61, undefined), true);
assert.equal(segmentOrderEnabledFor(62, undefined), false);
assert.equal(segmentOrderEnabledFor(121, undefined), false);
});
test("off disables everywhere; on keeps the research envelope; no scan means off", () => {
assert.equal(segmentOrderEnabledFor(21, "off"), false);
assert.equal(segmentOrderEnabledFor(61, "on"), true);
assert.equal(segmentOrderEnabledFor(121, "on"), false);
assert.equal(segmentOrderEnabledFor(undefined, undefined), false);
assert.equal(segmentOrderEnabledFor(0, "on"), false);
});