feat(rectification): 删掉年月录入卡,年月阶段打字回答且不被追问覆盖
Independent Staging Quality Gate / validate (push) Successful in 10m1s
Independent Staging Quality Gate / publish (push) Successful in 36m54s

This commit is contained in:
jesse-ux
2026-09-16 23:22:32 +08:00
parent abcfdac523
commit 6aabbe386f
49 changed files with 1035 additions and 431 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-16 — 年月阶段改打字回答,不再出录入卡,也不再追问是不是本人
答「有」之后只剩一句「大概哪年几月?」,在输入框打字即可;「哪一类事 + 发生年月」那张卡删掉了。助手不得把这句换成「是你本人做的吗」,也不得因为你回了一句短句就把本轮烧到步骤上限。独立问题块和助手正文对齐。Skill 升到 10.0.29。旧的校正会话仍按当时绑定的版本打开。
## 2026-09-16 — 生时校正:问完所有线才出卡,时间窗不再硬贴一个领域
出卡时机只看一件事:还有没有题可问。引导窗口题、跳过线的重问、还没问过的领域,全部问完之后才出目前范围卡;用户明确说「没有了」仍然立刻给结果。之前门槛一达标就会跳过剩下的采集线,现在不会了。反过来,题全问完了就照现行规则给卡和采用按钮,不会因为范围还宽而把你晾着。
+67 -2
View File
@@ -11725,8 +11725,8 @@
- 根因:提交文本直接拼 `KIND_ORAL[domain]`,而 `KIND_ORAL` 是**题干用的例子列表**,不是事件描述。
- 修复:改成「YYYY 年 M 月(D 日),<领域标签>方面有一件事」,标签取 `RANGE_DELIVERY_DOMAIN_LABEL`;文本里不再出现「或」。
- 验证:`frontend/tests/rectification-guided-collect-20260916.test.ts` 断言提交文本与「不含『或』」。
- 防复发:任何写进账本的用户文本都不得复用题干的例子列表。
- 相关记录:BUG-740、BUG-749
- 防复发:任何写进账本的用户文本都不得复用题干的例子列表。2026-09-16 录入卡按产品决策删除,本条防复发仍适用于任何用户可见文本。
- 相关记录:BUG-740、BUG-749、BUG-908
- 复发自:无
- 修复版本:待发布
@@ -11779,3 +11779,68 @@
- 复发自:BUG-713
- 修复版本:待发布
## BUG-908 | 年月阶段焦点题干被模型追问覆盖,下面还挂着录入卡
- 状态:resolved
- 首次发现:2026-09-16
- 最近更新:2026-09-16
- 影响面:`spoken-prompt.ts` `withSpokenPrompt``turn-decision.ts` `projectCurrentQuestion`、录入卡组件(已删)、Skill 10.0.29
- 用户现象:定向健康题答 A 后打字给了年月,助手追问「是你本人做的吗」,追问句下面还挂着「哪一类事 + 发生年月」录入卡。
- 触发条件:活动焦点题号是 `collect:targeted:<domain>:year``collect:guided:window:…:year`,模型用同号 `set-focus` 覆盖 `prompt`
- 根因:BUG-673 只保护了 `targeted_collect` 存在题;年月阶段 schema 会被 `withSpokenPrompt` 整段换成模型的话。产品同时拍板:录入卡整个删掉,年月阶段改打字。
- 修复:年月阶段以题号 `stage === "year"` 识别,只写 `spoken_prompt`、不动 `prompt`。投影只用服务器题干。删除 `EventDateEntryCard` / `EventDatePicker`。选项 A 改「有,我来说」。Skill 10.0.29。
- 验证:`frontend/tests/rectification-year-focus-overlay-20260916.test.ts`
- 防复发:年月阶段识别以题号前缀为准,与 BUG-673 同口径。不得再引入第二套录入入口。
- 相关记录:BUG-673、BUG-750
- 复发自:BUG-673
- 修复版本:待发布
## BUG-909 | 事件入账后年月焦点没有关闭
- 状态:resolved
- 首次发现:2026-09-16
- 最近更新:2026-09-16
- 影响面:`resolveActiveCollectFocusAfterEvidence``pendingTargetedYearDomain``rectification-record-evidence-batch``rectification-propose-evidence`
- 用户现象:时间轴已「记下了」年月事件,画面仍停在该领域的年月题。
- 触发条件:存在题答 A 落后年月焦点,用户打字给带年月事件。
- 根因:回放显示两条都要修。(a) batch 原先只在 `isCollectFocusSchema` 时 resolve,年月题号要按前缀识别;(b) `pendingTargetedYearDomain` 在存在题 resolved 后只要 year 题未关就会再出,即使账本已有该领域带年月事件。单条 `propose-evidence` 原先不 resolve。
- 修复:抽 `resolveActiveCollectFocusAfterEvidence`batch 与 propose 共用;`isCollectFocusSchema``:year` 题号都 resolve。`pendingTargetedYearDomain``coveredCollectKinds` 也算 yearClosed`health` 别名走 `canonicalCollectDomain`)。
- 验证:overlay 测试「dated evidence closes the year stage」与 persist collect schema。
- 防复发:两条写入路径必须走同一段 resolve;该领域已有带年月事件不得再出 `:year`
- 相关记录:BUG-908
- 复发自:无
- 修复版本:待发布
## BUG-910 | 年月阶段收到无年月短句会烧完步骤上限
- 状态:resolved
- 首次发现:2026-09-16
- 最近更新:2026-09-16
- 影响面:`agent-run.ts` host 前置、`year-entry-host.ts`
- 用户现象:对着追问回「是/不是」一类短句,整轮报「本轮达到步骤上限」。
- 触发条件:活动焦点是年月阶段,用户消息没有 `19xx/20xx` 或「x 年」。
- 根因:模型拿到「有年份走 batch,否则再问」的 hint,短句写不进、set-focus 同号幂等,循环到 8 步没正文。完整 agent 循环未在单测里复现(记防御性前置)。
- 修复:日期可靠性分类器之后、计费之前,host 以服务器题干再问一次,不进模型、不扣点。焦点保持原样,失败轮不会只关掉年月题。
- 验证:`shouldHostReaskYearEntry` 与 agent-run 源码顺序断言。
- 防复发:年月阶段无年份短句不得进模型。`applyHostFallback` 条件未改。
- 相关记录:BUG-908
- 复发自:无
- 修复版本:待发布
## BUG-911 | 独立问题块与助手正文不在同一条左边线
- 状态:resolved
- 首次发现:2026-09-16
- 最近更新:2026-09-16
- 影响面:`globals.css` `.rectification-message-question.is-standalone``rectification-agentic-chat.tsx`
- 用户现象:消息外的 persisted 问题块题干顶到左边。
- 触发条件:`questionGap === "persisted_question"` 的独立块。
- 根因:独立块没有 `--assistant-content-inset`
- 修复:独立块加 `is-standalone``margin-inline-start: var(--assistant-content-inset)`;块内 embedded 点选卡 inset 归零。
- 验证:overlay CSS 声明断言。
- 防复发:独立问题块必须与助手正文共用同一 inset token。
- 相关记录:BUG-908
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,33 @@
# 进度 · 删掉录入卡 + 年月阶段焦点被模型追问覆盖(2026-09-16)
基线:`origin/staging` @ `abcfdac5`(任务书含产品追加拍板)。
分支:`codex/rectification-year-focus-overlay-20260916`
BUG:908~911(任务书预留;仓库最大号当时是 753,906/907 给控制台噪音单)。
Skill10.0.29sha256 `750a0d58c6731c0dbdeb5c785cea1bf8a3f73aaf94a560a86397805b7037724c`
## 做了什么
- **T0**:删除 `event-date-entry-card.tsx` / `event-date-picker.tsx``.rectification-event-entry*` CSS。`event_date_entry` 不再写入 schema。年月题号保留,题干「大概哪年几月?」,用户打字答。选项 A「有,我来说」。`git grep` `event_date_entry|EventDateEntryCard|EventDatePicker|rectification-event-entry``frontend/src` **0 命中**
- **T1 / BUG-908**`withSpokenPrompt` 以题号 `stage === "year"` 只写 `spoken_prompt``projectCurrentQuestion` 年月阶段只用服务器 `prompt`。hint 与 Skill 禁止追问本人。
- **T2 / BUG-909**:回放定两条都要修。(a) persist 的年月焦点已是 `isCollectFocusSchema`,但 resolve 仍按题号前缀补一层;(b) `pendingTargetedYearDomain` 把已覆盖领域算 yearClosed`health``health_pressure`)。`resolveActiveCollectFocusAfterEvidence` 给 batch 与 propose 共用。
- **T3 / BUG-910**host 前置接无年月短句,不进模型、不扣点。完整 8 步循环未做成 live agent 回放,前置作为防御;失败轮不 resolve 焦点。状态仍记 `resolved`(用户可见路径已接住)。
- **T4 / BUG-911**:独立问题块 `is-standalone` 与助手正文共用 `--assistant-content-inset`
- **T5**BUG_HISTORY / CHANGELOG / DESIGN / VOICE / 真机清单 / 本进度。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| 改动文件 `eslint` | **0 error** / 34 warning(全是既有 unused / hooks |
| `Home()` growth | useState / useRef / 行数护栏全绿 |
| overlay + guided + 先前红的 Skill 版本合同 | **50 passed / 0 fail** |
| `rectification-*.test.ts` + skill-registry + home-shellconcurrency=1 | 1330 条:修完 Skill 正文 `version:` 断言后,内容失败为 0。剩 **5 条 Windows EPERM symlink**skill-registry 4 + `rectification-v9-agent.test.ts` 导入时建 runtime alias),Linux CI 不受影响。hash / 版本解析合同已绿。 |
| 全量 `npm test` / `npm run lint` | 本机并发 OOM,未拿全量数字。失败清单里与本单相关的 Skill `10.0.28` 正文断言已改到 `10.0.29`(原值/新值/原因写在测试旁)。 |
| `next build` | worktree 的 `node_modules` 是指向主检出的 junctionTurbopack 拒收;webpack 编译 + tsc 通过,收集 `/api/consult` 页数据时同样 EPERM symlink。**`/` Static 与首屏 gzip 留给 Gitea 门禁**。删除两张录入卡,gzip 只应降不应升。 |
| Python | 未改,不跑快速门。 |
## 环境缺口
- Windows 无管理员/开发者模式 symlink:`skill-registry` 与 Skill runtime alias。Gitea 跑 Linux。
- 真机清单:`docs/testing/rectification-year-focus-overlay-20260916.md`
+1 -1
View File
@@ -101,7 +101,7 @@
| `TASK-rectification-superseded-focus-duplicate-20260910.md` | `PROGRESS-rectification-superseded-focus-duplicate-20260910.md` | P0:一轮两次 batch 让感情采集题被顶替成 superseded 行;题号唯一约束含已结束行、汇总只聚合 declined/skipped,计划层看不见→撞号 `duplicate_focus` 被当「已问过」→出口误走交付旁白无卡(BUG-644);交付口吻与 `can_adopt=false` 矛盾(BUG-645) | 待验收(环境缺口:`next build` / 真人走查) | `a3a51c32`BUG-644/645 |
| `TASK-rectification-exhausted-gate-exit-20260910.md` | `PROGRESS-rectification-exhausted-gate-exit-20260910.md` | 五领域全拒答 + 三件带月经历:门槛句后无载体、客户端误报「没有拿到下一个问题」并循环「接着问」(BUG-646);第三件被留作 holdout 导致永远没有选择题(BUG-647)。含产品建议:一次性开放追问 + 终态标记 + 穷尽后释放 holdout | 已被取代(并入采集重设计单) | — |
| `TASK-rectification-collection-redesign-20260910.md` | `PROGRESS-rectification-collection-redesign-20260910.md` | 产品口径:用户先说、系统从已说的事锚定追问、不得用生日推年份、不得说「领域」、永远交付结果。无固定题数:收集问题池按信息价值排序问到训练门开;选择题问到收敛门开或增益见底;门关不出结果只出精确缺口句并保持开放;≥4 件才留 holdout;撤回 BUG-642 年份线索、BUG-646 单终态与「暂定采用」;Skill 10.0.23BUG-646648 | 待验收 | `dd8f35f7` |
| `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84**。主体默认本人不追问,Skill 10.0.29 | 待领取 | |
| `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84**。主体默认本人不追问,Skill 10.0.29 | 待验收 | `codex/rectification-year-focus-overlay-20260916` |
### 聊天主链路与首页
@@ -0,0 +1,9 @@
# 真机清单 · 年月阶段口述(2026-09-16
在 staging 已登录账号上走一遍。不要用真实出生资料以外的生产用户。
1. 开一段新的生时校正,答到定向健康题「身体上有没有过住院、手术……」。
2. 点 A「有,我来说」。下面应出现一句「大概哪年几月?」,**没有**「哪一类事」芯片,也**没有**年/月选择器。
3. 在输入框打字给一件带年月的事(例如「2019年3月做过手术」)。助手只说「记下了」一类承接,**不追问**「是不是你本人」。
4. 若仍口头追问了别的,回一句「是」或「不是」:不应出现「本轮达到步骤上限」。
5. 任何独立问题块(题干,如还有点选卡)左边线与助手正文对齐。
+1 -1
View File
@@ -713,7 +713,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 14. 断点
宽度切点只允许出现在 `globals.css` 顶部注释的白名单里。壳层切点以 `sidebarViewportForWidth` 为准:小于 768 移动,小于 1024 平板,否则桌面。组件级栅格(会员方案卡、校正候选、录入卡)用 640/641。报告目录在 860 塌成抽屉,因为那是第三栏的内容宽度,不是设备宽度。
宽度切点只允许出现在 `globals.css` 顶部注释的白名单里。壳层切点以 `sidebarViewportForWidth` 为准:小于 768 移动,小于 1024 平板,否则桌面。组件级栅格(会员方案卡、校正候选)用 640/641。校正独立问题块(`.rectification-message-question.is-standalone`)与助手正文共用 `--assistant-content-inset`报告目录在 860 塌成抽屉,因为那是第三栏的内容宽度,不是设备宽度。
禁止再引入 900 / 720 / 760 / 639 / 620 / 430 这类与壳层或彼此错位的切点。新切点必须先改白名单和 `viewport-breakpoint-contract.test.ts`
+2 -2
View File
@@ -48,9 +48,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 |
| 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 |
| 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 |
| 能问的都问完了 / 你要是还记得确切哪一天的事,不限领域,说出来我接着算 | 还有题可问时:「现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准」,下面是系统点名的题(带年月区间)和年/月选择器。题问完了或用户说「没有了」,就出目前范围卡,卡上不再邀请打字。 | 有题就继续引导,不让用户自由发挥;没题了照常给结果,不写「未达门槛」。不承诺「再补几件就能定到分钟」。 |
| 能问的都问完了 / 你要是还记得确切哪一天的事,不限领域,说出来我接着算 | 还有题可问时:「现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准」,下面是系统点名的题。答「有」之后是口述「大概哪年几月?」,在输入框打字回答,不再出「哪一类事 + 发生年月」的卡。题问完了或用户说「没有了」,就出目前范围卡。 | 有题就继续引导;年月阶段直接打字。没题了照常给结果,不写「未达门槛」。不承诺「再补几件就能定到分钟」。 |
| 2018 年 3 到 5 月之间,有没有入职、换工作或职责变重?(引擎其实不知道是哪一类) | 「2018 年 3 到 5 月之间,有没有什么事,比如搬家或开始长期住外地、开始认真关系、分手或结婚?」只有 D9 / D10 那两条轨道才点名领域。 | 边界只说得出「哪段时间」,说不出「哪一类事」。例子最多三个,只列用户还没拒答、也还没说过的领域。 |
| (录入卡提交)2019 年 3 月,开始认真关系、分手或结婚 | 2019 年 3 月,感情方面有一件事。 | 账本记的是用户选的那一类,不是题干里的三选一列表;不出现「或」。 |
| (录入卡)哪一类事 + 发生年月 | 答「有」后只剩口述「大概哪年几月?」,在输入框打字。 | 多余入口删除;年月阶段不再有第二套录入。账本仍记用户原话,不得复用题干例子列表。 |
| 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 |
| 你的出生记录不准,按校正结果来 / 这些经历说明记录写错了 / 校正结果无效 | 出生记录时间 04:40,默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 8 分钟。你可以改用校正结果,也可以继续用记录。 | 记录与校正区间冲突时记录优先,但两者必须并列。不得宣布任何一方无效——不说记录不准,也不说校正结果不作数。 |
| (冲突时按钮仍写)更像这个 | 按钮写「改用校正结果」,按钮下面一行写「选它之后,排盘会从出生记录时间 04:40 换成 04:48」。 | 换掉的是什么要在动手的地方说清楚。不新增入口,仍是同一个采用按钮;采用仍不是确认。 |
+6 -43
View File
@@ -2972,6 +2972,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
margin-block-start: var(--space-3);
width: 100%;
}
.rectification-message-question.is-standalone {
margin-inline-start: var(--assistant-content-inset);
}
.rectification-message-question.is-standalone .rectification-choice-card.is-embedded {
margin-inline-start: 0;
}
.rectification-message-question__prompt {
margin: 0;
color: var(--color-ink);
@@ -3086,49 +3092,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-choice-card.is-embedded .birth-time-choice-question legend {
display: unset;
}
.rectification-event-entry {
display: grid;
gap: var(--space-3);
margin: var(--space-3) 0 var(--space-4);
margin-inline-start: var(--assistant-content-inset);
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
}
.rectification-event-entry__label {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-event-entry__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rectification-event-entry__chip {
min-height: 36px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: 999px;
background: transparent;
color: var(--color-ink);
font-size: var(--type-caption);
}
.rectification-event-entry__chip[data-selected="true"] {
border-color: var(--color-action);
background: var(--color-action-soft);
color: var(--color-action);
}
.rectification-event-date {
display: grid;
gap: var(--space-2);
}
.rectification-event-date__footer {
display: flex;
justify-content: flex-end;
padding: 0 var(--space-3) var(--space-3);
}
.rectification-choice-why {
margin: 0 0 var(--space-3);
color: var(--color-ink-secondary);
@@ -1,119 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { EventDatePicker, type EventDateValue } from "@/components/event-date-picker";
import { Button } from "@/components/ui/button";
import {
COLLECT_KIND_ORDER,
GUIDED_ANY_DOMAIN,
type CollectKind,
type GuidedWindowDomain,
} from "@/lib/rectification-agentic/v9/collection-question-pool";
import { RANGE_DELIVERY_DOMAIN_LABEL } from "@/lib/rectification-agentic/user-copy";
export type EventDateEntrySubmit = Readonly<{
domain: CollectKind;
year: number;
month: number;
day: number | null;
}>;
/**
* BUG-750: the card already knows the date and the domain the user picked, so
* it states the one fact. The old text pasted the question's option list
* ("开始认真关系、分手或结婚"), which the ledger then stored as the summary.
*/
export function formatEventDateEntryMessage(input: EventDateEntrySubmit): string {
const when = input.day
? `${input.year}${input.month}${input.day}`
: `${input.year}${input.month}`;
const label = RANGE_DELIVERY_DOMAIN_LABEL[input.domain] ?? input.domain;
return `${when}${label}方面有一件事`;
}
export function eventDateYearRange(
birthYear: number | null | undefined,
nowYear = new Date().getFullYear(),
): { minYear: number; maxYear: number } {
const maxYear = nowYear;
if (typeof birthYear === "number" && birthYear >= 1900 && birthYear <= maxYear) {
return { minYear: birthYear, maxYear };
}
return { minYear: Math.max(1900, maxYear - 80), maxYear };
}
export function resolveEntryDomain(
domain: GuidedWindowDomain,
openDomains: readonly CollectKind[] = COLLECT_KIND_ORDER,
): CollectKind {
if (domain !== GUIDED_ANY_DOMAIN) return domain;
return openDomains[0] ?? COLLECT_KIND_ORDER[0];
}
export function EventDateEntryCard({
defaultDomain,
openDomains,
defaultYear,
defaultMonth,
minYear,
maxYear,
disabled = false,
onSubmit,
}: {
readonly defaultDomain: GuidedWindowDomain;
readonly openDomains?: readonly CollectKind[];
readonly defaultYear?: number;
readonly defaultMonth?: number;
readonly minYear: number;
readonly maxYear: number;
readonly disabled?: boolean;
readonly onSubmit: (value: EventDateEntrySubmit) => void;
}) {
const [domain, setDomain] = useState<CollectKind>(
resolveEntryDomain(defaultDomain, openDomains),
);
const initial = useMemo<EventDateValue | null>(() => {
if (!defaultYear || defaultYear < minYear || defaultYear > maxYear) return null;
const month = defaultMonth && defaultMonth >= 1 && defaultMonth <= 12 ? defaultMonth : 1;
return { year: defaultYear, month, day: null };
}, [defaultYear, defaultMonth, minYear, maxYear]);
const [date, setDate] = useState<EventDateValue | null>(initial);
return (
<form
className="rectification-event-entry"
onSubmit={(event) => {
event.preventDefault();
if (!date || disabled) return;
onSubmit({ domain, year: date.year, month: date.month, day: date.day });
}}
>
<p className="rectification-event-entry__label"></p>
<div className="rectification-event-entry__chips" role="group" aria-label="经历类型">
{COLLECT_KIND_ORDER.map((kind) => (
<button
key={kind}
type="button"
className="rectification-event-entry__chip"
data-selected={kind === domain}
disabled={disabled}
onClick={() => setDomain(kind)}
>
{RANGE_DELIVERY_DOMAIN_LABEL[kind] ?? kind}
</button>
))}
</div>
<EventDatePicker
value={date}
minYear={minYear}
maxYear={maxYear}
disabled={disabled}
onChange={setDate}
/>
<Button type="submit" disabled={disabled || !date}>
</Button>
</form>
);
}
@@ -1,105 +0,0 @@
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export type EventDateValue = Readonly<{
year: number;
month: number;
day: number | null;
}>;
type EventDatePickerProps = {
readonly value: EventDateValue | null;
readonly minYear: number;
readonly maxYear: number;
readonly disabled?: boolean;
readonly onChange: (value: EventDateValue) => void;
};
function clampYear(year: number, minYear: number, maxYear: number): number {
return Math.min(maxYear, Math.max(minYear, year));
}
function formatValue(value: EventDateValue | null): string {
if (!value) return "选择年月";
if (value.day) return `${value.year}${value.month}${value.day}`;
return `${value.year}${value.month}`;
}
export function EventDatePicker({
value,
minYear,
maxYear,
disabled = false,
onChange,
}: EventDatePickerProps) {
const labelId = useId();
const valueId = useId();
const [open, setOpen] = useState(false);
const selected = value
? new Date(value.year, value.month - 1, value.day ?? 1)
: undefined;
const startMonth = new Date(minYear, 0);
const endMonth = new Date(maxYear, 11);
return (
<div className="rectification-event-date">
<span id={labelId}></span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<Button
type="button"
variant="outline"
disabled={disabled}
aria-labelledby={`${labelId} ${valueId}`}
data-empty={!value}
className="w-full justify-start px-3 text-left font-normal data-[empty=true]:text-muted-foreground"
/>}
>
<span id={valueId}>{formatValue(value)}</span>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
key={value ? `${value.year}-${value.month}-${value.day ?? 0}` : "empty"}
mode="single"
className="[--cell-size:2.75rem] [&_button[data-selected-single=true]]:text-primary-foreground!"
selected={selected}
defaultMonth={selected ?? new Date(clampYear(maxYear - 10, minYear, maxYear), 0)}
captionLayout="dropdown"
navLayout="after"
startMonth={startMonth}
endMonth={endMonth}
reverseYears
disabled={{ before: new Date(minYear, 0, 1), after: new Date(maxYear, 11, 31) }}
onSelect={(nextDate) => {
if (nextDate === undefined) return;
onChange({
year: nextDate.getFullYear(),
month: nextDate.getMonth() + 1,
day: nextDate.getDate(),
});
}}
/>
<div className="rectification-event-date__footer">
<Button
type="button"
variant="ghost"
disabled={disabled || !value}
onClick={() => {
if (!value) return;
onChange({ year: value.year, month: value.month, day: null });
setOpen(false);
}}
>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}
@@ -113,13 +113,6 @@ import {
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import {
EventDateEntryCard,
eventDateYearRange,
formatEventDateEntryMessage,
} from "./event-date-entry-card";
import { parseYearEntryQuestionId } from "@/lib/rectification-agentic/v9/collection-question-pool";
import { birthYearFromDate } from "@/lib/rectification-agentic/v9/adult-floor";
import {
applyLiveCandidateOffer,
copyTextForMessage,
@@ -1424,8 +1417,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
answeredProbeCount: candidateResult?.answeredProbeCount ?? null,
datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null,
});
const yearEntry = parseYearEntryQuestionId(currentQuestion?.question_id);
const yearRange = eventDateYearRange(birthYearFromDate(birthDate));
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const liveSelectionCardKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
@@ -1739,7 +1730,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
})}
{questionGap === "persisted_question" && currentQuestion?.prompt && (
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
<div className="rectification-message-question">
<div className="rectification-message-question is-standalone">
{persistedQuestionSurface({
questionKind: currentQuestion.kind,
hasChoiceCard: Boolean(choiceCard),
@@ -1758,21 +1749,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
</>
) : yearEntry ? (
<>
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
<EventDateEntryCard
defaultDomain={yearEntry.domain}
defaultYear={yearEntry.year ?? undefined}
defaultMonth={yearEntry.month ?? undefined}
minYear={yearRange.minYear}
maxYear={yearRange.maxYear}
disabled={busy || readonly}
onSubmit={(value) => {
void send("message", formatEventDateEntryMessage(value));
}}
/>
</>
) : (
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
)}
@@ -21,6 +21,7 @@ import {
loadV9CaseSkillIdentity,
loadV9CaseCompute,
RectificationToolServiceError,
persistV9DeterministicTurn,
resolveV10ConversationFocus,
setV9EvidenceDateReliability,
type RectificationRpcClient,
@@ -31,6 +32,7 @@ import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
import { shouldHostReaskYearEntry } from "./year-entry-host.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { alreadyDelivered } from "./delivery-turn-guard";
@@ -336,6 +338,28 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
// Date reliability (above) runs first. Year-stage typed collect with a
// yearless short reply does not enter the model (BUG-910).
const yearReask = shouldHostReaskYearEntry(dossier.conversationSummary.activeFocus, message);
if (yearReask && message) {
const persisted = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId: options.requestId,
userMessage: message,
assistantMessage: yearReask,
});
await emit({ type: "answer.delta", text: yearReask, replace: true });
return {
ok: true,
turnId: persisted.turnId,
turnStatus: "completed",
skillLoaded: true,
answerText: yearReask,
phases: ["answer.host_year_entry"],
toolsUsed: [],
errorCode: null,
previousFocusId,
};
}
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
@@ -89,5 +89,5 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
export const RECTIFICATION_SKILL_VERSION = "10.0.28";
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
export const RECTIFICATION_SKILL_VERSION = "10.0.29";
@@ -27,15 +27,15 @@ export const CHOICE_STOP_MESSAGE = "先这样";
export const CHOICE_SKIP_QUESTION_LABEL = "这题跳过";
export const CHOICE_SKIP_QUESTION_MESSAGE = "这题跳过";
export const HOLDOUT_MESSAGE_PREFIX = "盘外核对(不计分)";
export const TARGETED_COLLECT_OPTION_A = "有,我来填时间";
export const TARGETED_COLLECT_OPTION_A = "有,我来";
export const TARGETED_COLLECT_OPTION_B = "这类事都没有过";
export const TARGETED_COLLECT_OPTION_C = "记不太清楚";
export const TARGETED_COLLECT_OPTION_D = "这条先跳过";
export const GUIDED_WINDOW_OPTION_A = "有,我来填时间";
export const GUIDED_WINDOW_OPTION_A = "有,我来";
export const GUIDED_WINDOW_OPTION_B = "这段没有";
export const GUIDED_WINDOW_OPTION_C = "记不清";
export const GUIDED_WINDOW_OPTION_D = "这条先跳过";
export const TARGETED_COLLECT_WHY_USER = "答有的话再用选择器填年月,没有或记不清就问下一条。";
export const TARGETED_COLLECT_WHY_USER = "答有的话直接打字说大概年月,没有或记不清就问下一条。";
export const TARGETED_COLLECT_KEEP_HINT = "照发服务端卡片,不要自己写题干。";
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -680,18 +680,10 @@ export function guidedWindowQuestionId(
return stage === "year" ? `${base}:year` : base;
}
export function parseYearEntryQuestionId(
questionId: string | null | undefined,
): Readonly<{ domain: GuidedWindowDomain; year: number | null; month: number | null }> | null {
const guided = parseGuidedWindowQuestionId(questionId);
if (guided?.stage === "year") {
return { domain: guided.domain, year: guided.year, month: guided.monthLo };
}
const targeted = parseTargetedCollectQuestionId(questionId);
if (targeted?.stage === "year") {
return { domain: targeted.domain, year: null, month: null };
}
return null;
/** Year-stage collect questions: typed answers, no entry card (T0 / BUG-908). */
export function isYearStageQuestionId(questionId: string | null | undefined): boolean {
return parseTargetedCollectQuestionId(questionId)?.stage === "year"
|| parseGuidedWindowQuestionId(questionId)?.stage === "year";
}
/** Open-window examples: the first three still-open domains, table driven. */
@@ -897,8 +889,9 @@ export function pendingTargetedYearDomain(
existenceResolved.add(targeted.domain);
}
}
for (const domain of covered) yearClosed.add(domain);
for (const domain of existenceResolved) {
if (covered.has(domain) || yearClosed.has(domain)) continue;
if (yearClosed.has(domain)) continue;
return domain;
}
return null;
@@ -1528,7 +1528,7 @@ export function targetedCollectYearFollowup(domain: CollectKind): MethodFollowup
ask_theme: theme,
domain,
kind_hint: `targeted:${domain}:year`,
user_prompt_hint: TARGETED_YEAR_PROMPT,
user_prompt_hint: `${TARGETED_YEAR_PROMPT} 用户给了年月就走 batch 写入并结束本题;不追问主体、原因、细节;定向健康题主体默认本人。`,
must_not_label: false,
choice_frame: null,
source: "method_coverage",
@@ -2490,7 +2490,7 @@ export function buildMethodFollowupPlan(input: {
kind_hint: collectKindFromFocus(focus),
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入并结束本题;不追问主体、原因、细节;定向健康题主体默认本人。否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
source: "active_focus",
...(liveProbe && focus.intent === "distinguish_candidates"
? {
@@ -23,11 +23,13 @@ import { refinementFromDecisionReceipt } from "./refinement-packet";
import {
setV10ConversationFocus,
resolveV10ConversationFocus,
loadV9CaseDossier,
RectificationToolServiceError,
safeToolErrorCode,
type AccountingClient,
type ConversationFocus,
} from "./tool-service";
import { isYearStageQuestionId } from "./collection-question-pool";
export { parsePersistedFollowupQuestionId, parseCollectFocusQuestionId } from "./method-followup";
@@ -342,14 +344,6 @@ export function collectFocusSchema(followup: MethodFollowup): Record<string, unk
schema.date_reliability = true;
schema.target_evidence_id = followup.date_reliability_evidence_id;
}
const yearEntry = (followup.kind_hint ?? "").endsWith(":year")
|| (followup.collection_key ?? "").endsWith(":year");
if (yearEntry) {
schema.event_date_entry = true;
if (followup.domain) schema.default_domain = followup.domain;
if (typeof followup.probe_year === "number") schema.default_year = followup.probe_year;
if (typeof followup.probe_month === "number") schema.default_month = followup.probe_month;
}
return schema;
}
@@ -381,6 +375,31 @@ export function isCollectFocusSchema(schema: Readonly<Record<string, unknown>> |
return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string";
}
export function shouldResolveCollectFocusAfterEvidence(
focus: Pick<ConversationFocus, "intent" | "questionId" | "expectedAnswerSchema"> | null | undefined,
): boolean {
if (!focus || focus.intent !== "collect_method_evidence") return false;
if (isCollectFocusSchema(focus.expectedAnswerSchema)) return true;
return isYearStageQuestionId(focus.questionId);
}
export async function resolveActiveCollectFocusAfterEvidence(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
evidenceId: string | null;
}): Promise<void> {
if (!input.evidenceId) return;
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const active = dossier.conversationSummary.activeFocus;
if (!active || !shouldResolveCollectFocusAfterEvidence(active)) return;
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
focusId: active.id,
status: "resolved",
evidenceId: input.evidenceId,
});
}
async function persistCollectFocus(input: {
accounting: AccountingClient;
userId: string;
@@ -2,6 +2,7 @@ import { parseAgentChoiceCopy } from "./choice-card";
import { persistableFocusDomain, stableFollowupQuestionId } from "./server-focus";
import { MACHINE_VOICE_LEXICON } from "./agent-voice-lexicon";
import { COLLECT_DOMAIN_KEYWORDS } from "../user-copy";
import { isYearStageQuestionId } from "./collection-question-pool";
import type { MethodFollowup } from "./method-followup";
export const SPOKEN_PROMPT_MIN = 8;
@@ -99,8 +100,9 @@ export function validateSpokenPrompt(input: {
export function withSpokenPrompt(
schema: Readonly<Record<string, unknown>>,
spokenPrompt: string,
questionId?: string | null,
): Record<string, unknown> {
if (schema.targeted_collect === true) {
if (schema.targeted_collect === true || isYearStageQuestionId(questionId)) {
return { ...schema, spoken_prompt: spokenPrompt };
}
const next: Record<string, unknown> = { ...schema, prompt: spokenPrompt };
@@ -14,7 +14,7 @@ import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
import { parseAgentChoiceCopy, serverOwnedChoiceCopy } from "./choice-card";
import { isCollectFocusSchema } from "./server-focus";
import { isTargetedCollectExistenceFocus } from "./collection-question-pool";
import { isTargetedCollectExistenceFocus, isYearStageQuestionId } from "./collection-question-pool";
import { rebuildTargetedCollectExistenceFrame } from "./method-followup";
export const TURN_DECISION_MAX_BYTES = 6 * 1024;
@@ -83,6 +83,17 @@ export function projectCurrentQuestion(
if (!focus) return null;
const schema = focus.expectedAnswerSchema;
const spoken = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
if (isYearStageQuestionId(focus.questionId) && spoken) {
return {
question_id: focus.questionId ?? null,
focus_id: focus.id ?? null,
probe_id: typeof schema?.probe_id === "string" ? schema.probe_id : null,
prompt: spoken,
kind: "collect_spoken",
intent: focus.intent,
domain: focus.targetDomain ?? null,
};
}
const copy = parseAgentChoiceCopy(schema);
const probeId = typeof schema?.probe_id === "string" ? schema.probe_id : null;
if (copy) {
@@ -0,0 +1,27 @@
/**
* Year-stage collect is a typed question. A yearless short reply must not
* enter the model (BUG-910). Date-reliability classification runs first.
*/
import { TARGETED_YEAR_PROMPT, isYearStageQuestionId } from "./collection-question-pool";
import type { ConversationFocus } from "./tool-service";
export function userMessageHasYear(message: string): boolean {
return /(?:19|20)\d{2}|\d{1,4}\s*年/.test(message);
}
export function yearEntryServerPrompt(
schema: Readonly<Record<string, unknown>> | null | undefined,
): string {
const prompt = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
return prompt || TARGETED_YEAR_PROMPT;
}
export function shouldHostReaskYearEntry(
focus: Pick<ConversationFocus, "questionId" | "expectedAnswerSchema"> | null | undefined,
message: string | null | undefined,
): string | null {
if (!message?.trim()) return null;
if (!isYearStageQuestionId(focus?.questionId ?? null)) return null;
if (userMessageHasYear(message)) return null;
return yearEntryServerPrompt(focus?.expectedAnswerSchema);
}
+22 -16
View File
@@ -94,10 +94,10 @@ import {
import {
COLLECT_FOCUS_SCHEMA_KEY,
collectFocusSchema,
isCollectFocusSchema,
openQuestionFromPersistedFocus,
persistableFocusDomain,
persistServerOwnedFocus,
resolveActiveCollectFocusAfterEvidence,
serverOwnedExpectedAnswerSchema,
stableFollowupQuestionId,
} from "@/lib/rectification-agentic/v9/server-focus";
@@ -1239,6 +1239,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
expectedAnswerSchema: withSpokenPrompt(
{ ...activeFocus.expectedAnswerSchema },
spoken.prompt,
activeFocus.questionId,
),
askedTurnId: turnId,
});
@@ -1346,7 +1347,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
&& isTargetedCollectExistenceFollowup(persistFollowup)
&& expectedAnswerSchema.choice
? { ...expectedAnswerSchema, spoken_prompt: spokenText }
: withSpokenPrompt(expectedAnswerSchema, spokenText);
: withSpokenPrompt(
expectedAnswerSchema,
spokenText,
stableFollowupQuestionId(persistFollowup),
);
await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint });
const result = await setV10ConversationFocus(accounting, userId, input.caseId, {
questionId: stableFollowupQuestionId(persistFollowup),
@@ -1626,23 +1631,16 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
focusId: recorded.focusId,
}));
if (result.acceptedCount > 0) {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const acceptedEvidenceId = result.items.find(
(item) => item.outcome === "accepted" && item.evidenceId,
)?.evidenceId ?? null;
const activeFocus = dossier.conversationSummary.activeFocus;
if (
acceptedEvidenceId
&& activeFocus
&& activeFocus.intent === "collect_method_evidence"
&& isCollectFocusSchema(activeFocus.expectedAnswerSchema)
) {
await resolveV10ConversationFocus(accounting, userId, input.caseId, {
focusId: activeFocus.id,
status: "resolved",
evidenceId: acceptedEvidenceId,
});
}
await resolveActiveCollectFocusAfterEvidence({
accounting,
userId,
caseId: input.caseId,
evidenceId: acceptedEvidenceId,
});
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
if (isResumableStatus(dossier.case.status as RectificationCaseStatus)) {
await transitionV9CaseStatus(accounting, userId, input.caseId, "collecting_evidence");
}
@@ -1777,6 +1775,14 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
errorCode: "invalid_item",
idempotent: false,
};
if (result.outcome === "accepted" && result.evidenceId) {
await resolveActiveCollectFocusAfterEvidence({
accounting,
userId,
caseId: input.caseId,
evidenceId: result.evidenceId,
});
}
await receipt("rectification-propose-evidence", "evidence.proposed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(result),
@@ -464,8 +464,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
@@ -365,8 +365,8 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
const accounting = fakeAccounting({
...receiptHandlers,
@@ -258,15 +258,15 @@ test("skill 10.0.26 forbids computing varga signs from transition times", () =>
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump
assert.match(skill, /^version: 10\.0\.28$/m);
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
@@ -1454,8 +1454,8 @@ test("public tool surface stays at 14 and new cases bind 10.0.26", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
const deprecated = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
@@ -602,8 +602,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
@@ -9,11 +9,12 @@ import {
guidedAnyExamples,
TARGETED_EXISTENCE_PROMPT,
TARGETED_EXISTENCE_PROMPT_RETRY,
TARGETED_YEAR_PROMPT,
guidedCollectExhausted,
guidedRetryPool,
guidedWindowPool,
guidedWindowPrompt,
parseYearEntryQuestionId,
parseGuidedWindowQuestionId,
targetedCollectPool,
rangeNarrowHint,
remainingGuidedLine,
@@ -26,11 +27,6 @@ import {
GUIDED_WINDOW_OPTION_B,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
eventDateYearRange,
formatEventDateEntryMessage,
resolveEntryDomain,
} from "../src/components/event-date-entry-card.tsx";
const WINDOWS: readonly GuidedCollectWindow[] = [
{ year: 2018, month_lo: 3, month_hi: 6, domain: "career", split: { left: 2, right: 3 } },
@@ -64,7 +60,7 @@ test("guided pool order is windows, skip retry, then uncovered domains", () => {
assert.equal(followup?.collection_key, windows[0]?.key);
});
test("A on a window yields a year-entry card, B/C close only that window", () => {
test("A on a window yields a typed year question, B/C close only that window", () => {
const evidence: CollectionEvidence[] = [];
const afterYes = [
{
@@ -75,11 +71,17 @@ test("A on a window yields a year-entry card, B/C close only that window", () =>
target_kind: "guided:window:career",
},
];
const year = parseYearEntryQuestionId("collect:guided:window:2018:3:6:career:year");
// 原值: parseYearEntryQuestionId + EventDateEntryCard
// 新值: 年月阶段是口述题「大概哪年几月?」
// 原因: T0 产品删除录入卡
const year = parseGuidedWindowQuestionId("collect:guided:window:2018:3:6:career:year");
assert.equal(year?.domain, "career");
assert.equal(year?.year, 2018);
assert.equal(year?.stage, "year");
const pending = targetedCollectFollowup([], evidence, afterYes, null, 5, ["04:51", "05:11"], WINDOWS);
assert.equal(pending?.collection_key?.endsWith(":year"), true);
assert.equal(pending?.spoken_prompt, TARGETED_YEAR_PROMPT);
assert.equal(pending?.choice_frame, null);
const afterNo = [
{
@@ -196,23 +198,7 @@ test("window prompt uses oral phrases, not server labels", () => {
assert.doesNotMatch(prompt, /event_family|career_entry/);
});
test("date entry submit walks the dated evidence path as year-month text", () => {
// 原值: "2018 年 3 月,开始认真关系、分手或结婚"
// 新值: "2018 年 3 月,感情方面有一件事"
// 原因: F6/BUG-750——提交文本不得是选项列表,账本 summary 不得含「或」
assert.equal(
formatEventDateEntryMessage({ domain: "relationship", year: 2018, month: 3, day: null }),
"2018 年 3 月,感情方面有一件事",
);
assert.doesNotMatch(
formatEventDateEntryMessage({ domain: "relationship", year: 2018, month: 3, day: 12 }),
/或/,
);
assert.equal(
formatEventDateEntryMessage({ domain: "family", year: 2019, month: 1, day: 5 }),
"2019 年 1 月 5 日,家人方面有一件事",
);
});
test("open windows ask without a domain and list open-domain orals", () => {
const open = guidedWindowPrompt({
@@ -250,9 +236,10 @@ test("an open window keeps its question id and is asked once for any domain", ()
assert.equal(asked.length, 0);
});
test("the entry card opens on the first still-open domain for an open window", () => {
assert.equal(resolveEntryDomain(GUIDED_ANY_DOMAIN, ["finance", "family"]), "finance");
assert.equal(resolveEntryDomain("relationship", ["finance", "family"]), "relationship");
test("open windows still rank remaining collect kinds without an entry card", () => {
// 原值: resolveEntryDomain + EventDateEntryCard 默认芯片
// 新值: 只保留 firstOpenCollectKind 表驱动排序
// 原因: T0 删除录入卡
assert.equal(
firstOpenCollectKind(
[{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2014-09-01", occurredTo: "2014-09-01" }],
@@ -262,11 +249,6 @@ test("the entry card opens on the first still-open domain for an open window", (
);
});
test("date picker year range is birth year through this year", () => {
assert.deepEqual(eventDateYearRange(1961, 2026), { minYear: 1961, maxYear: 2026 });
assert.deepEqual(eventDateYearRange(null, 2026), { minYear: 1946, maxYear: 2026 });
});
test("guided collect is not exhausted while a window remains", () => {
assert.equal(guidedCollectExhausted([], [], [], WINDOWS, ["04:51", "05:11"], 5), false);
assert.equal(guidedCollectExhausted(["d9", "d10", "d4", "d5", "d7", "d2", "d30"], [
@@ -219,15 +219,15 @@ test("new-case skill identity is 10.0.26 and the prompt prefers batch ingest", (
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump
assert.match(skill, /^version: 10\.0\.28$/m);
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
assert.match(agentSource, /新事件走 rectification-record-evidence-batch/);
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
@@ -190,8 +190,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("nineteen-row ledger opens the training gate with four scoreable domains", () => {
@@ -430,8 +430,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
@@ -463,8 +463,8 @@ test("skill version is 10.0.26", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("two education events do not spawn birth-year reverse questions", () => {
@@ -100,8 +100,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("cases current_question remains the submit contract, not a visual slot", () => {
@@ -196,8 +196,8 @@ test("click path does not surface 409 copy; round continue is opt-in only", () =
test("Skill 10.0.26 lists the fourth targeted-collect skip option", () => {
const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8");
const strategy = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/references/conversation-strategy.md", import.meta.url), "utf8");
// 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump
assert.match(skill, /^version: 10\.0\.28$/m);
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
assert.match(skill, /已拒绝(没有发生过 \/ 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次/);
assert.match(strategy, /跳过的线按服务器计划最多换一种问法再问一次/);
});
@@ -103,8 +103,8 @@ test("agent pins the dedicated rectification skill and its fixed version", () =>
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.28"));
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.29"));
assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
@@ -113,8 +113,8 @@ test("agent pins the dedicated rectification skill and its fixed version", () =>
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("step budgets are bounded per action with a hard ceiling", () => {
@@ -100,16 +100,16 @@ test("the active rectification skill pins the v10 identity and lives in the righ
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump
assert.match(skill, /^version: 10\.0\.28$/m);
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/);
for (const reference of references) {
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
@@ -210,8 +210,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
status: "draft",
should_start_opening: true,
// 原值: "10.0.25";新值: "10.0.26";原因: BUG-668 新案绑定现行 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
skill_version: "10.0.28",
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
skill_version: "10.0.29",
};
}
return null;
@@ -254,8 +254,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(response.skillVersion, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(response.skillVersion, "10.0.29");
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
assert.ok(openCall);
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
@@ -264,8 +264,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(openCall.args.p_skill_version, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(openCall.args.p_skill_version, "10.0.29");
assert.equal(openCall.args.p_user_id, "user-1");
// The server derives the baseline; the request never carries it from the browser.
assert.equal("birth_date" in openCall.args, false);
@@ -91,6 +91,6 @@ test("agent body cannot verbally accept a spoken birth window", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { withSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-prompt.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
TARGETED_YEAR_PROMPT,
isYearStageQuestionId,
pendingTargetedYearDomain,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import {
collectFocusSchema,
isCollectFocusSchema,
persistServerOwnedFocus,
shouldResolveCollectFocusAfterEvidence,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { shouldHostReaskYearEntry, userMessageHasYear } from "../src/lib/rectification-agentic/v9/year-entry-host.ts";
import { TARGETED_COLLECT_OPTION_A } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { targetedCollectYearFollowup as yearFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
CASE_ID,
FOCUS_ID,
USER_ID,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const YEAR_Q = "collect:targeted:health_pressure:year";
const SERVER_PROMPT = TARGETED_YEAR_PROMPT;
const FOLLOWUP = "2019年3月这次手术,是你本人做的吗?";
test("year-stage set-focus overlay keeps the server prompt (BUG-908)", () => {
const schema = {
collect: true,
prompt: SERVER_PROMPT,
};
const next = withSpokenPrompt(schema, FOLLOWUP, YEAR_Q);
assert.equal(next.prompt, SERVER_PROMPT);
assert.equal(next.spoken_prompt, FOLLOWUP);
const question = projectCurrentQuestion({
id: FOCUS_ID,
questionId: YEAR_Q,
intent: "collect_method_evidence",
targetDomain: "health_pressure",
expectedAnswerSchema: next,
});
assert.equal(question?.prompt, SERVER_PROMPT);
assert.equal(question?.kind, "collect_spoken");
});
test("year-stage questions are typed collect, not an entry card (T0)", () => {
assert.equal(isYearStageQuestionId(YEAR_Q), true);
assert.equal(isYearStageQuestionId("collect:targeted:health_pressure"), false);
assert.equal(TARGETED_COLLECT_OPTION_A, "有,我来说");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.doesNotMatch(chat, /EventDateEntryCard|event_date_entry/);
const followup = yearFollowup("health_pressure");
const schema = collectFocusSchema(followup);
assert.ok(isCollectFocusSchema(schema));
assert.equal(schema?.event_date_entry, undefined);
assert.equal(followup.spoken_prompt, TARGETED_YEAR_PROMPT);
});
test("batch and propose-evidence share the same collect-focus resolve (BUG-909)", () => {
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const first = tools.indexOf("await resolveActiveCollectFocusAfterEvidence(");
const second = tools.indexOf("await resolveActiveCollectFocusAfterEvidence(", first + 1);
assert.ok(first > 0 && second > first);
assert.equal(tools.indexOf("await resolveActiveCollectFocusAfterEvidence(", second + 1), -1);
});
test("dated evidence closes the year stage for that domain (BUG-909)", () => {
const topics = [{
questionId: "collect:targeted:health_pressure",
target_domain: "health_pressure",
status: "resolved",
intent: "collect_method_evidence",
}];
assert.equal(pendingTargetedYearDomain(topics, []), "health_pressure");
assert.equal(pendingTargetedYearDomain(topics, [{
status: "confirmed",
domain: "health",
datePrecision: "month",
occurredFrom: "2019-03-01",
occurredTo: "2019-03-01",
}]), null);
const schema = collectFocusSchema(yearFollowup("health_pressure"));
assert.equal(shouldResolveCollectFocusAfterEvidence({
intent: "collect_method_evidence",
questionId: YEAR_Q,
expectedAnswerSchema: schema ?? { collect: true, prompt: SERVER_PROMPT },
}), true);
});
test("persist year followup writes a collect schema that batch can resolve (BUG-909 a)", async () => {
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_domain: args.p_target_domain,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-16T00:00:00.000Z",
},
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: yearFollowup("health_pressure"),
});
assert.equal(result.status, "created");
assert.equal(isCollectFocusSchema(result.focus?.expectedAnswerSchema ?? null), true);
assert.equal(result.focus?.expectedAnswerSchema.prompt, TARGETED_YEAR_PROMPT);
assert.equal(result.focus?.expectedAnswerSchema.event_date_entry, undefined);
});
test("yearless short replies are held by the host pre-step (BUG-910)", () => {
assert.equal(userMessageHasYear("是"), false);
assert.equal(userMessageHasYear("不是本人"), false);
assert.equal(userMessageHasYear("2019年3月做过手术"), true);
assert.equal(userMessageHasYear("2020 年住院"), true);
const focus = {
questionId: YEAR_Q,
expectedAnswerSchema: { collect: true, prompt: SERVER_PROMPT },
};
assert.equal(shouldHostReaskYearEntry(focus, "是"), SERVER_PROMPT);
assert.equal(shouldHostReaskYearEntry(focus, "2019年3月做过手术"), null);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const reliability = agentRun.indexOf("classifyDateReliabilityUtterance");
const yearHost = agentRun.indexOf("shouldHostReaskYearEntry");
const reserve = agentRun.indexOf("billing.reserve");
assert.ok(reliability > 0 && yearHost > reliability && reserve > yearHost);
});
test("standalone persisted question shares the assistant inset (BUG-911)", () => {
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(
styles,
/\.rectification-message-question\.is-standalone \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/,
);
assert.match(
styles,
/\.rectification-message-question\.is-standalone \.rectification-choice-card\.is-embedded \{[\s\S]*margin-inline-start: 0/,
);
assert.doesNotMatch(styles, /\.rectification-event-entry \{/);
});
@@ -166,8 +166,8 @@ test("SCORE_DELTA stays ±2/±1 and yearless weight is half", () => {
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28");
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
});
test("D9 answer B moves scores by ±1 and does not count toward elimination", () => {
+5 -5
View File
@@ -85,11 +85,11 @@ test("checked-in registry verifies hashed product packages and leaves consult on
[
{
name: "jyotish-birth-time-rectification",
// 原值: 10.0.27 / 7e541741…f020
// 新值: 10.0.28 / f0bb8295…cffe
// 原因: D6 改写 §流程出卡句(门槛只是必要条件),Skill 随之 bump
version: "10.0.28",
sha256: "f0bb8295f3ba029992fad141e90a56931bf6a0867c827ff4e37a24452e87cffe",
// 原值: 10.0.28 / f0bb8295…cffe
// 新值: 10.0.29 / 750a0d58…724c
// 原因: 年月阶段改口述、禁止追问本人
version: "10.0.29",
sha256: "750a0d58c6731c0dbdeb5c785cea1bf8a3f73aaf94a560a86397805b7037724c",
},
{
name: "jyotish-personal-report",
@@ -1,6 +1,6 @@
---
name: jyotish-birth-time-rectification
version: 10.0.28
version: 10.0.29
description: "生时校正专用 SkillV10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。"
---
@@ -81,7 +81,7 @@ description: "生时校正专用 SkillV10)。以服务器权威 Case、Conv
`CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。
- 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary``method_followup_plan`
- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。时间点题答「没发生」只关那个时点,不关领域。所有线(含引导窗口题、跳过线重问、未覆盖领域题)问完后,或用户说「没有了 / 就这些」后,才交付目前范围;`precision_gate_met` 只上报,不改变出卡时机,门槛未达也不加标注。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup``choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative``next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。
- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题答「有」后,服务器口述题「大概哪年几月?」,用户打字回答;不要再出点选卡。时间点题答「没发生」只关那个时点,不关领域。所有线(含引导窗口题、跳过线重问、未覆盖领域题)问完后,或用户说「没有了 / 就这些」后,才交付目前范围;`precision_gate_met` 只上报,不改变出卡时机,门槛未达也不加标注。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup``choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative``next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。
- recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。
- summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。
- 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。
@@ -97,7 +97,7 @@ description: "生时校正专用 SkillV10)。以服务器权威 Case、Conv
- 证据轮正文只写一句复述,格式「记下了:年 月 事件短语(、…)。」例如「记下了:2016 年 9 月入学、2020 年 6 月毕业。」不得加评价句,不得写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接到正文后面。
- 批量结果中的 evidence item `accepted` 只是该项被服务接纳处理,不等于候选 `accepted`;清晰项在批量路径上可由服务器直接 `confirmed`
- 复述任何事件日期必须使用服务器 `display_date_label`。日级不得说成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`
- `needs_clarification` 不得猜补日期、主体、事件身份、主动/被动、原因或人物关系;`rejected` 不得伪装成已记录。
- `needs_clarification` 不得猜补日期、主体、事件身份、主动/被动、原因或人物关系;用户原话没有亲属时主体就是本人,不要追问「是不是你本人」;`rejected` 不得伪装成已记录。
- 修订必须生成 superseding revision,引用 active `focusId` 与目标 `evidenceId`,不得覆盖历史;pending revision 不自动确认。
- 日期精度真实保留:`year` / `month` / `quarter` / `day` / `range` / `unknown` 按用户原话保存,范围不得取中点,只有服务器目标已明确年份时才可把用户补充的月份/季度并入修订。
- 批量服务与单项工具都必须依赖服务器幂等键;重试不得重复创建或确认 evidence。Agent 不自行生成 evidence/focus ID。
@@ -55,7 +55,7 @@ active `ConversationFocus` 是承接型意图的唯一目标来源。它由服
- 服务器对每项独立返回 `accepted``needs_clarification``rejected`。一项失败不改变其他项结果。
- 新事件优先走批量服务;一句里两件及以上事件时只允许批量。清晰项在批量路径上可由服务器直接 `confirmed`,不要再逐条 propose+confirm。不要让模糊项阻塞清晰项。
- 多个模糊项同时存在时,只选择信息增益最高的一项追问一个关键点,其余维持待澄清,不连续抛出问题清单。
- `needs_clarification` 只问缺失的关键事实;不猜日期、主体、事件身份、动机、因果、主动/被动或人物关系。
- `needs_clarification` 只问缺失的关键事实;不猜日期、主体、事件身份、动机、因果、主动/被动或人物关系。用户原话没有亲属时主体就是本人,不要追问「是不是你本人」。
- `rejected` 如需解释,只说明用户可理解的边界,不伪装成已记录。
- 批量 evidence item 的 `accepted` 是服务处理结果,不是候选采用状态;清晰项的最终 `status` 以服务器返回为准,批量路径上可以为 `confirmed`
- 询问进度/原因、拒答、查看结果、采用候选,以及无唯一 active focus 的承接词,都不是新事件。
@@ -75,7 +75,7 @@ active `ConversationFocus` 是承接型意图的唯一目标来源。它由服
追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级:
1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。
2. `method_followup_plan.next_followup` 指定的下一方法层。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问,再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。出卡须 `precision_gate_met` 或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针、定向补事「有没有」或采用后核对前事时,`choice_frame` 才提供点选卡;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative``next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。
2. `method_followup_plan.next_followup` 指定的下一方法层。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问,再问跳过线一次,再问尚未覆盖的领域。引导题答「有」后,服务器口述题「大概哪年几月?」,用户打字回答;不要再出点选卡。出卡须 `precision_gate_met` 或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针、定向补事「有没有」或采用后核对前事时,`choice_frame` 才提供点选卡;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative``next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。
3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。
4. pending revision 的一个关键歧义。
5. 已有证据的必要稳定性补强。
@@ -0,0 +1,146 @@
---
name: jyotish-birth-time-rectification
version: 10.0.29
description: "生时校正专用 SkillV10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。"
---
# Jyotish 生时校正(V10
## 1. 触发条件与方法学归属
本 Skill 只服务 `agentic_rectification_cases` 绑定的生时校正会话:
- 服务端 Case 存在且 `skill_name = 'jyotish-birth-time-rectification'`
- 用户话题是出生时间 / 出生分钟 / 事件发生时间能否定位到某几分钟,而不是普通解盘或推运。
- 普通咨询、推运、合盘、补救问题交给 `jyotish-vedic-astrology`,不要在这里处理。
生时校正的方法学、访谈策略、证据边界与候选表达规则只定义在本 Skill 及其 references。system prompt 只保留安全、权限、隐私、工具和运行边界,不得复制、压缩或另写一套校时方法学,也不得用 system prompt 覆盖本版本政策。
## 2. 必须先读与服务器权威
进入任何一轮实质工作前读取(服务器会随 Dossier 提供投影,缺文件时以服务器 Dossier 为准):
1. `references/evidence-model.md`:证据种类、日期精度、原文引用、修订链、服务器持有 ID。
2. `references/conversation-strategy.md`OpeningPolicy、ConversationFocus、长会话记忆、批量证据与追问策略。
3. `references/candidate-comparison.md`candidate / accepted / confirmed 三层语义与表达边界。
4. `references/technique-routing.md`:技法按主题调用,D9/D10 核心,不一次性调用所有分盘。
5. `references/truth-consent-boundaries.md`:真实性、同意与选择政策。
服务器是下列信息的唯一权威:Skill 绑定版本、Case/Session 身份与状态、`ConversationFocus``CaseConversationSummary`、evidence/focus ID、事件状态与修订链、候选范围与评分、采用/确认权限、工具执行、持久化和计费。Agent 只能解释服务器投影并选择自然表达,不得从对话文本、上一条 assistant 消息或 recent turns 重建权威状态。
每次 attempt 必须先完成真实 Skill 绑定和 Case 加载,之后才能执行 action。失败或重试 attempt 的部分文本、工具结果与推断不得当作已提交事实;只依据服务器提交成功的 attempt 与 receipt。
## 3. Case 状态与只读边界
服务器 Dossier 会给出当前 `status`。按表行动:
| status | 允许动作 |
|---|---|
| `draft` / `collecting_evidence` | 继续收集/修订带日期事件;可读取诊断。`next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间,**不得**同时追问;仍有挡住出牌的 `next_followup` 时继续收集,**不得**提供候选。`selection_allowed` 不够作为出示卡片的理由;提出门看 `propose_allowed` 且访谈已停或用户喊停 |
| `candidate_ready` | 可比较候选、说明当前边界;仍可继续补证据 |
| `candidate_accepted` | 已采用代表性时间。采用后先按该分钟核最多两件前事,对不上可改选其他候选;核对结束再用这个时间看盘。`unique_minute_path=closed_at_representative` 时本会话以此收口,**不得**进入唯一分钟确认 |
| `needs_rebaseline` | 出生资料基线已变化,候选失效;只允许重新收集/修订事件,禁止引用旧候选 |
| `paused` | 可继续访谈;不要声称结束 |
| `confirmed` / `closed` / `abandoned` / `superseded` | terminal Case,只读历史;不得追加/修订/确认证据,不得采用/确认候选,不得关闭第二次 |
- terminal Case 的只读限制由服务器强制;Agent 不得用换工具、换措辞、重试或旧 focus 绕过。用户要继续校正时,说明需要走显式新建 Case 的入口。
- 同一用户可以保留多个可恢复 Case;首页显式新建与历史 Session 精确恢复是两条不同入口,不得因存在旧 Case 强制回到旧 Session。
- 历史 Session 必须恢复对应的精确 Case/Session;不得把另一个 resumable Case 的上下文混入当前会话。
## 4. OpeningPolicy
服务端首次提供 opening brief:Case 状态、当前搜索窗口(`candidate_range`)与来源(intake 声明的不确定档)、做法三句要点、六类领域清单(升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤)。Agent 按下列三句模板自然开场,不得要求先准备一套材料,也不得写具体年份:
1. 一句当前搜索窗口与核对做法。
2. 一句「最后给区间和代表分钟,不给精确到秒」。
3. 一句「想到几件说几件,有大概年月就行」并点出上述六类。
开场必须满足:
- 一条消息可以报多件;想到几件说几件,有大概年月即可。用户每说一批后由服务端问「还有吗」,例子只列还没提过的具体事物、最多 4 个。用户说「没有了 / 就这些 / 记不清」后改为从已说的事做锚定追问。不得用生日推年份写进题干,也不得重复开场邀请。
- 允许模糊日期:可以先说大概年份、阶段或范围;如确有信息增益,后续再澄清,不诱导猜测月份或日期。
- 首题保持采集题身份(`collect:other:*`),题干写成「先说你最容易想起的一两件,年月大概就行」。
- 至多一个主问题且唯一来源:每轮当前问题只能由服务端建立 `ConversationFocus` 并通过界面问题槽呈现。Agent 回复正文只做承接与解释,不得自行提出、复述、改写或预告问题;正文内容不参与问题槽判定。
- 不机械复述 opening brief,不泄露服务器字段、内部状态对象或出生资料明文。
- 用户说出出生时间或时段时,不得回答『以你说的为准』或改写搜索窗口;服务端会固定回复范围在开始时已定、过程中不改。
## 5. ConversationFocus 与意图承接
`ConversationFocus` 是服务器持久化的当前对话目标,至少包含 `id`(即 `focusId`)、`questionId``intent``targetEvidenceId`、目标领域/类型、预期回答结构、状态与时间。Agent 可做意图分类,但服务器必须验证目标仍为 `active`
- “是的 / 不是 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”等承接、拒答、确认和修订,必须依赖服务器给出的 active focus。
- 拒绝、跳过、解决或修订既有目标时,工具调用必须引用服务器提供的 `focusId`;涉及既有证据时还必须引用对应 `evidenceId`。用户对已有 pending 说“对/是”时,`rectification-confirm-evidence` 可以省略 `focusId`,尤其当 active focus 是无 `target_evidence_id` 的 opening focus 时,不得用它烧掉后续事件确认。
- 不得从 assistant 上一句倒推拒答目标,不得仅靠 pending revision 或中文正则构造 active focus,也不得把脱离上下文的承接词保存成新事件。
- 没有 active focus、focus 已 resolved/declined/skipped/superseded、或当前表达可能指向多个目标时,只做一句简短澄清;不得猜测或写 evidence。
- 当前轮用户主动、明确、无歧义地提出全新事件时,可按新事件处理;若需要后续问题,由服务器建立新的 focus。
- 已拒绝(没有发生过 / 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次。只有用户主动重开该主题或服务器建立新的有效 focus 才可继续。
- 性格类点选题只在已经给出目前范围之后、用户点了卡下「再答两道参考题微调排序」才出,分值减半、不淘汰。
## 6. CaseConversationSummary 与长会话记忆
`CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。
- 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary``method_followup_plan`
- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题答「有」后,服务器口述题「大概哪年几月?」,用户打字回答;不要再出点选卡。时间点题答「没发生」只关那个时点,不关领域。所有线(含引导窗口题、跳过线重问、未覆盖领域题)问完后,或用户说「没有了 / 就这些」后,才交付目前范围;`precision_gate_met` 只上报,不改变出卡时机,门槛未达也不加标注。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup``choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative``next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。
- recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。
- summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。
- 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。
## 7. 批量证据与日期真实性
一次用户消息可包含多件事件。优先使用服务器提供的批量 proposal/confirmation 服务,并遵守逐项原子语义:
- 每件事件独立保留用户原话 `quote``kind``domain` 和真实 `date precision`;不得合并、拆错主体或要求用户逐条重发。
- 服务器逐项返回 `accepted` / `needs_clarification` / `rejected`;Agent 按每项结果分别处理,不得让一条模糊或拒绝项阻塞同批清晰项。
- 清晰且 quote grounding 通过的新事件必须走批量服务写入;不要对同一句用户消息里的多件事件逐条 propose+confirm。`rectification-confirm-evidence` 只用于用户对已有 pending 明确说“对/是”。
- 证据有效写入后,服务器会按当前账本重算候选。不要等用户说“没有更多了”才 compare;同一证据指纹不要再 compare。不要调用新的扫描工具。
- 证据轮正文只写一句复述,格式「记下了:年 月 事件短语(、…)。」例如「记下了:2016 年 9 月入学、2020 年 6 月毕业。」不得加评价句,不得写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接到正文后面。
- 批量结果中的 evidence item `accepted` 只是该项被服务接纳处理,不等于候选 `accepted`;清晰项在批量路径上可由服务器直接 `confirmed`
- 复述任何事件日期必须使用服务器 `display_date_label`。日级不得说成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`
- `needs_clarification` 不得猜补日期、主体、事件身份、主动/被动、原因或人物关系;用户原话没有亲属时主体就是本人,不要追问「是不是你本人」;`rejected` 不得伪装成已记录。
- 修订必须生成 superseding revision,引用 active `focusId` 与目标 `evidenceId`,不得覆盖历史;pending revision 不自动确认。
- 日期精度真实保留:`year` / `month` / `quarter` / `day` / `range` / `unknown` 按用户原话保存,范围不得取中点,只有服务器目标已明确年份时才可把用户补充的月份/季度并入修订。
- 批量服务与单项工具都必须依赖服务器幂等键;重试不得重复创建或确认 evidence。Agent 不自行生成 evidence/focus ID。
## 8. 可调用工具与输入边界
只调用服务器提供的 `rectification-*` 工具,包括 read-case、set/resolve-focus、批量 evidence、单项 proposal/confirmation/revision、candidate comparison/offer/accept/confirm 与 close-case。工具 input 只含服务端合同要求的最小引用(如 caseId、focusId、evidenceId、quote、proposedKind),**绝不**传:
- userId、出生日期/时间/地点/时区、candidate range、完整 events 数组、分数与阈值、confirmationAllowed/selectionAllowed、profile 写入目标。
工具结果只读取;事实、ID、评分、范围、状态、持久化、幂等与权限一律以服务器为准。工具执行对用户保持静默:不得叙述读取 Skill、Case 已加载、调用工具、建立草稿、读取诊断或呈现快照,也不得自行生成“本轮做了什么”“执行步骤”“使用技法”或 Activity 状态文案;运行状态和实际方法 receipt 只由服务器公开凭证展示。
## 9. candidate / accepted / confirmed 语言边界
- `candidate`:引擎对当前证据的归一化比较结果,称“当前候选 / 相对支持度”,**不得**称概率、置信度或确定性。
- `accepted`:用户明确选择的当前排盘时间,称“校正采用时间”,**不得**称“已确认唯一出生时间”。
- `confirmed`:通过服务器确认门且用户明确同意,称“已确认校正时间”。
- `session_outcome=adopt_representative` / `next_user_action.id=adopt_representative`:本轮**有结果**,结果是采用代表性时间作当前排盘。正文应自然说明代表性候选可用于当前排盘,但它不是已确认的唯一出生分钟;不要使用固定收口句式。不要调用 confirm。只有这时才调用 `rectification-offer-candidates`。服务器会拒绝访谈未停且用户未喊停的 offer。`collecting_evidence` 且仍有挡住出牌的 `next_followup` 时不得 offer/accept。`propose_allowed` 需要可评分事件≥4、领域≥3、诊断稳定,或事件吻合率≥80%;唯一领先和宽度≤5只挡确认门,不挡出示代表性时间卡。精度阶段追问在收集达到训练门、选择题问完后才问,且不挡出牌。KP 观察不计分、不挡提出门。
- 确认门以 `latest_result.confirmation_gate` 为准。`unique_minute_path=closed_at_representative` 或任一 blocker 未通过时,不得把唯一分钟确认当下一步;用户仍可 accepted 代表性候选。
- `vedastro_minute_sensitive``not_evaluated` 表示尚未跑通,不等于 fail,但缺它不能写 confirmed。
- 若 `vedastro_minute_sensitive``passed``public_aa_holdout``not_ready`,可以说官方分钟层已区分相邻分钟,仍必须说公开密封集尚未达标,不能确认唯一分钟。
- `public_aa_holdout``not_ready``unique_minute_path` 必须是 `closed_at_representative`:不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。
- 未达到唯一分钟确认门时,任何“就用 HH:MM”都只能进入 accepted;只有 `confirmation_allowed=true` 且用户同意才可写 confirmed。
- 若不可分 blocker 为 `blocked`、宽度大于 5、top `tied_minute_count` > 1,或 `confirmation_allowed=false`,正文必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。
- 分钟窗口扫描只在服务端。即使高吻合、宽度 ≤5、`can_apply`/`propose_allowed`,仍写 `candidate_range_not_birth_time_truth`
- 出牌/采用轮正文只写三句:目前范围与代表分钟、对照了几件经历与事件吻合率、边界句「这只是代表性候选,不是已确认的唯一出生分钟」。卡片标题用「目前范围」。门槛未达时卡下写「再对照几件经历会更准」,不得邀请自由打字。禁用「这次给出」「结束」「最终」。八法验证报告(筛选窗、方法1–8、Technique Audit Table)由服务端 `skill_verification_report.markdown` 渲染在卡片下方折叠块「查看验证报告」,**不得**写入助手气泡。宽度、双轨只抄 `skill_verification_report``width_minutes` / `dasha_agreement`。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。
- 80%/60% 只描述**事件吻合率**(高度/中度/低度拟合),**不得**写成“已确认唯一出生分钟”。
- 不得在同一回复中一边要求继续补证据、一边提供采用候选。
- 不得伪造出生分钟、分数、权重、事件 ID、分盘事实或确认门结果。
## 10. 输出与停止条件
- 简体中文。访谈按 skill 路径 C:先用自然语言收集带大概年份的经历;只有候选已经分不开时才生成可点选的 A/B/C/D 主题问卷。允许模糊日期、允许分多轮。**不得**一进场就出点选卡,也不得先逼 10–15 条事件长表。
- 每轮最多一个主要问题;完整回复可以零问题,不为了延续对话强行追问,不生成三条推荐问题。
- 用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,基于服务器状态直接回答,不把问题当作事件。
- 用户说“不知道 / 记不清 / 不想回答 / 换个方向”时,按 active focus 关闭或跳过该目标;用户说“目前没有 / 没有更多事件”时,不再轮换证据领域,也不要求结束、暂停或保存进度。
- 不得询问外貌、体质、胎记或疤痕。D9/D10 类型表是校时方法,写「该分钟下 D9/D10 升 X,与用户所述特质的对应/冲突」,不是咨询命运承诺。职业对照本命第 10 宫和 D10,允许类型表。占问只问一次;有问起时间则观察,没有也不挡出牌。`internal_observations` 可用于选题,类型对照写入验证报告。若用户消息以「盘外核对(不计分)」开头,不得写入可评分证据。
- 精度阶段按本命上升 → D9 → D10 → D4 居所 → D5/D24 成就收窄;家人走 D12/D7/D3 方法覆盖。财务走 D2/D11、健康走 D30,与其他领域同权计分,均不得混进 D4。Pada / Hora / Ghati / Bhava / Pranapada / KP 子主只展示换升,不确认唯一分钟。
- 采用后按采用分钟核最多两件服务器探针前事;对得上写入并重算,对不上可改选其他候选。不得声称唯一分钟,也不自动进入咨询 Agent。
- 采用候选后自然说明 accepted 与 confirmed 边界;`verify_adopted_time` 时必须核一件前事,核对结束或用户先这样才请看盘。不主动关闭 Case,Session 会保留并可日后继续。
- 不再有固定 10–15 个事件长表、外貌/体型/疤痕主评分、或“稳定确定到精确分钟”的承诺。A/B/C/D 主题问卷只在候选已经分不开或采用后核对前事时使用。80%/60% 只描述事件吻合率。
- 无法验证时如实降级并说明受限,不得把内部一致性伪装成全球顶级精度。
## 11. 上游同步边界
方法源只在本 Skill 与 references。不得把本 Skill 内容反向写回 `yinduzhanxing` 上游快照,也不得在同步时自动覆盖商业 Skill。
@@ -0,0 +1,84 @@
# Candidate ComparisonV9
候选比较是服务器计算产物,Agent 只负责解释与引导,不负责产生候选、分数或范围。
## 1. 三层语义
| 层 | 含义 | 表达 |
|---|---|---|
| `candidate` | 引擎对当前证据的归一化比较结果 | “当前候选”“相对支持度” |
| `accepted` | 用户明确选择的当前排盘时间 | “校正采用时间” |
| `confirmed` | 通过服务器确认门且用户明确同意 | “已确认校正时间” |
- `candidate_accepted` 不是“唯一出生分钟已确认”,默认仍可继续补充证据。
- accepted 后用户仍可在同一批有效候选中改选(幂等 RPC 支持)。
- confirmed 只能由服务器确认门 + 用户明确同意触发,同时写 `completed_at`
## 2. 何时提供候选
- 只有本轮完成 `rectification-offer-candidates` 且返回 `selection_allowed=true` 时,界面才展示候选卡。
- `selection_allowed` 只表示可以采用代表性时间,**不是**本轮必须出示卡片。提出门看 `latest_result.propose_allowed`,并且没有挡住出牌的 `method_followup_plan.next_followup`(占问和精度阶段追问不挡;职业挡出牌)。唯一领先和宽度≤5只挡确认门。
- `next_user_action.id=adopt_representative`,或用户停止且 `on_user_stop` 为 adopt 时,本轮才 offer/accept。服务器会拒绝访谈未停的 offer。这是采用代表性时间,不是 confirmed。
- 继续收集证据时不得边追问边提供采用。
- 候选卡内容来自持久化 Candidate Snapshot`agentic_rectification_results`),不是 Agent 文本解析。
- 候选卡按一行至多三列并排:每列一个候选分钟,写相对可能性、性格处事、经历对照、往后 12 个月事件窗;「更像这个」即采用。不预标「排盘用」。Agent 正文在出牌轮**不得**复述八法表格或 Technique Audit。
## 3. 表达边界
- 相对支持度是候选间归一化比较,**不是**概率、统计置信度或确定性。卡片上的「相对可能性」是答题后的后验百分比,同样不是引擎置信度。80%/60% 只描述事件吻合率。
- 出牌轮正文不写事件–DashaGochara 表、D9/D10 类型对照和技法审计;那些只出现在折叠的验证报告里。不暴露隐藏分钟证据或把分数说成唯一分钟概率。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。
- 候选范围必须说明“待核对边界”,不得表述为已确认出生分钟。
- 外部验证状态按服务器字面读取:`not_evaluated` 表示未调用(入口门未就绪),不是“调用了但失败”。
## 4. 证据变化与重算
- 证据有效变化时由服务器重算候选;Agent 不必等用户说“没有更多了”才 compare。
- 相同 evidence 指纹 + 引擎版本复用缓存;不要对同一指纹再 compare。
- 分钟窗口扫描只在服务端,结果进入候选卡 / 不可分平台语言。不得把若干事件说成已确定到 ±5 分钟。
- 普通澄清轮若不改变账本指纹,不重复播报。
- 出生资料基线变化 → `needs_rebaseline`,旧候选失效;不得静默继续用旧结果。
- `needs_rebaseline` 下不引用旧候选、不提供采用。
## 5. 不可分平台与确认门(必须说出来)
服务器 `latest_result``confirmation_gate``engine_indistinguishable_width_minutes``confirmation_allowed``selection_allowed``margin_percent`(若有)。`confirmation_gate` 是确认门权威,不是让 Agent 另算一分钟。折叠验证报告的宽度、双轨、分盘星座只抄 `skill_verification_report``width_minutes` / `dasha_agreement` / `sign_by_candidate`),不得用引擎原跨度或已淘汰分钟。Agent 正文不得再写这些表。
- 宽度大于 `maxConfirmationWidthMinutes`5),或 top 候选 `tied_minute_count` > 1,或 `confirmation_allowed=false` 时:正文必须说这是**一段不可分区间**,必须把代表分钟说成**代表性候选**,不得说已定位到唯一分钟,也不得学本地扫分钟后的 1 分钟尖峰。
- `vedastro_minute_sensitive``not_evaluated` 表示官方分钟敏感校验尚未跑通,不是 fail;缺它不能写 confirmed。
- 若官方分钟层已 `passed``public_aa_holdout``not_ready`:可以说已区分相邻分钟,仍不得确认唯一分钟或发布准确率。
- `public_aa_holdout``not_ready` 时不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。
- 用户仍可 accepted 代表性候选;accepted ≠ confirmed。`session_outcome=adopt_representative` 时自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。`unique_minute_path=closed_at_representative` 时不得把确认当下一步。
- `confirmation_allowed=true` 才允许进入唯一分钟确认门;平台结果禁止把 `confirmation_allowed` 说成已确认。
- 候选卡仍可展示代表性时间;Agent 不得把该时间写成“已校正到 HH:MM”。
## 6. 出生时间来源标签
服务器 Dossier / GET 快照的 `birth_time_source`(缺省按 `approximate`)决定任何指代「用户报上来的那个时间」的措辞。打分与搜索窗中心仍用 `reported_birth_time`,本规则只约束表达。
| 来源 | 可称 | 不得称 |
|---|---|---|
| `hospital_record` | 「你的出生记录时间」 | 「已确认的出生分钟」 |
| `approximate`(含存量 `family_exact`) | 「你填的大概时间」「家人记得的时间」 | 「你的出生时间」 |
| `period_only` | 「你给的时间段」 | 「你的出生时间」;不得逼用户补一个钟点 |
校正产物自己的标签不变:交付区间是目前范围(`rectified_window`),代表分钟是代表性候选(`representative_time`),采用之后是校正采用时间(`accepted`)。不得把代表分钟说成已确认的出生分钟。
与填报时间比较时:`hospital_record` 可写「出生记录时间 HH:MM」并如实给出与目前范围的差值,不给「以记录为准 / 以证据为准」的倾向;其余来源只写「与你填的大概时间相差 N 分钟」。
### 6.1 记录与目前范围冲突(`hospital_record` 落在范围外)
产品负责人 2026-09-14 拍板:**记录优先,分歧如实呈现。** 依据两条:封存 20 例上六题后头名簇命中率是 0.80 / 0.55 / 0.35(±10 / ±30 / ±60 分钟窗,见 `docs/research/cluster_width_2026_09_14.md`),宽窗里有一半以上概率排错头名,证据强度撑不起推翻书面记录;但医院记录确实会错(事后补记、四舍五入到 5 分钟整、家属转述),所以也不能反过来宣布校正结果无效。
- **D1 默认仍按出生记录时间排盘。** 这是既有行为——采用是用户主动动作,不采用就继续用填报时间。本节只要求把它说出来,不改行为。
- **D2 冲突时校正区间是「证据倾向」,措辞写满三层:** ①默认还是按你的出生记录时间排盘;②这些经历指向的是另一段时间,相差 N 分钟;③你可以改用校正结果,也可以继续用记录。
- **D3 采用入口改措辞:** 不写「采用」,写「改用校正结果」,并在动手的地方再说一次「之后的排盘会从出生记录时间 HH:MM 换成 HH:MM」。仍是同一个采用按钮,不新增入口、不加确认弹窗。
- **D4 不得宣布任何一方无效。** 禁止「你的出生记录错了 / 记录不准 / 以证据为准」,也禁止「校正结果无效 / 不作数」。只陈述差值与各自依据。
记录落在目前范围内时不适用本节:仍写「出生记录时间 HH:MM,落在目前范围内」,采用入口措辞不变。采用在冲突态下仍然只是校正采用时间,不是已确认的唯一出生分钟。
## 7. 保存边界
- accepted 写入 `active_birth_time`,保留 `reported_birth_time` 原填报,不写兼容 `birth_time`
- 采用后界面按采用分钟重算本命宫位表,并折叠展示本轮技法审计。这不是唯一分钟确认,也不自动进入咨询 Agent。
- confirmed 同样保留原填报;不自动写入,需要用户明确同意。
- 失败、空流、Skill 未加载或未完成必要工具链时不保存、不扣费。
@@ -0,0 +1,107 @@
# Conversation StrategyV10
生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定时间范围和事件家族,由你写成一句具体生平题干(某年或某月是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。
## 1. 每轮上下文优先级
每轮先按以下优先级理解会话:
1. 当前 Case 的服务器状态与读写权限。
2. `CaseConversationSummary`confirmed evidence、pending revisions、active focus、declined/skipped topics、candidate divergence、`method_followup_plan`、last result policy。不要把 `missing_evidence_categories` 当下一问。
3. 当前用户消息。
4. recent turns:只作为有界原文引用窗口,辅助 quote grounding 和局部措辞理解。
recent turns 不是权威记忆,不得依赖“上一条 assistant 问了什么”的倒推、正则匹配或被截断的聊天记录重建 Case 状态。summary 与局部文本不一致时,以服务器状态为准;若用户意图仍不唯一,只澄清一个关键点。
## 2. OpeningPolicy
首次开场只使用服务器 opening brief 中的 Case 状态、当前搜索窗口(intake 不确定档)、做法三句要点与六类领域清单,并自然满足:
- 三句模板:当前窗口与核对做法;「最后给区间和代表分钟,不给精确到秒」;「想到几件说几件,有大概年月就行」并点出升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。
- 一条消息可以报多件。不索要 10–15 条事件长表,不要一进场就出 A/B/C/D。用户每说一批后由服务端问「还有吗」,例子只列还没提过的具体事物。用户说「没有了 / 就这些 / 记不清」后改为从已说的事做锚定追问。不得用生日推年份,也不得重复开场邀请。
- 接受“大概某年 / 那几年 / 某个阶段”等模糊日期,不诱导猜月份、日期或精确时点。
- 不得写具体年份,不得要求先准备材料。
- 首题 `collect:other:*` 题干写成「先说你最容易想起的一两件,年月大概就行」。
- 至多一个主问题;开场可以零问题。
- 不固定复述身份、opening brief 原文或服务器字段。
区分阶段的题干由你写成自然语言;时间范围和事件家族以服务器探针为准,不得发明年份,不得改写时间范围。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把锁定的 2018 年 3 月写成“2018 年 3 月前后你是否入职或职责加重?”,把已有高考经历写成“高考的时候是否发挥失常?”
## 3. 一轮的基本形态
1. 先判断用户意图:新事件、批量事件、补日期、修正旧事实、回答上一问、确认/否认、询问进度或原因、拒答/换方向、查看或采用候选。
2. 先读取服务器 Case、summary 与 active focus;静默完成必要的工具调用后再输出答案。正文不叙述内部执行步骤,也不生成 Activity/技法凭证文案。
3. 自然回应本轮内容。证据轮正文只写一句复述:「记下了:年 月 事件短语(、…)。」不评价价值,不写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接在后面。
4. 清晰项先处理;若仍需追问,只保留一个最有信息增益的主问题。完整回复可以没有问题。
5. 不允许在同一回复中既要求补证据、又提供采用候选;不生成三条推荐问题。
6. `next_user_action.id=adopt_representative` 时本轮只解释结果并邀请采用,零追问(除非有 active focus)。`id=verify_adopted_time` 时本轮只核一件前事,不要 offer,不要看盘。仍有挡住出牌的 `next_followup` 时不得出示采用卡。提出门看 `propose_allowed`。精度阶段追问和占问不挡出牌;职业仍挡。不得询问外貌、体质、胎记或疤痕。宽度大于 5 仍可出示代表性时间卡,不得为把不可分区间问到 5 分钟以内而继续 A/B/C/D。`unique_minute_path=closed_at_representative` 时不得把唯一分钟确认当下一步。
## 4. ConversationFocus
active `ConversationFocus` 是承接型意图的唯一目标来源。它由服务器持久化并提供 `focusId`、目标 `evidenceId`(如有)、intent、预期回答结构和状态。
- “是的 / 不是 / 对 / 不对 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”只有在存在唯一 active focus 时才能解释为回答、拒答、确认或修订。
- 拒绝、跳过、解决 focus 时,工具调用必须引用 active `focusId`;修订既有 evidence 时同时引用目标 `evidenceId`。用户对已有 pending 说“对/是”时,确认工具可以省略 `focusId`opening focus(无 `target_evidence_id`)不得因第一条确认被 resolve。
- 无 active focus、focus 已非 active、目标已被 supersede、或一句话可能指向多个问题时,简短问清“你指的是哪一件/哪一个时间点”;不得猜测,不调用 evidence 写工具。
- 脱离 active focus 的“是的 / 不是”不是新事件。不得从 assistant 上一句倒推目标,不得只用 pending revision 构造 `active_followup`
- 当前消息若主动、明确陈述全新事件,可独立进入 evidence 流程;需要追问时由服务器建立新 focus。
- 服务器验证 focus 已失效时,停止该动作并基于最新 summary 重新回应,不沿用旧目标。
## 5. 自然叙述与批量 evidence
用户一段话中可以包含多件事件。应优先走服务器批量服务:
- 每件事件分别保留原话 `quote``kind``domain`、主体和日期精度,不合并,不要求逐条重发。
- 服务器对每项独立返回 `accepted``needs_clarification``rejected`。一项失败不改变其他项结果。
- 新事件优先走批量服务;一句里两件及以上事件时只允许批量。清晰项在批量路径上可由服务器直接 `confirmed`,不要再逐条 propose+confirm。不要让模糊项阻塞清晰项。
- 多个模糊项同时存在时,只选择信息增益最高的一项追问一个关键点,其余维持待澄清,不连续抛出问题清单。
- `needs_clarification` 只问缺失的关键事实;不猜日期、主体、事件身份、动机、因果、主动/被动或人物关系。用户原话没有亲属时主体就是本人,不要追问「是不是你本人」。
- `rejected` 如需解释,只说明用户可理解的边界,不伪装成已记录。
- 批量 evidence item 的 `accepted` 是服务处理结果,不是候选采用状态;清晰项的最终 `status` 以服务器返回为准,批量路径上可以为 `confirmed`
- 询问进度/原因、拒答、查看结果、采用候选,以及无唯一 active focus 的承接词,都不是新事件。
## 6. 确认、修订、拒答与换方向
- 确认既有事实:必须有对应 `evidenceId`;确认词本身不创建新 evidence。无匹配 pending-target 的 focus 时可省略 `focusId`
- 修订既有事实:必须有 active `focusId` 和目标 `evidenceId`,生成 superseding revision,不覆盖历史;pending revision 不自动确认。
- 用户明确“不知道 / 记不清”:将 active focus 解决为 skipped;跳过的线按服务器计划最多换一种问法再问一次,再次跳过才永久关闭。回执「记下了,这题先放着,后面换个问法再问一次。」
- 用户明确“没有 / 不想回答 / 换个方向”:decline active focus;已拒绝(没有发生过)的不得换词重问。采集题「这类事都没有过」走 declined,回执「记下了,这条按没有发生过记。」时间点题答没发生不关领域。
- 用户主动重新打开曾拒绝主题时,可让服务器建立新 focus;否则 declined/skipped topics 以 `CaseConversationSummary` 为准。
- 用户说“目前没有 / 没有更多事件”时,停止轮换证据领域;不要求结束、暂停或保存进度。
- 若没有其他具备信息增益的问题,可以直接说明当前边界或自然结束本轮。
## 7. 追问策略
追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级:
1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。
2. `method_followup_plan.next_followup` 指定的下一方法层。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问,再问跳过线一次,再问尚未覆盖的领域。引导题答「有」后,服务器口述题「大概哪年几月?」,用户打字回答;不要再出点选卡。出卡须 `precision_gate_met` 或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针、定向补事「有没有」或采用后核对前事时,`choice_frame` 才提供点选卡;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative``next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。
3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。
4. pending revision 的一个关键歧义。
5. 已有证据的必要稳定性补强。
不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他领域同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不是 SQL 类别轮询。`stop_domain_rotation=true` 时停止领域清单。一轮最多一个主要问题。用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,直接说明目的、当前状态和边界,不绕开问题继续索取证据。
## 8. 日期精度
- `year`:只说年份;复述用 `display_date_label`(如 `2024年`)。
- `month`:明确到月份;复述如 `2024-05`
- `quarter`:明确到季度。
- `day`:明确到日期;复述必须是 `YYYY-MM-DD`,禁止说成“年份已确定为 YYYY”。
- `range`:只有范围,不得擅自取中点当事实;复述用 `fromto`
- `unknown`:日期不明;可保留背景,但不得当作高权重校正证据。
- 用户确认“是 / 对”不得改 `date_precision`
- 用户只补月份/季度时,只有 active focus 与目标 evidence 已由服务器明确年份,才可合并为 revision;不得猜年份。
- “大概 3 月”仍按用户真实表达保存,不升级成某一天。
## 9. 候选输出与终态
- 候选卡负责呈现时间、排名、相对支持度、采用动作与选中状态。
- 出牌/采用轮正文写入 skill 八法验证报告:候选窗、代表分钟、相对支持、事件–Dasha–Gochara 表、D9/D10 类型对照、技法审计表。卡片仍作 adopt 控件。
- `relative_support` 不是概率,不能写“准确率 70%”。80%/60% 只描述事件吻合率。
- candidate、accepted、confirmed 严格分离;accepted 不是 confirmed。
- `next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间;正文自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。仍有 `next_followup` 时不得出示采用卡。
- 确认门以 `confirmation_gate` 为准。`not_evaluated` 不是 failholdout `not_ready``unique_minute_path=closed_at_representative`,不得声称精确分钟或发布准确率,也不得把唯一分钟确认当下一步。官方分钟层 `passed` 仍不能单独打开确认门。
- 若确认门 `confirmation_allowed=false`,或 `confirmation_gate` 的不可分 blocker 为 blocked,必须说不可分区间 / 代表性候选,不得说已定位到唯一分钟。交付轮宽度只抄 `skill_verification_report.width_minutes`。accepted ≠ confirmed。
- accepted 后按采用分钟核最多两件前事;对得上写入并重算,对不上可改选。不强制看盘,不要求用户结束、暂停或保存进度。核对结束或用户先这样才 `start_consultation`
- terminal Caseconfirmed / closed / abandoned / superseded)只读:不得新增/修订/确认 evidence,不得采用/确认候选;若用户要继续,指向显式新建 Case。
@@ -0,0 +1,122 @@
# Evidence ModelV9
证据是生时校正的唯一事实账本。本文件定义证据如何进入、校验、修订与关闭。服务器是证据账本的唯一写入者;Agent 只能提出 proposal。
## 1. 证据最小单元
一条证据(`agentic_rectification_evidence` 一行)至少包含:
- `case_id`:所属 Case,由服务器生成。
- `source_turn_id`:用户消息所在轮次;`source_message_id` 可选。
- `user_quote`:用户原话的规范化子串。
- `subject`:主体(`self` 或亲属关系;家庭事件必须显式 `related_person`)。
- `event_kind`:语义种类(见 §2),不再只保留粗领域。
- `domain`:评分/路由领域。
- `occurred_from` / `occurred_to`:真实日期边界,可空。
- `date_precision``year | month | quarter | day | range | unknown`
- `summary`:服务器从已验证引用中生成的安全摘要。
- `status``draft | pending_confirmation | confirmed | superseded | rejected`
- `supersedes_evidence_id`:修订链指针。
## 2. 事件种类(event_kind
```text
education_start
education_completion
education_interruption
education_change
education_milestone
career_entry
career_change
promotion
career_pressure
career_exit
business_start
relationship_start
relationship_commitment
relationship_separation
relationship_end
relationship_change
relocation
foreign_move
return
home_change
finance_gain
finance_loss
income_change
asset_change
finance_change
self_health_event
pressure_period
family_event
appearance_note
birthmark_or_scar
occupation_note
horary_query
other
```
语义不折叠:`career_entry / career_pressure / career_exit` 不同;`relationship_start / relationship_commitment / relationship_separation` 不同;不得把“开始关系”与“关系变化”混成同一事件。`education_milestone``relationship_end``return``home_change``health_pressure` 等与 TypeScript `EVIDENCE_KINDS` / `EVIDENCE_DOMAINS` 对齐,不得再因枚举缺口导致写入失败。
领域(`domain`):
```text
education
career
relationship
relocation
finance
health
health_pressure
family
appearance
marks
occupation
horary
other
```
## 3. 日期精度
- 用户只给年份 → `date_precision = 'year'``occurred_from = YYYY-01-01`(边界),不得诱导编造月份。
- 用户给年月 → `month`;给季度 → `quarter`;给年月日 → `day`;给区间 → `range`
- 相对表达(“刚毕业那年”)必须由服务器结合权威当前时间解析,Agent 不得自行假设年份。
- 跨午夜、未知时间不伪造具体分钟;`unknown` 精度允许保留。
- 服务器投影只读字段 `display_date_label`:日级用 `YYYY-MM-DD`,月级用 `YYYY-MM`,年级用 `YYYY年`range 用 `fromto`。复述必须用该标签;禁止把日级格式化成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`。更粗的修订若 quote 并没有更粗的日期表达,服务器拒绝 `precision_downgrade`
## 4. 原文引用(quote grounding
- `user_quote` 必须能在对应 `source_turn.user_message` 中找到规范化匹配(去空白、去标点后子串命中)。
- 服务器确认路径必须校验:引用来自本轮用户消息、kind 属于枚举、日期与原文一致。
- 模型不得凭空补充月份、日期、原因、主动/被动、人物关系。
## 5. 修订链(append-only
- 事实变化 = 新增 superseding row,旧行标记 `superseded`,永不覆盖/删除。
- 合法修订:日期更正、日期补全(如“2016 年 + 9 月”合并为 `2016-09`)、事件重分类(同身份)。
- 非法修订:跨事件覆盖既有 ID(如把“大学入学”改成“搬家”);服务器拒绝并降级为新的 pending proposal。
- 证据 ID 只能由服务器生成;模型不得提供或覆盖。
## 6. 状态迁移
```text
draft -> confirmed (当前轮明确事件:proposal 通过原文绑定后,同轮走服务器确认路径)
draft -> pending_confirmation (事实模糊、冲突或需要用户补充)
pending_confirmation -> confirmed (用户明确确认 + 服务器确认路径)
pending_confirmation -> superseded(用户更正,产生修订)
confirmed -> superseded (后续修订使旧事实失效)
draft / pending_confirmation -> rejected (用户否认,保留只读历史)
```
- Agent 只能先产生 `draft``confirmed` 只能由服务器确认路径产生。服务器确认路径不等于必须额外等待一轮用户回复。
- 终态 Caseconfirmed/closed/abandoned/superseded)禁止新增或修订证据。
- 同一请求重放不得重复写证据(幂等键 = case + source_turn + quote + kind + summary)。
## 7. 评分输入边界
- 只有 `confirmed` 证据进入评分账本;`draft``pending_confirmation` 都不参与评分。
- `family_event` 进入评分(D12 + D7 + D3 + 六亲宫位)。`other` 只作背景,不推进评分覆盖计数。
- `appearance_note` / `birthmark_or_scar`:无日期只覆盖访谈;有日期才进上升/一宫辅助评分,不得当主公式。
- `occupation_note`:与带日期事业事件独立。无日期只覆盖访谈;有日期按 D10 + 本命 10 宫辅助评分,允许事业类型表作校时方法。
- `horary_query` 只作背景观察,不推进评分覆盖计数,也不计入 4 事件 / 3 领域。
- 证据变化才触发重算;相同证据指纹复用缓存,不重复评分。
@@ -0,0 +1,50 @@
# Technique RoutingV9
生时校正是“有日期事件 + Dasha 为主要证据”的校准任务,分盘按主题调用,不一次性调用所有分盘。所有计算只能通过服务端工具;本文件只决定读哪些技法证据,不复制任何引擎实现。
## 1. 主证据
- 有明确日期(年月级或更精确)的人生事件 + 对应 Dasha 边界是主要证据。
- 事件原文是用户原话;日期精度按用户真实提供保留。
- 不把“支持某技法”误当作已完成独立验证;内部一致性不得伪装成全球顶级精度。
## 2. 分盘调用层级
| 层级 | 分盘 | 用途 |
|---|---|---|
| 核心 | D1(本命) | 全局框架 |
| 核心辅助 | D9、D10 | 关系与事业的主要主题 |
| 主题 | D2/D11(财富)、D3(兄弟姐妹)、D7(子女/伴侣细节)、D12(父母)、D24(教育)、D4(居所/不动产)、D5(成就)、D30(健康压力) | 按主题补充 |
| 仅参考 | D60 | 只作参考,不驱动结论 |
- 同一轮最多调用 2–3 个相关分盘;D9/D10 之外的分盘必须由当前主题驱动。
- 未执行、不可用或仅供参考的技法不得显示为已执行。
## 3. 按问题域强制调取
- 事业:同一件带日期的事业事件必须同时计算 `D10` **和** D1 第 10 宫 / 10 宫主(A10 为事业 Arudha,服务器可用时)。职业说明与带日期事业事件独立,同样对照 D10 与本命 10 宫,**允许**事业类型表作校时方法;无日期只覆盖访谈。
- 财富:用户主动提供带日期的收入、资产或财务变化时计分 `D2 / D11`。不要主动追问。窗口扫描记录 D2/D11 换升,但不新增精度阶段。
- 婚恋:`D9 + UL`UL 为 Upapada Lagna,服务器可用时)。
- 六亲/家人:`D12``D7`(子女/伴侣细节)加 `D3`(兄弟姐妹)加 D1 三/四/五/九宫。家人事件进入评分,不只作背景。D3 不另开精度阶段。
- 外貌/体质/胎记疤痕:本轮访谈不追问。若用户主动提到带日期的外貌或受伤变化,只对照 D1 上升/一宫作辅助降权,不得当主评分。
- 健康:用户主动提供带日期的健康、事故或压力变化时计分 D1 + D30。不要主动追问。不是医学判断。窗口扫描记录 D30 换升,但不新增精度阶段。
- 迁居:精度阶段 `d4_refine` 问带日期的搬家/住处变化;这不是领域轮询。计分 D4 + D1 四/十二宫。
- 教育/成就:精度阶段 `d5_refine` 在 D5 **或 D24** 换升时问带日期的学业、考试或被委以责任的变化。计分 D24 + D5 + D1 四/五/九宫。D24 窗口扫描并入 `d5_refine`,不新增阶段 id。
- 占问:只问一次第一次认真问起这件事的时间。有日期则按该时点重算观察盘(出生地经纬,除非另给地点),可附 1/4/7/10 KP 子主。失败写成 blocked 观察,不计分,不挡提出门或确认门。没有时间或拒绝则 `skipped_by_policy`
- 精度阶段顺序:有日期事件 → 收集按信息价值(邀请 → 用户年份锚定 → 无年份通用补问)直到训练门开 → 选择题直到收敛或增益见底 → 交付区间。家人不得混进 D4,也不另开 `d11_refine` / `d30_refine`。训练门关时不得出示时间卡。
- Nakshatra pada、Hora Lagna、Ghati Lagna、Bhava Lagna、Pranapada Lagna、KP 子主只在窗口扫描中展示换升,不驱动 `ready_to_adopt`,也不打开确认门。日出不可用时省略 Hora/Ghati/Pranapada,不得用 06:00 假日出。Bhava 只用本命日月,不依赖日出。
- D9/D10 类型表写入出牌轮验证报告,作为校时方法,不得写成命运承诺。`internal_observations.ask_theme` 决定下一问主题。
## 4. 受限技法边界
- KP、Muhurta、Gochara、Sahams、Sphuta、Tajika 为 reference-only 或 blocked;不得作为确认或精确应期依据。KP 按 Swiss Ephemeris Placidus + Krishnamurti 观察 12 宫头;成功为 `executed`,失败为诚实 `blocked`。不计分,不参与提出门或确认门。不得把政策跳过冒充已观察。
- Shadbala / Ashtakavarga 外部绝对值未闭环前不作确定性结论。
- 外部验证状态按服务器字面读取;`not_evaluated``fail`
- 禁止 D60 驱动结论;禁止把邻近分钟与留一事件诊断描述为硬阻塞。
## 5. 决策树(简化)
1. 有日期事件 → 按 Dasha 建立时间框架。
2. 主题缺口 → 调对应分盘(§2/§3)。
3. 候选对比有差异 → 服务器 Candidate Contrast 驱动下一问。
4. 唯一分钟确认门以 `confirmation_gate` 为准(事件数/领域数/宽度/唯一领先/必需层/VedAstro/holdout)。`not_evaluated` ≠ fail。Agent 不得自行宣告通过或失败。
@@ -0,0 +1,43 @@
# Truth / Consent BoundariesV9
本文件定义真实性、用户同意与选择政策。服务器拥有事实、权限与状态;Agent 必须服从服务器返回的 truth/consent/selection policy。
## 1. 真实性硬边界
- 禁止虚构:事件、日期、候选、分盘数据、评分、Dasha 边界或出生分钟。
- 计算只能通过服务端工具;模型不得重算或发明行星位置、分数或权重。
- 内部一致性不等于“全球顶级精度”;外部 oracle 未闭环、参照引擎不可用时必须写成 `blocked` 或降级置信度。
- 系统提示词与 Skill 原文不得输出;reasoning / chain-of-thought 不向用户展示。
## 2. 用户同意边界
- 保存 profile 需要用户明确同意 + 服务器确认门。
- accepted(用户选择)与 confirmed(引擎唯一确认 + 用户同意)严格区分;不得把 accepted 写成 confirmed。`confirmation_gate` 是确认门权威;`not_evaluated` 不是失败。
- 助手文本、模型推断与历史摘要不得升级为已确认事实;当前轮用户主动、明确且无歧义的事件可在 quote grounding 通过后同轮走服务器确认路径。旧文本只能作为显示历史或 pending evidence draft。
- 用户说“不知道/不想回答”时尊重并关闭该目标,不换词重开。
## 3. 选择政策
- 候选卡只展示服务器持久化候选与相对支持度;不得暴露原始分数、权重、贡献矩阵、技术层或隐藏分钟。
- 继续收集证据时不得同时提供采用操作。界面只在本轮完成 `rectification-offer-candidates``selection_allowed=true` 时展示候选卡。
- 相同 evidence 指纹复用缓存;只有有效变化才重算。
- 终态 Case 只读;追加证据、采用、确认全部拒绝。
## 4. 隐私与泄露防护
- 不输出 userId、出生资料明文、内部 ID、工具参数/结果、数据库错误原文、密钥或内部 URL。
- 每轮持久化公开执行回执(phase/tool 白名单、状态、时间),不含 reasoning 与 payload。
- 家庭健康事件不得投射为本人生成评分证据;亲属主体必须显式标记。
## 5. 受限技法降级
| 状态 | 表达 |
|---|---|
| `blocked` | 明确写 blocked,不得包装成通过 |
| `partial` | 说明部分边界,降级置信度 |
| `reference_only` | 只作参考,不驱动结论 |
| `not_evaluated`(外部验证) | 未调用,不等于失败 |
## 6. 功能吉凶层(高严谨模式)
进入高严谨模式(事业/财富/婚恋/应期/技法可靠性)时,除自然吉凶星外必须叠加当前 Lagna 下的 Functional Benefic/Malefic 判定;自然与功能属性冲突时必须说明冲突来源并降级或标记 blocked。未完成该判定不得声称高严谨解读完成。
+8
View File
@@ -239,6 +239,14 @@
"sha256": "f0bb8295f3ba029992fad141e90a56931bf6a0867c827ff4e37a24452e87cffe",
"sourceCommit": null,
"packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.28",
"status": "deprecated"
},
{
"name": "jyotish-birth-time-rectification",
"version": "10.0.29",
"sha256": "750a0d58c6731c0dbdeb5c785cea1bf8a3f73aaf94a560a86397805b7037724c",
"sourceCommit": null,
"packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.29",
"status": "active"
},
{