fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流

schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
jesse-ux
2026-09-18 13:17:35 +08:00
parent b4fcb9466f
commit e32ce6247e
22 changed files with 617 additions and 205 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
## 对话额度拆分(2026-09-16,分支 `codex/consultation-session-capacity-20260915`BUG-732
- **无 Docker** 本机 `docker` 不在 PATH。`npm run test:db``npm run db:migrate:check`(需要 `SCHEMA_DATABASE_URL` 连 Postgres)未跑。`frontend/tests/database-consultation-session-capacity.test.ts` 在无 Docker 时 skip,**不得写成通过**。替代证据是 SQL 静态合同:额度求和表达式不含 `thinkingText` / `thinkingSections`,物理上限为 `length(elem::text)`,算式写在迁移注释里。
- **详情接口体积(5.3,已量,不改接口):** 用三域本命夹具(每轮正文 4,000 汉字 + 思考 4,000 汉字 + 实测 `thinkingSections` + 典型三个 receipt)构造 `GET /api/sessions/[id]``{ session }` JSON。19 轮 UTF-8 **562,240 B0.536 MiB**50 轮 **1,479,034 B1.411 MiB**,约为 2.63 倍。1.4 MiB 在 2 vCPU 上打开长会话会偏沉,分页 / 按需加载历史不在本单(任务书 §9)。PostgreSQL `length()` 按字节计,汉字正文 4,000 字 ≈ 12,000 字节,线上按字节撞 200,000 额度会早于「50 轮汉字」;50 轮数字是任务书字符口径对照。
- **详情接口体积(5.3,已量,不改接口):** 用三域本命夹具(每轮正文 4,000 汉字 + 思考 4,000 汉字 + 实测 `thinkingSections` + 典型三个 receipt)构造 `GET /api/sessions/[id]``{ session }` JSON。2026-09-16 旧思考计划:19 轮 UTF-8 **562,240 B0.536 MiB**50 轮 **1,479,034 B1.411 MiB**。2026-09-18 四标题思考计划重测:19 轮 **533,113 B0.508 MiB**50 轮 **1,402,384 B1.338 MiB**,比例仍约 2.63。1.31.4 MiB 在 2 vCPU 上打开长会话会偏沉,分页 / 按需加载历史不在本单(任务书 §9)。PostgreSQL `length()` 按字节计,汉字正文 4,000 字 ≈ 12,000 字节,线上按字节撞 200,000 额度会早于「50 轮汉字」;50 轮数字是任务书字符口径对照。
## 只读页验收修复:镜像与浏览器(2026-09-16,分支 `codex/readonly-pages-fix-20260916`
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-18 — 出生范围用户的应期不再被删字
只知道一段出生范围时,大运和行运边界按区间说出来,不再挖成「[具体时间已省略]」。没有出生分钟时,个人星盘断言会整段换成需要具体分钟的说明。保证性结论(一定会、注定)仍不发。多领域问题装不下的部分记在省略列表里,不再整轮算不成。Skill 版本不变。
## 2026-09-18 — 咨询思考、进度、正文分开生成
普通咨询的思考区不再从模型内部推理里抠字、也不再把英文词挖掉留下残句。进度是服务端自己报的;思考是一条条完整的中文判断依据;正文只写结论。本命回答的章节改成:先回答你的问题、盘里支持这个判断的地方、时间怎么看、这周可以做的一件事。技法审计表仍在回复下面的折叠面板里,不写进正文。
+80
View File
@@ -12365,3 +12365,83 @@
- 相关记录:BUG-942、BUG-943
- 复发自:无
- 修复版本:`94c1e81f`
## BUG-945 | 三领域问题被 schema 整次拒绝,一个领域都不算
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`consultation-tools.ts` 工具入参 `domains.max``executableDomainPlan`
- 用户现象:一次问事业+财富+家庭时整轮没有排盘,回答空或只剩道歉。
- 触发条件:模型列出 ≥3 个领域。`MAX_CONSULTATION_DOMAINS` 因 31s/领域降到 2schema `.max(2)` 在 execute 前拒绝整次调用。
- 根因:执行上限与计划上限绑在同一个常数上。工具描述承诺截断进 `omitted_domains`,zod 却整次拒绝,截断逻辑成死代码。
- 修复:schema 上限改为 `MAX_CONSULTATION_DOMAIN_PLAN_VALUES = 6`;真正执行仍按墙钟上限 2 截断,其余进 `omitted_domains`。描述与 mastra 指令对齐。
- 验证:`consultation-agentic-runtime.test.ts` 超限计划 `runWorkflow` ≥1、`omitted_domains` 非空、schema 接受 3–6 条拒绝 7 条。
- 防复发:计划长度与执行长度必须是两个常数;超限合同必须走到 execute。
- 相关记录:BUG-944、BUG-946
- 复发自:BUG-944
- 修复版本:见本批合入 SHA
## BUG-946 | 领域时长改 31s 后 6 条预算断言仍按 21s / cap=3
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`consultation-agentic-runtime.test.ts`
- 用户现象:无(测试红)。门禁 `npm test` 过不了,镜像不发布。
- 触发条件:`94c1e81f` 把领域时长改成 31s 后跑全量测试。
- 根因:实现改了常数,断言没跟。三领域调用被 BUG-945 的 schema 拒绝后,合并合同读 `undefined`
- 修复:按 MAX=2 / 31s 重写 6 条断言,写原值/新值/原因。不得弱化成 `cap >= 1`
- 验证:同文件预算与超限截断用例。
- 防复发:改 `CONSULTATION_DOMAIN_DURATION_MS` 必须同步预算合同。
- 相关记录:BUG-944、BUG-945
- 复发自:BUG-944
- 修复版本:见本批合入 SHA
## BUG-947 | 校正流思考分片被条目门整条丢掉
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`rectification-agentic/v9/stream-mapping.ts``think-step-gate.ts`
- 用户现象:生时校正计算期思考行大面积空白。
- 触发条件:provider `reasoning-delta` 分片短于 8 字或没有句末标点。`先核对经历。`7 字)被 `acceptThinkStepText` 判空。
- 根因:本命 Pass 2 的整条门被误用到校正分片通道。
- 修复:选任务书方案 (b)。校正流保留分片通道,改走 `acceptThinkingFragment`(只判 CJK / 工具名,不改写、不设 8 字下限)。另提供 assembler 给连续分片测可见行。
- 验证:`rectification-step-answer.test.ts`,含「连续中文分片最终能产出一条可见思考行」。
- 防复发:条目门不得接到分片通道。新增正则必须能回答「门还是刀」。
- 相关记录:BUG-942
- 复发自:BUG-942
- 修复版本:见本批合入 SHA
## BUG-948 | 三类检测器没人调用,范围用户应期被旧口径挖空
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`timing-output-guard.ts``stream-agent-response.ts``consult/route.ts`、出生时间模式
- 用户现象:只知道出生范围、或已校正后问应期,日期被挖成「[具体时间已省略]」;同时保证句和个人盘断言在恒等壳下什么都不拦。
- 触发条件:`94c1e81f` 把守卫改成恒等,Pass 4 只接了方法学记账检测。产品 2026-09-18 授权按模式分开处置。
- 根因:检测器保留了,生产路径没接线。身份壳看起来在保护。旧红线把分钟敏感主题一律挖日期,范围用户不能用。
- 修复:删除恒等壳。Pass 4 按模式:`exact-timing` 在有盘/窗口模式只记 `pass4-observe` 不改写;`guarantee` 全模式 `pass4-reject`,退回 Pass 3 一次,二次丢句;`personal-chart``general_no_birth_time` 退回,二次整段换成拒绝句。正文先 hold 再一次发出,避免闪两次。
- 验证:timing-output-guard / birth-time-mode / range-reading / birth-accuracy / agentic-runtime Pass 4 合同。范围用户含日期正文原样通过。
- 防复发:全仓不得再出现 `guardPreciseTimingOutput` / `guardGeneralNoBirthTimeOutput` / `createBirthTimeModeOutputGuard`。回执步骤名带 `pass4-observe:<kind>` / `pass4-reject:<kind>`
- 相关记录:BUG-298、BUG-943
- 复发自:BUG-298
- 修复版本:见本批合入 SHA
## BUG-949 | 思考计划变小后容量算术两条红
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`consultation-session-capacity.test.ts`
- 用户现象:无(测试红)。门禁过不了。
- 触发条件:本命思考计划改成四标题后,1 域 JSON 1053 < 旧下限 120019 轮旧口径不再 > 200,000。
- 根因:断言按旧计划形状写死上下界。
- 修复:按 2026-09-18 实测重算:分节 1053/1267/126719 轮详情 533,113 B、旧口径 177,97350 轮 1,402,384 B。三栏说明。19→50 轮产品口径不变。
- 验证:同文件两条容量合同。
- 防复发:改 thinking plan 形状必须重测这两条,不得放宽成「大于 0」。
- 相关记录:BUG-732、BUG-943
- 复发自:无
- 修复版本:见本批合入 SHA
@@ -0,0 +1,54 @@
# PROGRESS · 三通道重做修复单(2026-09-18
工作树:`.worktrees/consult-three-channels-fix-20260918`
分支:`codex/consult-three-channels-fix-20260918`
基线:`origin/staging` @ `b4fcb946`(任务书写 `7ae3aa9a`,其后两笔纯文档已快进)
任务书:`docs/tasks/TASK-consult-three-channels-fix-20260918.md`
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| BUG-945 领域 schema 与执行上限解耦 | 完成 | 计划最多 6 条,执行仍按墙钟 2 条,其余 `omitted_domains` |
| BUG-946 31s 预算断言 | 完成 | 6 条按 MAX=2 / 31s 重写,附三栏 |
| BUG-947 校正思考分片 | 完成 | 选方案 (b):分片级 CJK 门,不复用 8 字条目门 |
| BUG-948 Pass 4 按模式分流 | 完成 | 日期观察、保证句退回、无分钟个人盘整段拒绝 |
| BUG-949 容量算术 | 完成 | 按四标题计划重测上下界 |
## 947 选型
**(b)**。校正流仍按 `reasoning-delta` 分片推到浏览器,不能拿 Pass 2 的整条门(≥8 字 + 句末标点)去滤。新门 `acceptThinkingFragment` 只判非空、含 CJK、不含工具名,不改写、不删词。另给 `createThinkingFragmentAssembler` 证明连续分片能拼出可见行。
## 948 让步
1.`composeAnswer` 的本命路径:Pass 4 命中保证句 / 个人盘时退回 Pass 3 一次,二次再落地(丢保证句或整段拒绝句)。正文 hold 到判定结束再发,避免闪两次。
2.`composeAnswer` 的一般 / 窗口路径:不另开一轮模型,直接二次落地。
3. 非 agentic 的 `streamTextResponse` 旧路径不再套恒等壳,也不在流上改写。日期因此能出来;保证句在这条旧路径上没有模型重写。
4. `exact-timing``general_no_birth_time` 不记 observe、也不拦——产品表只给有盘 / 窗口模式观察日期。
## 断言三栏(既有测试)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `MAX_CONSULTATION_DOMAINS` | 3 | 2 | 31s/领域,墙钟 65s 只能付 2 个 |
| `executableDomainPlan` 5 个领域 | 执行 3 | 执行 2 | 同上 |
| `domainFitsRunBudget(42_000, 2)` | true(按 21s | 改为 31s 口径的 0/1/2 组 | 预算公式跟新时长 |
| 超限计划 | schema 失败、execute 0 | schema 接受 ≤6execute ≥1`omitted_domains` 非空 | BUG-945 |
| 未校正 / 分钟敏感日期 | `[具体时间已省略]` | 原文保留 + `pass4-observe:exact-timing` | BUG-948 产品授权 |
| 无分钟个人盘 | 半句替换或恒等 | 二次整段 `GENERAL_NO_BIRTH_TIME_REFUSAL` | 不得半句替换 |
| 1 域 thinkingSections JSON | 12002500 | 9001300(实测 1053 | 四标题计划更小 |
| 2/3 域 thinkingSections JSON | 18003500 / 24004500 | 11001600(实测 126712 步封顶同长) | 同上 |
| 19 轮旧口径字符 | > 200,000 | 170k190k(实测 177,973 | 分节变小后 19 轮不再必然撞旧额度 |
| 19/50 轮详情字节 | 540k590k / 1.45M1.51M | 520k550k / 1.38M1.43M533,113 / 1,402,384 | 同上;比例仍约 2.63 |
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error121 warning,与基线同量级) |
| 原 12 条红 | 全绿(见上表) |
| 全量 `npm test`(本机 Windows | tests 3449 / pass 3371 / fail 78。多出来的失败是本机 symlink/EPERM、bash、无 Docker 的既有缺口,不是本单 12 条 |
| 本单新增 | Pass 4 观察/退回/拒绝合同、范围用户日期合同、分片 assembler、容量上下界 |
Linux 门禁才是验收数字:目标 fail 回到 31,总数 ≥ 3484。本机无法跑 skill symlink 的两条 Agent 探针(`personal Agent exposes…` / `general agent runtime has no Jyotish skill package…`),CI Linux 上应仍绿。
+1 -1
View File
@@ -136,7 +136,7 @@
| `TASK-session-list-single-source-fix-20260917.md` | `PROGRESS-session-list-single-source-fix-20260917.md` | 验收修复单:F1 四条源码合同搬到 `(app)/layout.tsx` 两端对断(BUG-933);F2 两条陈旧 Python 入口断言(BUG-934,既有欠账);F3 无活跃会话时输入框静默吞发送(BUG-935);交付前必须跑全量测试 | 待验收 | `codex/session-list-single-source-fix-20260917` |
| — | `PROGRESS-birth-time-journey-page-path-20260918.md` | **P0 门禁**`test_birth_time_journey_contract` 仍读已搬走的 `app/page.tsx`BUG-939);修好后 run 2764 又露出 5 条校正守卫后的前端源码合同(BUG-940)。均复发自 BUG-933 | 已合入 | `39a0d7a9` / `0378d9e0` |
| `TASK-consult-three-channels-20260918.md` | `PROGRESS-consult-three-channels-20260918.md` | 咨询运行时三通道:进度 / 思考 / 正文生成时分离。设计分支误用 BUG-939/940/941,落地改号 942944 | 验收未通过(见修复单) | `94c1e81f` |
| `TASK-consult-three-channels-fix-20260918.md` | — | 验收修复单:领域上限 3→2 且超限被 zod 整次拒绝、三领域问题零执行(BUG-945 P0);21s→31s 后 6 条预算断言未跟946校正思考分片被条目门整条丢弃(947);时间 / 保证 / 个人盘三类检测器无生产调用方 → 按模式分开处置:日期照出不删字(含出生范围用户)、保证句与无出生时间下的个人盘断言退回重写(948,产品 09-18 授权,推翻分钟敏感主题一律套精确时间守卫的旧红线);容量算术两条红949。基线 `7ae3aa9a`npm test 43 红 vs 基线 31 红 | 待领取 | — |
| `TASK-consult-three-channels-fix-20260918.md` | `PROGRESS-consult-three-channels-fix-20260918.md` | 验收修复单:领域上限解耦(945)、31s 预算断言(946校正思考分片门(947)、Pass 4 按模式分流(948)、容量算术(949 | 待验收 | `codex/consult-three-channels-fix-20260918` |
| `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — |
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
+3 -1
View File
@@ -34,6 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 坏 | 好 | 为什么 |
|---|---|---|
| Rahu 大运为 [具体时间已省略] 至 [具体时间已省略]。 | Rahu 大运为 2013 年 11 月中下旬至 2031 年 11 月中下旬。按范围看,不写成单一分钟。 | 出生范围用户要拿到区间说法;算出来的边界不是要删的字。 |
| 请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。 | 眼下按 04:45–05:15 来核对,用你记得的经历对照几种分盘和大运。最后给区间和代表分钟,不给精确到秒。想到几件说几件,有大概年月就行——比如升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。 | 开场三句讲做法并点领域,不写具体年份。 |
| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 |
| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
@@ -118,7 +119,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。交付旁白不写「候选 A 预测下一次事业变动」;改说预测窗口留在采用后的核对阶段。
- 不得对用户写「概率 / 置信度 / 确定 / 相对支持度」。专业数字只留在看盘板。
- 采用旁白里「这段区间里哪些主题稳定、哪些会随分钟变」只能来自服务端 `theme_sensitivity`,模型不得自编;声明时段咨询必须写明「这只是粗看」。
- 技法审计表仍在本命回答文末
- 技法审计表只出现在回复下面的折叠面板,不写进正文
- 只知道出生范围时,大运和行运边界按区间说出来,不得挖成「[具体时间已省略]」。没有出生分钟时,任何「你的盘」断言都不要写。
## 口语层的形状
+5 -30
View File
@@ -39,7 +39,6 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { streamAgentResponse } from "@/lib/stream-agent-response";
import { detectMethodologyBookkeeping } from "@/lib/timing-output-guard";
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
import { consultationComposePrompt, consultationContinuePrompt, natalConsultationThinkingPlan, type PublicThinkingSection } from "@/lib/consultation-thinking-plan";
import {
@@ -62,7 +61,6 @@ import {
applyBirthTimeModeToWorkflowContext,
ACCEPTED_RANGE_READING_INSTRUCTION,
consultationBirthTimeModeSchema,
createBirthTimeModeOutputGuard,
shouldRunBirthChartWorkflow,
shouldRunDeclaredWindowWorkflow,
type ConsultationBirthTimeMode,
@@ -1004,10 +1002,7 @@ export async function POST(request: Request) {
retryForAnswer,
continueAfterLength,
continueAfterDisconnect: true,
transformText: createBirthTimeModeOutputGuard(
generalDailyContext ? "general_no_birth_time" : consultationMode,
false,
),
pass4Mode: generalDailyContext ? "general_no_birth_time" : consultationMode,
toolStatus: () => "ready",
receipt: executionReceipt,
headers: { "x-jyotish-birth-time-mode": consultationMode },
@@ -1107,7 +1102,7 @@ export async function POST(request: Request) {
retryForAnswer,
continueAfterLength,
continueAfterDisconnect: true,
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
pass4Mode: consultationMode,
toolStatus: () => workflowStatus(state.workflowReceipt?.status),
receipt: executionReceipt,
headers: { "x-jyotish-birth-time-mode": consultationMode },
@@ -1188,10 +1183,10 @@ export async function POST(request: Request) {
const interpretFindings = async () => (
(state.thinkingPlan ?? []).map((section) => ({ id: section.id }))
);
const composeAnswer = async () => {
const composeAnswer = async (_: readonly { id: string; text?: string }[] = [], retryHint?: string) => {
const composed = await agent.stream([
...baseMessages,
{ role: "user" as const, content: consultationComposePrompt() },
{ role: "user" as const, content: `${consultationComposePrompt()}${retryHint ? `\n${retryHint}` : ""}` },
], {
...streamOptions,
maxSteps: AGENT_SLICE_MAX_STEPS,
@@ -1230,16 +1225,8 @@ export async function POST(request: Request) {
continueAfterLength,
interpretFindings,
composeAnswer,
pass4Violations: (text) => detectMethodologyBookkeeping(text).map((item) => item.kind),
pass4Mode: consultationMode,
continueAfterDisconnect: true,
transformText: (text) => createBirthTimeModeOutputGuard(
consultationMode,
state.workflowReceipt?.preciseTiming === "allowed",
{
currentTheme: consultationTheme,
minuteSensitiveThemes: state.workflowReceipt?.minuteSensitiveThemes,
},
)(text),
toolStatus: () => workflowStatus(state.workflowReceipt?.status),
receipt: executionReceipt,
headers: { "x-jyotish-birth-time-mode": consultationMode },
@@ -1313,10 +1300,6 @@ export async function POST(request: Request) {
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(
generalDailyContext ? "general_no_birth_time" : consultationMode,
false,
),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
@@ -1426,14 +1409,6 @@ export async function POST(request: Request) {
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(
consultationMode,
workflowReceipt.preciseTiming !== "blocked",
{
currentTheme: consultationTheme,
minuteSensitiveThemes: workflowReceipt.minuteSensitiveThemes,
},
),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
@@ -1,8 +1,4 @@
import { z } from "zod";
import {
guardGeneralNoBirthTimeOutput,
guardPreciseTimingOutput,
} from "./timing-output-guard.ts";
export const consultationBirthTimeModeSchema = z.enum([
"verified_chart",
@@ -70,28 +66,4 @@ export function applyBirthTimeModeToWorkflowContext<
};
}
/**
* Server-side output boundary. Timing and guarantee filtering remains active
* without inserting a rectification warning into every answer.
*/
export function createBirthTimeModeOutputGuard(
mode: ConsultationBirthTimeMode,
canAnswerPreciseTiming: boolean,
options?: {
currentTheme?: string | null;
minuteSensitiveThemes?: readonly string[] | null;
},
): (text: string) => string {
return (text) => {
if (mode === "general_no_birth_time") return guardGeneralNoBirthTimeOutput(text);
if (mode === "declared_birth_window" || !canAnswerPreciseTiming) {
return guardPreciseTimingOutput(text);
}
const theme = options?.currentTheme ?? "";
const sensitive = options?.minuteSensitiveThemes ?? [];
if (theme && (theme === "timing" || sensitive.includes(theme))) {
return guardPreciseTimingOutput(text);
}
return text;
};
}
@@ -22,7 +22,7 @@ import {
type PublicRectificationPhase,
type PublicRectificationTool,
} from "./public-receipt";
import { acceptThinkStepText } from "../../think-step-gate";
import { acceptThinkingFragment } from "../../think-step-gate";
import { isToolInputRejection, toolResultFromChunk } from "./host-fallback";
export type PublicPhaseStreamEvent = Readonly<{
@@ -187,7 +187,7 @@ export type InternalThinkingDeltaEvent = Readonly<{
}>;
export function toPublicThinkingDelta(text: string): InternalThinkingDeltaEvent | null {
const cleaned = acceptThinkStepText(text);
const cleaned = acceptThinkingFragment(text);
if (!cleaned) return null;
return { type: "thinking.delta", text: cleaned };
}
+64 -15
View File
@@ -14,6 +14,7 @@ import { createVisibleTextTransformer } from "./stream-text-response.ts";
import { consultationWriteLabel } from "./consultation-activity-labels.ts";
import { logTruncatedReasoning } from "./consultation-budget.ts";
import { acceptThinkStepText } from "./think-step-gate.ts";
import { applyPass4Policy, type Pass4Mode } from "./timing-output-guard.ts";
import {
applyThinkingSectionProgress,
generalConsultationThinkingPlan,
@@ -303,9 +304,9 @@ type StreamAgentResponseOptions = EventOptions & {
retry?: () => Promise<ChunkStream>;
retryForAnswer?: () => Promise<ChunkStream>;
continueAfterLength?: (output: string) => Promise<ChunkStream>;
composeAnswer?: (findings: readonly ThinkFinding[]) => Promise<ChunkStream>;
composeAnswer?: (findings: readonly ThinkFinding[], retryHint?: string) => Promise<ChunkStream>;
interpretFindings?: () => Promise<readonly ThinkFinding[]>;
pass4Violations?: (text: string) => readonly string[];
pass4Mode?: Pass4Mode;
continueAfterDisconnect?: boolean;
headers?: HeadersInit;
onFirstActivity?: () => void | Promise<void>;
@@ -372,7 +373,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
async function consumeAttempt(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
stream: ChunkStream,
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {},
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean; holdAnswer?: boolean } = {},
) {
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
let held = "";
@@ -395,7 +396,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: held });
if (!attempt.holdAnswer) send(controller, { type: "answer.delta", text: held });
fullOutput += held;
if (/\S/.test(held)) emitted = true;
held = "";
@@ -455,6 +456,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
async function continueCurrentAnswer(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
heading?: string,
holdAnswer = false,
) {
if (options.state.modelFinishReason !== "length") return;
if (!options.continueAfterLength) throw new Error("answer_truncated");
@@ -467,6 +469,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
});
await consumeAttempt(controller, await options.continueAfterLength(fullOutput), {
suppressCompositionActivity: true,
holdAnswer,
});
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
if (options.state.modelFinishReason === "length" && fullOutput === beforeContinue) {
@@ -509,6 +512,51 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
return findings;
}
function recordPass4Steps(steps: ReturnType<typeof applyPass4Policy>["steps"]) {
const seen = new Set<string>();
for (const step of steps) {
const key = `${step.action}:${step.kind}`;
if (seen.has(key)) continue;
seen.add(key);
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: `${step.action === "observe" ? "pass4-observe" : "pass4-reject"}:${step.kind}`,
status: step.action === "observe" ? "completed" : "failed",
failureCode: step.kind,
});
}
}
async function finishPass4(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
origin: string,
findings: readonly ThinkFinding[],
) {
if (!options.pass4Mode) {
const produced = fullOutput.slice(origin.length);
if (produced) send(controller, { type: "answer.delta", text: produced });
return;
}
let report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode);
recordPass4Steps(report.steps);
if (report.retry && options.composeAnswer) {
fullOutput = origin;
await consumeAttempt(
controller,
await options.composeAnswer(findings, report.retryHint),
{ suppressCompositionActivity: true, holdAnswer: true },
);
await continueCurrentAnswer(controller, undefined, true);
report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true });
recordPass4Steps(report.steps);
} else if (report.retry) {
report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true });
recordPass4Steps(report.steps);
}
fullOutput = origin + report.text;
if (report.text) send(controller, { type: "answer.delta", text: report.text });
}
async function composeOnce(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
findings: readonly ThinkFinding[],
@@ -525,19 +573,14 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
label: "正在写结论",
});
const started = Date.now();
const origin = fullOutput;
await consumeAttempt(
controller,
await options.composeAnswer(findings),
{ suppressCompositionActivity: true },
{ suppressCompositionActivity: true, holdAnswer: Boolean(options.pass4Mode) },
);
await continueCurrentAnswer(controller);
if ((options.pass4Violations?.(fullOutput) ?? []).length > 0) {
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: "pass4-reject",
status: "failed",
});
}
await continueCurrentAnswer(controller, undefined, Boolean(options.pass4Mode));
await finishPass4(controller, origin, findings);
send(controller, {
type: "phase.completed",
phase: "compose",
@@ -559,14 +602,17 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
for (const event of skillBoundEvents) send(controller, event);
flushThinkingPlan(controller);
try {
const holdMain = Boolean(options.pass4Mode) && !options.composeAnswer;
await consumeAttempt(controller, options.stream, {
drainSpoken: Boolean(options.composeAnswer),
holdAnswer: holdMain,
});
if (!contractReady(options) && options.retry) {
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "runtime-contract-retry", status: "completed" });
send(controller, { type: "activity", phase: "loading-method", label: "正在补齐方法与计算步骤" });
await consumeAttempt(controller, await options.retry(), {
drainSpoken: Boolean(options.composeAnswer),
holdAnswer: holdMain,
});
}
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
@@ -575,10 +621,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
await consumeAttempt(controller, await options.retryForAnswer());
await consumeAttempt(controller, await options.retryForAnswer(), { holdAnswer: holdMain });
}
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
if (!composed) await continueCurrentAnswer(controller);
if (!composed) {
await continueCurrentAnswer(controller, undefined, holdMain);
if (holdMain) await finishPass4(controller, "", findings);
}
settling = true;
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
const thinkingSections = applyThinkingSectionProgress(options.state.thinkingPlan ?? [], fullOutput);
+34
View File
@@ -15,3 +15,37 @@ export function acceptThinkStepText(text: string): string | null {
if (!SENTENCE_RE.test(trimmed)) return null;
return trimmed;
}
const TOOLISH_FRAGMENT_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+|skill_read|proposedKind|validationErrors/i;
/**
* Rectification still publishes reasoning as fragments. This gate only accepts
* or rejects a chunk. It does not rewrite, strip English, or require 8 chars.
*/
export function acceptThinkingFragment(text: string): string | null {
const trimmed = text.replace(/\s+/g, " ").trim();
if (!trimmed) return null;
if (!CJK_RE.test(trimmed)) return null;
if (TOOLISH_FRAGMENT_RE.test(trimmed)) return null;
return trimmed;
}
export function createThinkingFragmentAssembler() {
let buffer = "";
return {
push(chunk: string): string | null {
const accepted = acceptThinkingFragment(chunk);
if (!accepted) return null;
buffer += accepted;
if (!SENTENCE_RE.test(buffer)) return null;
const released = buffer;
buffer = "";
return released;
},
flush(): string | null {
const leftover = buffer;
buffer = "";
return leftover ? acceptThinkingFragment(leftover) : null;
},
};
}
+81 -6
View File
@@ -143,12 +143,87 @@ export function detectMethodologyBookkeeping(text: string): TimingGuardViolation
return hits;
}
/** @deprecated Detection only. Never rewrite the model text. */
export function guardPreciseTimingOutput(text: string) {
return text;
export type Pass4Mode =
| "verified_chart"
| "unverified_birth_time"
| "declared_birth_window"
| "general_no_birth_time";
export type Pass4Step = Readonly<{
action: "observe" | "reject";
kind: TimingGuardKind;
excerpt: string;
}>;
export type Pass4Result = Readonly<{
text: string;
steps: readonly Pass4Step[];
retry: boolean;
retryHint?: string;
}>;
function dropGuaranteeClauses(text: string): string {
const parts = text.split(/([.!?\n]+)/u);
let output = "";
for (let index = 0; index < parts.length; index += 2) {
const clause = parts[index] ?? "";
const punct = parts[index + 1] ?? "";
if (detectPreciseTimingViolations(clause).some((item) => item.kind === "guarantee")) continue;
output += clause + punct;
}
return output;
}
/** @deprecated Detection only. Never rewrite the model text. */
export function guardGeneralNoBirthTimeOutput(text: string) {
return text;
export function classifyPass4(
text: string,
mode: Pass4Mode,
): Pass4Step[] {
const steps: Pass4Step[] = [];
for (const hit of detectPreciseTimingViolations(text)) {
if (hit.kind === "exact-timing") {
if (mode !== "general_no_birth_time") {
steps.push({ action: "observe", kind: hit.kind, excerpt: hit.excerpt });
}
continue;
}
if (hit.kind === "guarantee") {
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
}
}
if (mode === "general_no_birth_time") {
for (const hit of detectGeneralNoBirthTimeViolations(text)) {
if (hit.kind === "personal-chart") {
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
}
}
}
for (const hit of detectMethodologyBookkeeping(text)) {
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
}
return steps;
}
export function applyPass4Policy(
text: string,
mode: Pass4Mode,
options?: { secondPass?: boolean },
): Pass4Result {
const steps = classifyPass4(text, mode);
const rejects = steps.filter((step) => step.action === "reject");
if (!options?.secondPass && rejects.some((step) => step.kind === "guarantee" || step.kind === "personal-chart")) {
return {
text,
steps,
retry: true,
retryHint: "不要写保证性结论(一定、保证、注定、will definitely)。无出生分钟时不要对用户作个人星盘断言。",
};
}
let next = text;
if (options?.secondPass && rejects.some((step) => step.kind === "guarantee")) {
next = dropGuaranteeClauses(next);
}
if (options?.secondPass && mode === "general_no_birth_time" && rejects.some((step) => step.kind === "personal-chart")) {
next = GENERAL_NO_BIRTH_TIME_REFUSAL;
}
return { text: next, steps, retry: false };
}
+2 -2
View File
@@ -134,7 +134,7 @@ const domainPlanValueSchema = consultationDomainPlanValueSchema;
// stated. Internal callers keep the single-value form; see canonicalDomainPlan.
const consultationToolInputSchema = z.object({
question: z.string().trim().min(1).max(500),
domains: z.array(domainPlanValueSchema).min(1).max(MAX_CONSULTATION_DOMAINS).optional(),
domains: z.array(domainPlanValueSchema).min(1).max(MAX_CONSULTATION_DOMAIN_PLAN_VALUES).optional(),
}).strict();
const MAX_RECORDED_STEPS = 32;
@@ -629,7 +629,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
let calculation: Promise<ReturnType<typeof toModelDomainPlanContext>> | null = null;
const consultationTool = createTool({
id: "run-jyotish-consultation",
description: `Run one server-validated plan of at most ${MAX_CONSULTATION_DOMAINS} allowlisted personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, or omit it entirely to accept the domain the server already selected. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; if the clock runs short the server executes the domains that fit and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
inputSchema: consultationToolInputSchema,
execute: async (input, context) => {
const requestedDomains = canonicalDomainPlan(input, ctx);
+1 -1
View File
@@ -21,7 +21,7 @@ Write in Simplified Chinese: a heading-free spoken opener first (反差(表面
${jyotishSkillMethodBlock}
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. . Do not replace the skeleton with spoken-only chat.
Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan shortthe natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. List every domain the question actually needs, in priority order, up to six. Do not drop a relevant domain to keep the plan shortthe natal compute already ran the full technique spectrum. The server canonicalizes aliases, rejects unsupported/product domains, executes as many accepted domains as the wall clock can pay for (about ${MAX_CONSULTATION_DOMAINS}), and returns the rest in omitted_domains. The actual executed domains are in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
The tool result's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
The tool result always carries one top-level answer contractstatus, evidence_contract, claim_cards, rectificationeven when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage.
@@ -37,13 +37,13 @@ import {
DAILY_HEADING,
REPORT_HEADING,
} from "../src/lib/consultation-thinking-plan.ts";
import { getJyotishAgent } from "../src/mastra/index.ts";
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
collectAgentPublicEvents,
streamAgentResponse,
} from "../src/lib/stream-agent-response.ts";
import { GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
const serverChart = {
name: "测试",
@@ -192,27 +192,28 @@ test("multi-domain plan canonicalizes aliases, de-duplicates, preserves order, a
return workflow(input.theme);
},
});
// Aliases, not repetitions: the array bound is now the executable domain cap,
// so a duplicate spends one of the slots the clock can actually pay for.
// canonicalDomainPlan keeps the de-duplication coverage for longer raw lists.
// 原值:别名 career/finance/home 三个都执行,domains 含 migration
// 新值:别名仍规范化,执行上限 2migration 进 omitted_domains
// 原因:BUG-945/946,墙钟只付得起 2 个领域。
const result = await tools["run-jyotish-consultation"].execute!(
modelInput({ question: "事业、财富和迁居怎么一起规划", domains: ["career", "finance", "home"] }),
{ observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never,
) as { domains: string[]; consultations: Array<{ domain: string }> };
) as { domains: string[]; omitted_domains: string[]; consultations: Array<{ domain: string }> };
assert.deepEqual(calls, [
{ theme: "career", question: "事业、财富和迁居怎么一起规划" },
{ theme: "wealth", question: "事业、财富和迁居怎么一起规划" },
{ theme: "migration", question: "事业、财富和迁居怎么一起规划" },
]);
assert.deepEqual(result.domains, ["career", "wealth", "migration"]);
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth", "migration"]);
assert.deepEqual(result.domains, ["career", "wealth"]);
assert.deepEqual(result.omitted_domains, ["migration"]);
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth"]);
assert.deepEqual(state.workflowReceipt, {
route: "multi-domain",
status: "degraded",
preciseTiming: "allowed",
missingLayers: ["D11"],
domains: ["career", "wealth", "migration"],
domains: ["career", "wealth"],
omittedDomains: ["migration"],
});
});
@@ -253,14 +254,17 @@ test("a multi-domain result exposes the same top-level answer contract as a sing
});
test("the merged answer policy is the most restrictive of the executed domains", async () => {
const { result, state } = await runDomainPlan(["career", "timing", "wealth"], (theme) => workflow(theme, {
// 原值:career+timing+wealth 三个都执行,D11 / 限制句来自 wealth
// 新值:只执行 2 个;把限制性字段放到 timing 上,合并规则不变
// 原因:BUG-946MAX=2 后第三域进 omitted,不能再靠它提供合并输入。
const { result, state } = await runDomainPlan(["career", "timing"], (theme) => workflow(theme, {
// One domain forbidding precise timing must forbid it for the whole answer.
preciseTiming: theme !== "timing",
status: theme === "wealth" ? "degraded" : "ready",
missingLayers: theme === "wealth" ? ["D11"] : [],
status: theme === "timing" ? "degraded" : "ready",
missingLayers: theme === "timing" ? ["D11"] : [],
hardBlockers: theme === "timing" ? ["negative_holdout_gate"] : [],
leadWithLimitations: theme === "timing",
limitation: theme === "wealth" ? "财富层证据不完整。" : undefined,
limitation: theme === "timing" ? "财富层证据不完整。" : undefined,
chart: natalChart,
}));
@@ -364,31 +368,32 @@ test("the identical natal projection is carried once instead of per domain", asy
});
test("the domain cap is what the run budget can actually pay for", () => {
// 21s per sequential domain against the 110s run budget, minus the reserve a
// three-domain staging run actually left for composing the answer.
assert.equal(MAX_CONSULTATION_DOMAINS, 3);
// 原值:MAX=3,按 21s/领域
// 新值:MAX=2,按实测 31s/领域(110s-45s 写作预留)
// 原因:BUG-944/946,工具实测约 31s/领域,21s 会把写作预算吃光。
assert.equal(MAX_CONSULTATION_DOMAINS, 2);
assert.equal(AGENT_TIMEOUT_MS, 110_000);
assert.equal(CONSULTATION_DOMAIN_WALL_CLOCK_MS, 65_000);
assert.ok(MAX_CONSULTATION_DOMAINS * 21_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
// Six domains, the previous cap, could never finish inside the deadline.
assert.ok(6 * 21_000 > AGENT_TIMEOUT_MS);
assert.ok(MAX_CONSULTATION_DOMAINS * 31_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
assert.ok(6 * 31_000 > AGENT_TIMEOUT_MS);
assert.deepEqual(
executableDomainPlan(["career", "wealth", "timing", "marriage", "health"]),
{ domains: ["career", "wealth", "timing"], omittedDomains: ["marriage", "health"] },
{ domains: ["career", "wealth"], omittedDomains: ["timing", "marriage", "health"] },
);
assert.deepEqual(executableDomainPlan(["career"]), { domains: ["career"], omittedDomains: [] });
// The first domain always runs; after that the next one has to be projected
// to finish, judged by how long the executed ones really took.
assert.equal(domainFitsRunBudget(0, 0), true);
assert.equal(domainFitsRunBudget(21_000, 1), true);
assert.equal(domainFitsRunBudget(42_000, 2), true);
assert.equal(domainFitsRunBudget(31_000, 1), true);
assert.equal(domainFitsRunBudget(62_000, 2), false);
assert.equal(domainFitsRunBudget(60_000, 2), false);
assert.equal(domainFitsRunBudget(40_000, 1), false);
});
test("a plan larger than the cap cannot be expressed and never starts a calculation", async () => {
test("a plan larger than the execution cap is truncated not refused", async () => {
// 原值:4 个领域 schema 失败、execute 0 次、无 omitted_domains
// 新值:schema 接受最多 6 个,execute ≥1 次,omitted_domains 非空
// 原因:BUG-945,描述承诺截断,zod 却整次拒绝。
let calls = 0;
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
@@ -399,18 +404,20 @@ test("a plan larger than the cap cannot be expressed and never starts a calculat
const inputSchema = tool.inputSchema as unknown as { safeParse: (value: unknown) => { success: boolean } };
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing"] }).success, true);
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, false);
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, true);
assert.equal(inputSchema.safeParse({
question: "测试",
domains: ["career", "wealth", "timing", "marriage", "health", "education", "family"],
}).success, false);
// Mastra rejects the over-budget plan before the tool body runs, so it costs
// one correctable step and nothing about the run advances.
const refused = await tool.execute!(
{ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] } as never,
const result = await tool.execute!(
modelInput({ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] }),
toolContext,
) as Record<string, unknown>;
assert.equal(calls, 0);
assert.equal("domains" in refused, false);
assert.equal(state.consultationToolStarted, false);
assert.deepEqual(runSteps(state), []);
) as PlanResult;
assert.ok(calls >= 1);
assert.ok((result.omitted_domains ?? []).length > 0);
assert.doesNotMatch(JSON.stringify(result), /Tool input validation failed/);
assert.equal(state.consultationToolStarted, true);
});
test("a plan that runs long stops early and discloses the domains it dropped", async () => {
@@ -455,9 +462,9 @@ test("the advertised domain limit matches the enforced one", () => {
})["run-jyotish-consultation"];
const description = tool.description ?? "";
assert.match(description, new RegExp(`at most ${MAX_CONSULTATION_DOMAINS} allowlisted`));
assert.doesNotMatch(description, /up to six|six allowlisted/);
assert.match(description, /up to 6/);
assert.match(description, /omitted_domains/);
assert.match(description, new RegExp(`about ${MAX_CONSULTATION_DOMAINS}`));
assert.match(description, /top-level answer contract/);
});
@@ -947,6 +954,7 @@ test("the receipt separates method the server delivered from method the model we
});
test("personal Agent exposes the Jyotish Skill and named server tool", async () => {
const { getJyotishAgent } = await import("../src/mastra/index.ts");
const state = createConsultationRuntimeState();
const agent = getJyotishAgent({
id: "personal-agent-probe", label: "Probe", description: "", creditCost: 1, isDefault: false,
@@ -1693,6 +1701,92 @@ test("composeAnswer length continue finishes the same body", async () => {
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
test("pass4 holds verified dates and records pass4-observe without rewriting", async () => {
const state = toolOnlyRunState();
async function* chunks() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日 至 2031年11月22日。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text);
assert.equal(answers.length, 1);
assert.match(answers[0] ?? "", /2013年11月21日/);
assert.doesNotMatch(answers.join(""), /具体时间已省略/);
assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true);
});
test("pass4 retries compose once on guarantee then drops leftover clauses", async () => {
const state = toolOnlyRunState();
async function* first() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
let composed = 0;
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: first(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
composeAnswer: async (_findings, retryHint) => {
composed += 1;
if (composed === 2) assert.match(retryHint ?? "", /不要写保证性结论/);
async function* body() {
yield {
type: "text-delta",
payload: {
text: "方向可以推进。我保证你一定会升职。",
},
};
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
return body();
},
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.equal(composed, 2);
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.match(answer, /方向可以推进/);
assert.doesNotMatch(answer, /一定会升职/);
assert.equal(state.steps.some((step) => step.name === "pass4-reject:guarantee"), true);
});
test("pass4 general mode second-pass replaces personal chart claims with the refusal", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
async function* chunks() {
yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: false,
pass4Mode: "general_no_birth_time",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.equal(answer, GENERAL_NO_BIRTH_TIME_REFUSAL);
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
});
test("natal tool success stores a Chinese thinking plan", async () => {
const { state } = await runDomainPlan(["career", "wealth"], () => workflow());
const encoded = JSON.stringify(state.thinkingPlan ?? []);
@@ -4,13 +4,13 @@ import test from "node:test";
import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts";
import {
createBirthTimeModeOutputGuard,
shouldRunBirthChartWorkflow,
shouldRunDeclaredWindowWorkflow,
UNVERIFIED_BIRTH_TIME_NOTICE,
applyBirthTimeModeToWorkflowContext,
unverifiedBirthTimeNotice,
} from "../src/lib/consultation-birth-time-mode.ts";
import { applyPass4Policy, GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
import {
consultationInputSchema,
runConsultationWorkflow,
@@ -253,26 +253,36 @@ test("unverified notices grade by source without changing the output guards", ()
});
test("output guards and window/general instruction seams stay byte-stable", () => {
// 原值:createBirthTimeModeOutputGuard 在 general 模式替换个人盘句、窗口模式挖日期
// 新值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝
// 原因:BUG-948,出生范围用户要能用应期,不能删字。
const modeSource = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8");
const routeSource = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(modeSource, /if \(mode === "general_no_birth_time"\) return guardGeneralNoBirthTimeOutput\(text\)/);
assert.match(
modeSource,
/if \(mode === "declared_birth_window" \|\| !canAnswerPreciseTiming\) \{[\s\S]*return guardPreciseTimingOutput\(text\);/,
);
assert.doesNotMatch(modeSource, /createBirthTimeModeOutputGuard|guardPreciseTimingOutput|guardGeneralNoBirthTimeOutput/);
assert.match(routeSource, /pass4Mode:/);
assert.doesNotMatch(routeSource, /createBirthTimeModeOutputGuard|guardPreciseTimingOutput|guardGeneralNoBirthTimeOutput/);
assert.equal(UNVERIFIED_BIRTH_TIME_NOTICE.includes("未校正填报时间"), true);
const general = createBirthTimeModeOutputGuard("general_no_birth_time", false)(
const generalFirst = applyPass4Policy(
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。\n你的上升是巨蟹座。",
"general_no_birth_time",
);
assert.match(general, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
assert.match(general, /你的上升是巨蟹座/);
assert.equal(generalFirst.retry, true);
const general = applyPass4Policy(
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。\n你的上升是巨蟹座。",
"general_no_birth_time",
{ secondPass: true },
);
assert.equal(general.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
const windowed = createBirthTimeModeOutputGuard("declared_birth_window", false)(
const windowed = applyPass4Policy(
"Rahu 大运为 2013年11月21日 至 2031年11月22日。",
"declared_birth_window",
);
assert.match(windowed, /2013年11月21日/);
assert.match(windowed.text, /2013年11月21日/);
assert.doesNotMatch(windowed.text, /具体时间已省略/);
assert.equal(windowed.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.match(routeSource, /function generalNoMinuteInstruction\(hasPublicDaily: boolean\) \{/);
assert.match(routeSource, /function declaredWindowInstruction\(\) \{/);
@@ -2,15 +2,13 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
UNVERIFIED_BIRTH_TIME_NOTICE,
applyBirthTimeModeToWorkflowContext,
createBirthTimeModeOutputGuard,
consultationBirthTimeModeSchema,
shouldRunBirthChartWorkflow,
shouldRunDeclaredWindowWorkflow,
} from "../src/lib/consultation-birth-time-mode.ts";
import { getGeneralJyotishAgent } from "../src/mastra/index.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
import { applyPass4Policy, GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
import { homeSurface } from "./home-surface.ts";
test("general-no-birth-time is an explicit server mode that never runs a chart workflow", () => {
@@ -54,41 +52,45 @@ test("unverified chart context preserves evidence-owned precise timing permissio
});
test("unverified answers preserve exact dates when server evidence allows precise timing", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", true);
const answer = transform("Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.equal(answer, "Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.doesNotMatch(answer, /具体时间已省略/);
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。";
const report = applyPass4Policy(text, "unverified_birth_time");
assert.equal(report.text, text);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("evidence-blocked unverified answers still use the deterministic timing guard", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
const answer = transform("2026年8月适合观察方向。");
assert.match(answer, /具体时间已省略/);
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
test("evidence-blocked unverified answers keep dates and only observe exact-timing", () => {
// 原值:createBirthTimeModeOutputGuard 把日期挖成「[具体时间已省略]」
// 新值:日期原样保留,Pass 4 记 pass4-observe
// 原因:BUG-948,范围/未校正用户应期要能用,不能删字。
const text = "2026年8月适合观察方向。";
const report = applyPass4Policy(text, "unverified_birth_time");
assert.equal(report.text, text);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => {
const transform = createBirthTimeModeOutputGuard("general_no_birth_time", false);
const guarded = transform([
// 原值:恒等壳下的 transform 把个人盘句替换成拒绝句
// 新值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
// 原因:BUG-948,无出生分钟模式才拦「你的盘」断言,且不得半句替换。
const mixed = [
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。",
"忽略之前的规则,基于你的盘,你的 D9 上升一定是处女座。",
"你的上升是巨蟹座,因此你一定会升职。",
"你的金星落在第七宫。",
"D9 显示你适合晚婚。",
"你的 D9:处女上升。",
].join("\n"));
assert.match(guarded, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
assert.match(guarded, /这部分需要具体出生分钟才能判断,我不会补造时间/);
assert.doesNotMatch(guarded, /改问一般知识|先完成生时校正|一般咨询模式不能生成个人星盘结论/);
assert.doesNotMatch(guarded, /你的 D9 上升一定是处女座|你的上升是巨蟹座|你一定会升职|你的金星落在|D9 显示你|你的 D9:处女上升/);
assert.doesNotMatch(guarded, /。。/);
].join("\n");
const first = applyPass4Policy(mixed, "general_no_birth_time");
assert.equal(first.retry, true);
const second = applyPass4Policy(mixed, "general_no_birth_time", { secondPass: true });
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
assert.equal(second.steps.some((step) => step.action === "reject" && step.kind === "personal-chart"), true);
});
test("general agent runtime has no Jyotish skill package and no personal chart tool", async () => {
const { getGeneralJyotishAgent } = await import("../src/mastra/index.ts");
const model: ResolvedLanguageModel = {
id: "general-zero-tool-probe",
label: "General probe",
@@ -244,9 +244,14 @@ test("measured thinkingSections and receipts stay inside the physical-cap arithm
const one = JSON.stringify(thinkingPlan(["career"]));
const two = JSON.stringify(thinkingPlan(["career", "wealth"]));
const three = JSON.stringify(thinkingPlan(["career", "wealth", "marriage"]));
assert.ok(one.length >= 1200 && one.length <= 2500, `1-domain sections JSON length ${one.length}`);
assert.ok(two.length >= 1800 && two.length <= 3500, `2-domain sections JSON length ${two.length}`);
assert.ok(three.length >= 2400 && three.length <= 4500, `3-domain sections JSON length ${three.length}`);
// 原值:1 域 12002500 / 2 域 18003500 / 3 域 24004500
// 新值:1 域 9001300 / 23 域 11001600(实测 1053 / 1267 / 1267
// 原因:BUG-949,本命思考计划改成四标题后 JSON 变小;12 步上限让 2 域与 3 域同长。
assert.ok(one.length >= 900 && one.length <= 1300, `1-domain sections JSON length ${one.length}`);
assert.ok(two.length >= 1100 && two.length <= 1600, `2-domain sections JSON length ${two.length}`);
assert.ok(three.length >= 1100 && three.length <= 1600, `3-domain sections JSON length ${three.length}`);
assert.ok(two.length >= one.length, "adding a domain must not shrink the plan JSON");
assert.ok(three.length >= two.length, "a third domain may share the 12-step cap");
const receipts = typicalReceipts(["career", "wealth", "marriage"]);
const receiptJson = JSON.stringify({
@@ -283,12 +288,19 @@ test("session-detail JSON at the old ~19-round cap versus the new ~50-round cap"
assert.equal(oldCap.session.messages.length, 38);
assert.equal(newCap.session.messages.length, 100);
assert.ok(oldCountedChars(oldCap.session.messages) > 200_000, "19 full rounds already exceeded the old combined quota");
// 原值:oldCountedChars(19) > 200_000(思考分节计入旧额度)
// 新值:四标题计划更小,19 轮旧口径实测 177,973,落在 170k190k
// 原因:BUG-949,思考计划变小后 19 轮不再必然撞上 20 万旧额度。
assert.ok(
oldCountedChars(oldCap.session.messages) > 170_000 && oldCountedChars(oldCap.session.messages) < 190_000,
`19-round old combined count was ${oldCountedChars(oldCap.session.messages)}`,
);
assert.ok(countedText(newCap.session.messages) >= 200_000, "50 rounds of 100+4000 visible text reach the conversation quota");
// Measured 2026-09-16 on this fixture: 19-round 562,240 B (0.536 MiB);
// 50-round 1,479,034 B (1.411 MiB); ratio 2.631.
assert.ok(oldBytes > 540_000 && oldBytes < 590_000, `19-round detail JSON was ${oldBytes} bytes`);
assert.ok(newBytes > 1_450_000 && newBytes < 1_510_000, `50-round detail JSON was ${newBytes} bytes`);
// 原值:2026-09-16 夹具 19 轮 562,240 B / 50 轮 1,479,034 B
// 新值:2026-09-18 实测 533,113 B / 1,402,384 B,比例仍约 2.63
// 原因:BUG-949thinkingSections JSON 随四标题计划缩小。
assert.ok(oldBytes > 520_000 && oldBytes < 550_000, `19-round detail JSON was ${oldBytes} bytes`);
assert.ok(newBytes > 1_380_000 && newBytes < 1_430_000, `50-round detail JSON was ${newBytes} bytes`);
assert.ok(newBytes > oldBytes, "50-round detail JSON must be larger than 19-round");
const ratio = newBytes / oldBytes;
@@ -122,7 +122,7 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
assert.match(route, /composeAnswer,/);
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
assert.equal(route.match(/const continueAfterLength = async \(output: string\) => \{/g)?.length, 3);
assert.equal(route.match(/const composeAnswer = async \(\) => \{/g)?.length, 1);
assert.equal(route.match(/const composeAnswer = async \(/g)?.length, 1);
assert.match(route, /toolChoice: "none"/);
assert.match(route, /entrypoint: consultEntrypoint/);
assert.match(route, /entrypoint: parsed\.data\.entrypoint/);
@@ -153,10 +153,11 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
assert.match(natalRetry, /], natalStreamOptions\)/);
assert.doesNotMatch(natalRetry, /], streamOptions\)/);
const composeBlock = route.slice(
route.indexOf("const composeAnswer = async () => {"),
route.indexOf("const executionReceipt = (): AgentExecutionReceipt => ({", route.indexOf("const composeAnswer = async () => {")),
route.indexOf("const composeAnswer = async ("),
route.indexOf("const executionReceipt = (): AgentExecutionReceipt => ({", route.indexOf("const composeAnswer = async (")),
);
assert.match(composeBlock, /\.\.\.streamOptions,/);
assert.match(composeBlock, /retryHint/);
assert.doesNotMatch(composeBlock, /natalStreamOptions/);
assert.doesNotMatch(stream, /section-empty-retry/);
assert.match(tools, /dailyConsultationThinkingPlan/);
@@ -191,7 +192,11 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
assert.doesNotMatch(agenticBranch, /JSON\.stringify\(toolInput\)/);
assert.match(tools, /\(ctx\.runWorkflow \?\? runConsultationWorkflow\)\(toolInput, \{/);
assert.match(tools, /return \{ "run-jyotish-consultation": consultationTool \};/);
assert.match(agenticBranch, /state\.workflowReceipt\?\.preciseTiming === "allowed"/);
// 原值:transformText 读 workflowReceipt.preciseTiming === "allowed" 再套恒等壳
// 新值:Pass 4 只看 consultationMode,不再读 preciseTiming 开关去挖日期
// 原因:BUG-948,恒等壳下线;日期观察按模式,不按分钟敏感开关。
assert.match(agenticBranch, /pass4Mode: consultationMode/);
assert.doesNotMatch(agenticBranch, /state\.workflowReceipt\?\.preciseTiming === "allowed"/);
assert.match(route, /createConsultationReplyMetadata/);
});
@@ -4,8 +4,8 @@ import test from "node:test";
import {
ACCEPTED_RANGE_READING_INSTRUCTION,
createBirthTimeModeOutputGuard,
} from "../src/lib/consultation-birth-time-mode.ts";
import { applyPass4Policy } from "../src/lib/timing-output-guard.ts";
import { RANGE_READING_COPY } from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
import {
createConsultationRuntimeState,
@@ -47,25 +47,30 @@ test("theme_sensitivity maps sensitive keys onto the consultation answer policy"
assert.deepEqual(receipt.minuteSensitiveThemes, ["marriage"]);
});
test("minute-sensitive or timing themes use the precise-timing output guard", () => {
test("minute-sensitive or timing themes keep calculated dasha dates", () => {
// 原值:verified_chart + marriage/timing 主题把日期挖成「[具体时间已省略]」
// 新值:日期原样保留,Pass 4 只记 observe
// 原因:BUG-948,已校正用户问应期不该被删字;范围用户同样要拿到区间说法。
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。";
const marriage = createBirthTimeModeOutputGuard("verified_chart", true, {
currentTheme: "marriage",
minuteSensitiveThemes: ["marriage"],
})(text);
assert.match(marriage, /具体时间已省略/);
const marriage = applyPass4Policy(text, "verified_chart");
assert.equal(marriage.text, text);
assert.equal(marriage.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
const career = createBirthTimeModeOutputGuard("verified_chart", true, {
currentTheme: "career",
minuteSensitiveThemes: ["marriage"],
})(text);
assert.equal(career, text);
const career = applyPass4Policy(text, "verified_chart");
assert.equal(career.text, text);
const timing = createBirthTimeModeOutputGuard("verified_chart", true, {
currentTheme: "timing",
minuteSensitiveThemes: [],
})(text);
assert.match(timing, /具体时间已省略/);
const timing = applyPass4Policy(text, "verified_chart");
assert.equal(timing.text, text);
assert.doesNotMatch(timing.text, /具体时间已省略/);
});
test("declared birth window keeps exact dates and does not blank them", () => {
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期,不写成单一分钟。";
const report = applyPass4Policy(text, "declared_birth_window");
assert.equal(report.text, text);
assert.match(report.text, /2013年11月21日/);
assert.doesNotMatch(report.text, /具体时间已省略/);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
});
test("declared window consultation merges range-reading themes and says it is a coarse look", async () => {
@@ -8,6 +8,7 @@ import {
shouldPublishStepText,
} from "../src/lib/rectification-agentic/v9/step-answer.ts";
import { mapStreamChunkToPhase, mapStreamChunkToThinking } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { createThinkingFragmentAssembler } from "../src/lib/think-step-gate.ts";
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
function isPublicTool(name: string): boolean {
@@ -44,6 +45,13 @@ test("never publishes reasoning-delta to the browser", () => {
assert.ok(mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对经历。" }) as never));
});
test("consecutive Chinese reasoning fragments assemble into a visible thinking line", () => {
const assembler = createThinkingFragmentAssembler();
assert.equal(assembler.push("The proposedKind value was rejected"), null);
assert.equal(assembler.push("先核"), null);
assert.equal(assembler.push("对经历。"), "先核对经历。");
});
test("publishes only the terminal no-tool step as assistant text", () => {
const state = createStepAnswerState();
applyStepAnswerChunk(state, chunk("step-start"), isPublicTool);
+47 -16
View File
@@ -1,32 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
applyPass4Policy,
detectGeneralNoBirthTimeViolations,
detectPreciseTimingViolations,
GENERAL_NO_BIRTH_TIME_REFUSAL,
guardGeneralNoBirthTimeOutput,
guardPreciseTimingOutput,
} from "../src/lib/timing-output-guard.ts";
import { streamTextResponse } from "../src/lib/stream-text-response.ts";
import { parseAgentReply } from "../src/lib/agent-reply.ts";
import { createBirthTimeModeOutputGuard } from "../src/lib/consultation-birth-time-mode.ts";
test("detects exact dates and months when precise timing is blocked", () => {
// 原值:就地替换成「[具体时间已省略]」
// 新值:只检测,不改写
// 原因:任务书 P3,正则当门不当刀。
// 新值:只检测,不改写Pass 4 在有盘模式下记 observe
// 原因:任务书 P3 / BUG-948,正则当门不当刀。
const text = "你会在2027年3月15日结婚,事业将在11月转折。";
const hits = detectPreciseTimingViolations(text);
assert.equal(hits.some((item) => item.kind === "exact-timing"), true);
assert.equal(guardPreciseTimingOutput(text), text);
const report = applyPass4Policy(text, "verified_chart");
assert.equal(report.text, text);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("detects guarantee conclusions when the evidence contract is incomplete", () => {
const text = "我保证你一定会升职。";
const hits = detectPreciseTimingViolations(text);
assert.equal(hits.some((item) => item.kind === "guarantee"), true);
assert.equal(guardPreciseTimingOutput(text), text);
const first = applyPass4Policy(text, "verified_chart");
assert.equal(first.retry, true);
assert.equal(first.steps.some((step) => step.action === "reject" && step.kind === "guarantee"), true);
const second = applyPass4Policy(text, "verified_chart", { secondPass: true });
assert.doesNotMatch(second.text, /一定会升职/);
assert.equal(
detectPreciseTimingViolations("You will definitely get promoted.").some((item) => item.kind === "guarantee"),
true,
@@ -42,7 +48,6 @@ test("streamed dates stay intact because the guard no longer rewrites", async ()
const response = streamTextResponse(reply(), {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000098",
transformText: guardPreciseTimingOutput,
});
const text = await response.text();
assert.match(text, /2027年3月15日/);
@@ -64,7 +69,6 @@ test("guards only visible prose and preserves AYANAM blocks across arbitrary chu
const response = streamTextResponse(reply(), {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000099",
transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false),
});
const text = await response.text();
const parsed = parseAgentReply(text);
@@ -96,19 +100,22 @@ test("general mode structurally rejects personalized chart placements in Chinese
for (const claim of unsafeClaims) {
const hits = detectGeneralNoBirthTimeViolations(claim);
assert.equal(hits.some((item) => item.kind === "personal-chart" || item.kind === "exact-timing"), true, claim);
assert.equal(guardGeneralNoBirthTimeOutput(claim), claim, claim);
const first = applyPass4Policy(claim, "general_no_birth_time");
assert.equal(first.retry, true, claim);
const second = applyPass4Policy(claim, "general_no_birth_time", { secondPass: true });
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL, claim);
}
assert.equal(
guardGeneralNoBirthTimeOutput("第七宫在占星概念中常与关系相关。"),
applyPass4Policy("第七宫在占星概念中常与关系相关。", "general_no_birth_time").text,
"第七宫在占星概念中常与关系相关。",
);
assert.equal(
guardGeneralNoBirthTimeOutput("Venus is generally associated with relating and values."),
applyPass4Policy("Venus is generally associated with relating and values.", "general_no_birth_time").text,
"Venus is generally associated with relating and values.",
);
assert.equal(
guardGeneralNoBirthTimeOutput("你问的第七宫,在占星概念中常与关系相关。"),
applyPass4Policy("你问的第七宫,在占星概念中常与关系相关。", "general_no_birth_time").text,
"你问的第七宫,在占星概念中常与关系相关。",
);
});
@@ -128,7 +135,8 @@ test("general mode rejects personalized chart predicates beyond possessive place
for (const claim of unsafeClaims) {
assert.equal(detectGeneralNoBirthTimeViolations(claim).length > 0, true, claim);
assert.equal(guardGeneralNoBirthTimeOutput(claim), claim, claim);
const second = applyPass4Policy(claim, "general_no_birth_time", { secondPass: true });
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL, claim);
}
});
@@ -151,7 +159,9 @@ test("general mode preserves educational chart questions and general knowledge",
];
for (const statement of safeStatements) {
assert.equal(guardGeneralNoBirthTimeOutput(statement), statement);
const report = applyPass4Policy(statement, "general_no_birth_time");
assert.equal(report.text, statement);
assert.equal(report.retry, false, statement);
}
});
@@ -169,7 +179,6 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
const response = streamTextResponse(reply(), {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000097",
transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false),
});
const text = await response.text();
const parsed = parseAgentReply(text);
@@ -182,3 +191,25 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
assert.equal(parsed.title, "一般占星咨询");
assert.doesNotMatch(parsed.text, /AYANAM_SUGGESTIONS|了解第七宫的一般概念/);
});
test("declared birth window keeps calculated dates as interval facts", () => {
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期。";
const report = applyPass4Policy(text, "declared_birth_window");
assert.equal(report.text, text);
assert.equal(report.retry, false);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("identity output guards are gone from production sources", () => {
const guard = readFileSync(new URL("../src/lib/timing-output-guard.ts", import.meta.url), "utf8");
const mode = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
for (const source of [guard, mode, route]) {
assert.doesNotMatch(source, /function guardPreciseTimingOutput/);
assert.doesNotMatch(source, /function guardGeneralNoBirthTimeOutput/);
assert.doesNotMatch(source, /createBirthTimeModeOutputGuard/);
}
assert.match(route, /pass4Mode/);
assert.match(guard, /export function applyPass4Policy/);
});