From 53d78137ff15608a175deb0f8fc7c3cd634011ec Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 18 Sep 2026 18:31:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(consult):=20=E7=94=B3=E6=8A=A5=E6=97=B6?= =?UTF-8?q?=E6=AE=B5=E8=AE=A1=E7=AE=97=E6=94=B9=E4=B8=BA=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E9=A2=84=E8=B7=91=E5=B9=B6=E8=B5=B0=E5=90=8C=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=BC=93=E5=AD=98=EF=BC=88BUG-957=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 窗口计算挂在 agent context 缓存上,模型开口前预跑并注入 packet;工具再调用命中同请求缓存,成功次数仍为 1。 --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 26 +- ...RESS-window-consult-precompute-20260918.md | 37 ++ docs/tasks/README.md | 3 +- frontend/src/app/api/consult/route.ts | 32 +- frontend/src/lib/stream-agent-response.ts | 21 +- frontend/src/mastra/consultation-tools.ts | 315 ++++++++++-------- frontend/src/mastra/index.ts | 1 + .../consultation-agentic-runtime.test.ts | 214 ++++++++++++ .../consultation-workflow-contract.test.ts | 25 ++ 10 files changed, 523 insertions(+), 155 deletions(-) create mode 100644 docs/tasks/PROGRESS-window-consult-precompute-20260918.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 495e6e53..2daa9d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-18 — 申报时段计算改为服务端先跑 + +只知道一段出生范围时,窗口比较在模型开口前就先算好。模型按这份结果写即可,不必再决定调不调计算工具。Skill 版本不变。 + ## 2026-09-18 — 没跑完计算时,保证性结论仍不会发出 合同没跑完但模型写了字时,那段回答会先按句子过一遍合规门:保证性结论整句拿掉,其余留下,并注明这次没跑完星盘计算。不会只丢一句说明、上面空空的。同一轮重试过的旧稿不会再拼进这次回答。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6daf3a7b..043bf6a4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12586,19 +12586,19 @@ ## BUG-957 | 窗口计算由模型决定调不调,对结果无信息增益 -- 状态:investigating +- 状态:resolved - 首次发现:2026-09-18 - 最近更新:2026-09-18 -- 影响面:`consultation-tools.ts` `run-jyotish-window-consultation`、咨询运行时 -- 用户现象:无(窗口线被 BUG-954 挡住,本项尚未在真实环境验证)。 -- 触发条件:窗口工具 `inputSchema` 只有 `question`,出生数据完全服务端绑定。 +- 影响面:`consultation-tools.ts` `run-jyotish-window-consultation`、`stream-agent-response.ts` warmup、`consult/route.ts` 申报时段分支 +- 用户现象:只知道一段出生范围时,回答仍取决于模型是否调用计算工具;漏调就整轮失败或降级。 +- 触发条件:`consultationMode=declared_birth_window`。窗口工具 `inputSchema` 只有 `question`,出生数据完全服务端绑定。 - 根因:由模型决定调不调对计算结果没有信息增益,只多一条失败路径(BUG-205/214/922/923/937)。 -- 修复:本单不做。任务书要求等 BUG-954 在真实环境验证通过后再单独做服务端预跑 + 同请求缓存。 -- 验证:未做。 -- 防复发:不得与救急同轮改运行时结构。 -- 关联记录:BUG-954、BUG-205、BUG-214、BUG-922、BUG-923、BUG-937 -- 复发自:无 -- 修复版本:— +- 修复:缓存改挂在 agent context 上。服务端在模型循环前 `precomputeWindowConsultation`,进度经 warmup 写入同一 NDJSON 流;成功则把 packet 以服务端确定性消息注入本轮。工具仍留在窗口 Agent 上,再调用命中同请求缓存且 `consultationToolSuccessCount` 不得加第二次。预跑失败合同保持红,模型仍可调工具,既有 retry + 降级仍在。本命不预跑。没有 `toolChoice: "required"`。`contractReady()` 仍是「本请求恰好一次成功的真实计算」,预跑算那一次。 +- 验证:预跑成功且模型不调工具 → 合同绿、回执有 `run-jyotish-window-consultation` completed、正文送达;预跑后模型再调 → 缓存命中、成功次数为 1;预跑失败且模型只写字 → 仍降级。源码合同锁窗口工具仍 attach、本命工厂不变。 +- 防复发:窗口计算不得再依赖模型选不选工具;缓存不得回到工厂局部 `let`;咨询侧不得再写 `toolChoice: "required"`。 +- 关联记录:BUG-954、BUG-205、BUG-214、BUG-922、BUG-923、BUG-937、BUG-956、BUG-959 +- 复发自:无(954 验证通过后按任务书单独做) +- 修复版本:`59788ac6` ## BUG-959 | 降级正文绕过 Pass 4,保证性结论原样送达 @@ -12614,7 +12614,7 @@ - 防复发:降级路径必须复用 `releasePass4Sentences`,不得另写分句或句内替换。Pass 4 是门不是刀。 - 关联记录:BUG-956、BUG-948、BUG-950、BUG-951 - 复发自:BUG-956(降级路径漏接 Pass 4) -- 修复版本:`ae2e46a6` +- 修复版本:`e07d5190` ## BUG-960 | 两次 attempt 的正文被拼起来一起送出 @@ -12630,7 +12630,7 @@ - 防复发:未绿缓冲按 attempt 隔离,不得跨 attempt 累加。 - 关联记录:BUG-956、BUG-959 - 复发自:BUG-956 -- 修复版本:`ae2e46a6` +- 修复版本:`e07d5190` ## BUG-961 | 降级之后还会空跑一轮 compose @@ -12646,7 +12646,7 @@ - 防复发:降级是终态交付,不得再进 compose。 - 关联记录:BUG-956、BUG-959 - 复发自:BUG-956 -- 修复版本:`ae2e46a6` +- 修复版本:`e07d5190` ## BUG-958 | 窗口指令「必须调工具」与「不得给应期」未写清,模型可能跳过计算或拒答 diff --git a/docs/tasks/PROGRESS-window-consult-precompute-20260918.md b/docs/tasks/PROGRESS-window-consult-precompute-20260918.md new file mode 100644 index 00000000..af29451d --- /dev/null +++ b/docs/tasks/PROGRESS-window-consult-precompute-20260918.md @@ -0,0 +1,37 @@ +# PROGRESS · 申报时段计算改为服务端预跑(2026-09-18) + +工作树:`.worktrees/contract-degraded-pass4-20260918` +分支:`codex/contract-degraded-pass4-20260918` +基线:`origin/staging` @ `1061514f` +前序:`TASK-window-consult-contract-20260918.md` §5、BUG-954 已在 staging 验证通过 +任务:BUG-957 + +## 结论 + +申报时段的窗口计算不再等模型决定调不调。服务端在模型开口前先跑同一套工具体,进度写进同一条 NDJSON 流;算出的 packet 以服务端确定性消息注入本轮上下文。工具仍挂在窗口 Agent 上,再调用会命中同请求缓存,成功次数仍是 1。本命线没动。没有 `toolChoice: "required"`。 + +## 做法 + +| 点 | 落地 | +| --- | --- | +| 缓存位置 | `WindowConsultationAgentContext.calculationCache`,不是工厂局部 `let`。重建 tools 仍共享 | +| 预跑 | `precomputeWindowConsultation(ctx, { question, writer })` 走同一 `execute` 体,记账 `consultationToolCompleted` / `successCount === 1` / 回执 `run-jyotish-window-consultation` | +| 时机 | `streamAgentResponse` 的 `warmup`:`run.started` 与 skill 事件之后、`consumeAttempt` 之前 | +| 注入 | 预跑成功后往 `baseMessages` 追加 `windowPrecomputedPacketMessage` | +| 失败 | warmup 吞掉错误,合同保持红;模型仍可调工具;既有 retry + 降级仍在 | +| 本命 | 不预跑 | + +## 测试 + +| 项 | 结果 | +| --- | --- | +| 预跑成功 + 模型不调工具 | 合同绿,回执有窗口工具 completed 步,正文送达,无降级说明 | +| 预跑成功 + 模型再调工具 | 缓存命中,`consultationToolSuccessCount === 1` | +| 预跑失败 + 模型只写字 | 仍降级,不是静默成功 | +| 源码合同 | 窗口 Agent 仍 attach 工具;本命工厂仍是局部 `let calculation`;窗口分支有 warmup;本命分支没有 | + +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(120 warning,与基线同量级,未顺手修) | +| 定向 6 个测试文件 | 132 tests / 130 pass / 2 fail。两条失败都是本机 Windows `EPERM` 无法为 live skill 建 symlink,与基线缺口同类 | + +未跑全量 `npm test`(基线 3501 / 31 fail)。Linux 门禁才是验收数字。本机无登录态、无 Chrome。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 3b027804..3a5cb4f1 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -139,7 +139,8 @@ | `TASK-consult-three-channels-fix-20260918.md` | `PROGRESS-consult-three-channels-fix-20260918.md` | 验收修复单:领域上限解耦(945)、31s 预算断言(946)、校正思考分片门(947)、Pass 4 按模式分流(948)、容量算术(949) | 已验收通过(Claude:tsc 0 / lint 0 error / npm test 3491 条 31 红且与基线 `742ffbc6` 同一组、原 12 条红全绿 / `/` 仍 Static / 首屏 js gzip 468,388 B 较基线 +0.02%);review 另出 BUG-950~953 见下一行 | `e32ce624` | | `TASK-consult-pass4-streaming-20260918.md` | `PROGRESS-consult-pass4-streaming-20260918.md` | 验收 review 三轮:Pass 4 一 hold 正文就整段蹦出、逐字流式消失(BUG-950 产品拍板按句放行);无出生分钟模式整段被一句拒绝顶掉、一般知识句一起丢(951 改按句丢弃);该模式下日期不留痕(952);校正流 token 级 thinking 是死链,按 P2 删除并把测试翻转成否定合同(953)。基线 `1e553976` | 待验收 | `cd4775ae` | | `TASK-window-consult-contract-20260918.md` | `PROGRESS-window-consult-contract-20260918.md` | **P0 线上**:申报时段模式**自 2026-08-21 起每轮秒败**(69ms、0 token、模型从未被调用)。服务端日志实证根因:窗口 Agent attach 了 `jyotishSkillBinding()`,但 `windowJyotishInstructions` 从来不含方法块 marker,输入处理器直接 abort(BUG-954)。abort 与「模型没调工具」同码,是它藏四周的原因(955);另含合同未绿不得丢正文(956)、计算不该由模型触发(957,须等 954 上线后另轮)、窗口指令应期冲突(958)。基线 `9cdcf96b` | 已验收通过(Claude:tsc 0 / lint 0 error / npm test 3501 条 31 红同基线 / `/` Static / gzip 无变化;实跑确认窗口 Agent 指令已含 marker);review 另出 BUG-959~961 见下一行 | `5b6abc23` | -| `TASK-contract-degraded-pass4-20260918.md` | `PROGRESS-contract-degraded-pass4-20260918.md` | 验收 review:BUG-956 新增的降级交付路径绕过 Pass 4,保证句原样送达(BUG-959);`uncontractedText` 跨 attempt 不清零,同一轮正文说两遍(960);降级后还空跑一轮 compose(961)。基线 `877128ce` | 待验收 | `ae2e46a6` | +| `TASK-contract-degraded-pass4-20260918.md` | `PROGRESS-contract-degraded-pass4-20260918.md` | 验收 review:BUG-956 新增的降级交付路径绕过 Pass 4,保证句原样送达(BUG-959);`uncontractedText` 跨 attempt 不清零,同一轮正文说两遍(960);降级后还空跑一轮 compose(961)。基线 `877128ce` | 待验收 | `e07d5190` | +| `TASK-window-consult-contract-20260918.md` §5 | `PROGRESS-window-consult-precompute-20260918.md` | BUG-957:申报时段计算改为服务端预跑并走同请求缓存。产品确认 BUG-954 已在 staging 验证,本轮单独做。不得 `toolChoice: "required"`,本命不预跑 | 待验收 | `59788ac6` | | `TASK-chat-markdown-list-20260918.md` | — | 真机排版反馈:聊天正文列表**没有项目符号**(Tailwind v4 preflight 清了 `list-style`,`.markdown-list` 没恢复,BUG-962);`promoteDefinitionLists` 把四标题口语体的散文误判成并列项,三段正文被改写成列表(BUG-963,判据太松,收紧而非删除——BUG-356 的场景要留);思考条与正文之间实测 56px=报告 grid gap 24 + 首标题 margin-top 32,后者是被特指度压掉的「首元素清零」规则(BUG-964)。基线 `ea0280c1` | 待领取 | — | | `TASK-secondary-page-entry-20260918.md` | — | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 比对自愈。基线 `41902067` | 待领取 | — | | `TASK-account-dialog-inert-20260918.md` | — | 真机:账户弹窗打开后整个弹窗点不动、退出登录做不了,刷新依旧——`e4e73f56` 把 `SidebarInset` 搬进 layout 后 `inert={modalOpen}` 罩住了没有 portal 的 `AccountDialogOverlay`(BUG-968,P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待领取 | — | diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 62380dde..fb1b468b 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -38,7 +38,7 @@ import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; 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 { consultationPublicActivityEvent, streamAgentResponse } from "@/lib/stream-agent-response"; import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events"; import { consultationComposePrompt, consultationContinuePrompt, natalConsultationThinkingPlan, type PublicThinkingSection } from "@/lib/consultation-thinking-plan"; import { @@ -51,6 +51,8 @@ import { consultationWindowPrepareStep, createConsultationAgentContext, createWindowConsultationAgentContext, + precomputeWindowConsultation, + windowPrecomputedPacketMessage, consultationModelStepTelemetry, consultationStepBudgetReceipt, createConsultationRuntimeHooks, @@ -1039,7 +1041,6 @@ export async function POST(request: Request) { state, }); const agent = getWindowJyotishAgent(selectedModel, agentContext); - const result = await streamWithOverflowRetry(agent, windowStreamOptions); const retry = async () => { const retried = await agent.stream([ ...baseMessages, @@ -1096,7 +1097,32 @@ export async function POST(request: Request) { requestId, sideEvent: titleSideEvent, state, - stream: result.fullStream, + warmup: async (send) => { + try { + const packet = await precomputeWindowConsultation(agentContext, { + question: resolvedQuestion.modelQuestion, + writer: { + custom: async (chunk) => { + if (!chunk || typeof chunk !== "object") return; + const value = chunk as { type?: unknown; data?: unknown }; + if (value.type !== "data-jyotish-activity") return; + const event = consultationPublicActivityEvent(value.data); + if (event) send(event); + }, + }, + }); + baseMessages = [ + ...baseMessages, + { role: "user" as const, content: windowPrecomputedPacketMessage(packet) }, + ]; + } catch { + // Leave the contract red; the model may still call the tool. + } + }, + stream: async () => { + const streamed = await streamWithOverflowRetry(agent, windowStreamOptions); + return streamed.fullStream; + }, requireTool: true, retry, retryForAnswer, diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 00a48096..0c5dffc5 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -61,7 +61,9 @@ type EventOptions = { state?: ConsultationRuntimeState; }; -function activity(value: unknown): ConsultationAgentPublicEvent | null { +export function consultationPublicActivityEvent( + value: unknown, +): Extract | null { if (!value || typeof value !== "object") return null; const data = value as { phase?: unknown; label?: unknown }; const phase = publicActivityPhaseSchema.safeParse(data.phase); @@ -224,7 +226,7 @@ function mapChunk( ): ConsultationAgentPublicEvent[] { const payload = chunk.payload ?? {}; if (chunk.type === "data-jyotish-activity") { - const event = activity(chunk.data); + const event = consultationPublicActivityEvent(chunk.data); return event ? [event] : []; } if (chunk.type === "tool-call") { @@ -311,9 +313,16 @@ export type ThinkFinding = Readonly<{ text?: string; }>; +type AgentStreamSource = ChunkStream | (() => ChunkStream | Promise); + +async function resolveAgentStream(stream: AgentStreamSource): Promise { + return typeof stream === "function" ? await stream() : stream; +} + type StreamAgentResponseOptions = EventOptions & { state: ConsultationRuntimeState; - stream: ChunkStream; + stream: AgentStreamSource; + warmup?: (send: (event: ConsultationAgentPublicEvent) => void) => Promise; transformText?: (text: string) => string; requireTool: boolean; retry?: () => Promise; @@ -744,7 +753,11 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { flushThinkingPlan(controller); try { let deliveredDegraded = false; - await consumeAttempt(controller, options.stream, { + if (options.warmup) { + await options.warmup((event) => send(controller, event)); + flushThinkingPlan(controller); + } + await consumeAttempt(controller, await resolveAgentStream(options.stream), { drainSpoken: Boolean(options.composeAnswer), }); if (!contractReady(options) && options.retry) { diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 50e970e4..4b6e0dd2 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -297,6 +297,10 @@ export type ConsultationAgentContext = Readonly<{ now?: () => number; }>; +export type WindowConsultationCalculationCache = { + current: Promise> | null; +}; + export type WindowConsultationAgentContext = Readonly<{ userId: string; sessionId: string; @@ -310,14 +314,41 @@ export type WindowConsultationAgentContext = Readonly<{ now?: () => number; fetchWindowChart?: typeof fetchDeclaredWindowChart; runRangeReading?: typeof runV9RangeReading; + calculationCache?: WindowConsultationCalculationCache; }>; export function createConsultationAgentContext(context: ConsultationAgentContext) { return Object.freeze(context); } -export function createWindowConsultationAgentContext(context: WindowConsultationAgentContext) { - return Object.freeze(context); +export function createWindowConsultationAgentContext( + context: Omit & { + calculationCache?: WindowConsultationCalculationCache; + }, +) { + return Object.freeze({ + ...context, + calculationCache: context.calculationCache ?? { current: null }, + }); +} + +function ensureWindowCalculationCache(ctx: WindowConsultationAgentContext): WindowConsultationCalculationCache { + if (ctx.calculationCache) return ctx.calculationCache; + const created: WindowConsultationCalculationCache = { current: null }; + (ctx as { calculationCache: WindowConsultationCalculationCache }).calculationCache = created; + return created; +} + +type WindowToolExecuteContext = { + writer?: { custom?: (value: unknown) => unknown }; + abortSignal?: AbortSignal; +}; + +export const WINDOW_PRECOMPUTED_PACKET_LEAD = + "服务器已完成本轮声明窗口计算。请根据下面的结果回答;不要把探针时刻写成出生分钟。如仍调用 run-jyotish-window-consultation,会命中同请求缓存,不会再算一次。"; + +export function windowPrecomputedPacketMessage(packet: Record): string { + return `${WINDOW_PRECOMPUTED_PACKET_LEAD}\n${JSON.stringify(packet)}`; } /** @@ -757,145 +788,161 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { return { "run-jyotish-consultation": consultationTool }; } +async function executeWindowConsultation( + ctx: WindowConsultationAgentContext, + input: { question: string }, + context: WindowToolExecuteContext, +) { + const cache = ensureWindowCalculationCache(ctx); + if (cache.current) return cache.current; + ctx.state.consultationToolStarted = true; + ctx.state.consultationToolCallCount += 1; + const now = ctx.now ?? Date.now; + const startedAt = now(); + const currentCalculation = (async () => { + try { + await context.writer?.custom?.({ + type: "data-jyotish-activity", + data: { + phase: "chart-calculation", + label: "正在比较声明出生窗口内的稳定层", + }, + }); + const packet = await (ctx.fetchWindowChart ?? fetchDeclaredWindowChart)({ + window: ctx.declaredWindow, + signal: context.abortSignal ?? ctx.abortSignal, + }); + let minuteSensitiveThemes: string[] = []; + try { + const reading = await (ctx.runRangeReading ?? runV9RangeReading)({ + baselineBirthSnapshot: { + birth_date: ctx.declaredWindow.truth.birthDate, + latitude: ctx.declaredWindow.truth.latitude, + longitude: ctx.declaredWindow.truth.longitude, + timezone_offset: ctx.declaredWindow.truth.timezoneOffset, + ayanamsa: ctx.declaredWindow.toolInput.ayanamsa, + }, + candidateRange: { + start_time: ctx.declaredWindow.toolInput.rangeStart, + end_time: ctx.declaredWindow.toolInput.rangeEnd, + }, + birthTimeAccuracy: "approximate", + }); + minuteSensitiveThemes = [...(reading?.sensitiveThemes ?? [])]; + } catch { + minuteSensitiveThemes = []; + } + const varyingLagna = Array.isArray(packet.varying_layers.ascendant_signs) + && packet.varying_layers.ascendant_signs.length > 1; + ctx.state.workflowReceipt = { + route: "declared-birth-window", + status: packet.answer_policy.can_answer_direction ? "degraded" : "blocked", + preciseTiming: "blocked", + missingLayers: [ + ...packet.blocked_layers, + ...(varyingLagna ? ["single-lagna"] : []), + ], + ...(minuteSensitiveThemes.length > 0 ? { minuteSensitiveThemes } : {}), + }; + ctx.state.techniqueTruth = "declared-window"; + ctx.state.consultationToolDurationMs = now() - startedAt; + await context.writer?.custom?.({ + type: "data-jyotish-activity", + data: { phase: "evidence-validation", label: "正在核对窗口稳定层" }, + }); + ctx.state.consultationToolCompleted = true; + ctx.state.consultationToolSuccessCount += 1; + appendConsultationRuntimeStep(ctx.state, { + kind: "tool", + name: "run-jyotish-window-consultation", + status: "completed", + durationMs: ctx.state.consultationToolDurationMs, + }); + const modelContext = { + question: ctx.plan?.userIntent ?? input.question, + theme: ctx.theme ?? ctx.plan?.requestedDomains[0], + declared_range: packet.declared_range, + probe_count: packet.probe_count, + probes: packet.probes, + stable_layers: packet.stable_layers, + varying_layers: packet.varying_layers, + blocked_layers: packet.blocked_layers, + answer_policy: { + ...packet.answer_policy, + minute_sensitive_themes: minuteSensitiveThemes, + }, + status: ctx.state.workflowReceipt.status, + evidence_contract: { + answer_policy: { + ...packet.answer_policy, + minute_sensitive_themes: minuteSensitiveThemes, + }, + hard_blockers: packet.blocked_layers, + user_facing_limitation: "这是声明出生窗口内的稳定结构,不是单一出生分钟的本命盘。这只是粗看。", + }, + rectification: { boundary: "not_auto_rectified" }, + }; + ctx.state.thinkingPlan = windowConsultationThinkingPlan(); + ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows([ + { + technique: "Declared birth window probes", + status: "executed", + note: `${packet.probe_count} probes inside ${packet.declared_range.start}–${packet.declared_range.end}`, + }, + { + technique: "Stable planet signs", + status: Object.keys(packet.stable_layers.planet_signs).length > 0 ? "executed" : "blocked", + }, + { + technique: "Lagna / houses", + status: varyingLagna ? "blocked" : packet.stable_layers.ascendant_sign ? "executed" : "blocked", + }, + { + technique: "Vimshottari / Narayana boundaries", + status: "blocked", + note: "Window probes are not a birth minute", + }, + ]); + return modelContext; + } catch (error) { + ctx.state.consultationToolDurationMs = now() - startedAt; + appendConsultationRuntimeStep(ctx.state, { + kind: "tool", + name: "run-jyotish-window-consultation", + status: "failed", + durationMs: ctx.state.consultationToolDurationMs, + failureCode: consultationToolFailureCode(error), + }); + throw error; + } + })(); + cache.current = currentCalculation; + try { + return await currentCalculation; + } catch (error) { + if (cache.current === currentCalculation) cache.current = null; + throw error; + } +} + +export async function precomputeWindowConsultation( + ctx: WindowConsultationAgentContext, + options: { question: string; writer?: WindowToolExecuteContext["writer"] }, +) { + return executeWindowConsultation(ctx, { question: options.question }, { + writer: options.writer, + abortSignal: ctx.abortSignal, + }); +} + export function createWindowConsultationTools(ctx: WindowConsultationAgentContext) { - let calculation: Promise> | null = null; const windowTool = createTool({ id: "run-jyotish-window-consultation", description: "Load the server-owned declared birth-window evidence packet. Send only the question. Birth data is server-bound and must never be supplied. Probe clocks are samples, never a birth minute. The packet's answer_policy is the output contract: can_answer_precise_timing is always false; only stable_layers may be claimed as personal structure; varying_layers must be named as a set of possibilities.", inputSchema: z.object({ question: z.string().trim().min(1).max(500), }).strict(), - execute: async (input, context) => { - if (calculation) return calculation; - ctx.state.consultationToolStarted = true; - ctx.state.consultationToolCallCount += 1; - const now = ctx.now ?? Date.now; - const startedAt = now(); - const currentCalculation = (async () => { - try { - await context.writer?.custom({ - type: "data-jyotish-activity", - data: { - phase: "chart-calculation", - label: "正在比较声明出生窗口内的稳定层", - }, - }); - const packet = await (ctx.fetchWindowChart ?? fetchDeclaredWindowChart)({ - window: ctx.declaredWindow, - signal: context.abortSignal ?? ctx.abortSignal, - }); - let minuteSensitiveThemes: string[] = []; - try { - const reading = await (ctx.runRangeReading ?? runV9RangeReading)({ - baselineBirthSnapshot: { - birth_date: ctx.declaredWindow.truth.birthDate, - latitude: ctx.declaredWindow.truth.latitude, - longitude: ctx.declaredWindow.truth.longitude, - timezone_offset: ctx.declaredWindow.truth.timezoneOffset, - ayanamsa: ctx.declaredWindow.toolInput.ayanamsa, - }, - candidateRange: { - start_time: ctx.declaredWindow.toolInput.rangeStart, - end_time: ctx.declaredWindow.toolInput.rangeEnd, - }, - birthTimeAccuracy: "approximate", - }); - minuteSensitiveThemes = [...(reading?.sensitiveThemes ?? [])]; - } catch { - minuteSensitiveThemes = []; - } - const varyingLagna = Array.isArray(packet.varying_layers.ascendant_signs) - && packet.varying_layers.ascendant_signs.length > 1; - ctx.state.workflowReceipt = { - route: "declared-birth-window", - status: packet.answer_policy.can_answer_direction ? "degraded" : "blocked", - preciseTiming: "blocked", - missingLayers: [ - ...packet.blocked_layers, - ...(varyingLagna ? ["single-lagna"] : []), - ], - ...(minuteSensitiveThemes.length > 0 ? { minuteSensitiveThemes } : {}), - }; - ctx.state.techniqueTruth = "declared-window"; - ctx.state.consultationToolDurationMs = now() - startedAt; - await context.writer?.custom({ - type: "data-jyotish-activity", - data: { phase: "evidence-validation", label: "正在核对窗口稳定层" }, - }); - ctx.state.consultationToolCompleted = true; - ctx.state.consultationToolSuccessCount += 1; - appendConsultationRuntimeStep(ctx.state, { - kind: "tool", - name: "run-jyotish-window-consultation", - status: "completed", - durationMs: ctx.state.consultationToolDurationMs, - }); - const modelContext = { - question: ctx.plan?.userIntent ?? input.question, - theme: ctx.theme ?? ctx.plan?.requestedDomains[0], - declared_range: packet.declared_range, - probe_count: packet.probe_count, - probes: packet.probes, - stable_layers: packet.stable_layers, - varying_layers: packet.varying_layers, - blocked_layers: packet.blocked_layers, - answer_policy: { - ...packet.answer_policy, - minute_sensitive_themes: minuteSensitiveThemes, - }, - status: ctx.state.workflowReceipt.status, - evidence_contract: { - answer_policy: { - ...packet.answer_policy, - minute_sensitive_themes: minuteSensitiveThemes, - }, - hard_blockers: packet.blocked_layers, - user_facing_limitation: "这是声明出生窗口内的稳定结构,不是单一出生分钟的本命盘。这只是粗看。", - }, - rectification: { boundary: "not_auto_rectified" }, - }; - ctx.state.thinkingPlan = windowConsultationThinkingPlan(); - ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows([ - { - technique: "Declared birth window probes", - status: "executed", - note: `${packet.probe_count} probes inside ${packet.declared_range.start}–${packet.declared_range.end}`, - }, - { - technique: "Stable planet signs", - status: Object.keys(packet.stable_layers.planet_signs).length > 0 ? "executed" : "blocked", - }, - { - technique: "Lagna / houses", - status: varyingLagna ? "blocked" : packet.stable_layers.ascendant_sign ? "executed" : "blocked", - }, - { - technique: "Vimshottari / Narayana boundaries", - status: "blocked", - note: "Window probes are not a birth minute", - }, - ]); - return modelContext; - } catch (error) { - ctx.state.consultationToolDurationMs = now() - startedAt; - appendConsultationRuntimeStep(ctx.state, { - kind: "tool", - name: "run-jyotish-window-consultation", - status: "failed", - durationMs: ctx.state.consultationToolDurationMs, - failureCode: consultationToolFailureCode(error), - }); - throw error; - } - })(); - calculation = currentCalculation; - try { - return await currentCalculation; - } catch (error) { - if (calculation === currentCalculation) calculation = null; - throw error; - } - }, + execute: async (input, context) => executeWindowConsultation(ctx, input, context as WindowToolExecuteContext), }); return { "run-jyotish-window-consultation": windowTool }; } diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index f744d01a..80367a41 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -180,6 +180,7 @@ This request has a declared birth window, not a single birth minute. Never inven ${jyotishSkillMethodCoreBlock} The bound skill method is this product's answering contract. Window answers do not use the natal Level 2 report skeleton; the window output contract below takes priority over any report-template or precise-timing language in the bound method. Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn. +If this turn already includes a server-owned window packet, use it and answer; you may still call the tool, which hits the same-request cache. Timing questions still require calling the tool first. Answer from stable_layers as directional structure, and name which parts need a birth minute. Do not skip the calculation or refuse the whole question because precise timing is unavailable. Treat the tool result's answer_policy as a hard output contract: - can_answer_precise_timing is always false. Do not state a month, date, dasha boundary, or guaranteed timing outcome. diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index 8fc67a4e..5d2a0361 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -21,6 +21,9 @@ import { createConsultationRuntimeHooks, createConsultationTools, createConsultationRuntimeState, + createWindowConsultationAgentContext, + createWindowConsultationTools, + precomputeWindowConsultation, domainFitsRunBudget, executableDomainPlan, publicConsultationRuntimeSteps, @@ -41,6 +44,7 @@ import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/l import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; import { collectAgentPublicEvents, + consultationPublicActivityEvent, CONTRACT_DEGRADED_NOTE, streamAgentResponse, } from "../src/lib/stream-agent-response.ts"; @@ -1482,6 +1486,216 @@ test("degraded delivery does not start a compose pass (BUG-961)", async () => { assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); +const declaredWindowFixture = { + name: "测试", + toolInput: { + year: 1997, + month: 8, + day: 8, + city: "邯郸", + lat: 36.4, + lon: 114.2, + tz: 8, + ayanamsa: "raman" as const, + rangeStart: "14:00", + rangeEnd: "18:00", + }, + truth: { + birthDate: "1997-08-08", + birthTimeSource: "period_only" as const, + birthTimePeriod: "afternoon" as const, + birthTimeStatus: "reported" as const, + wrapsMidnight: false, + placeLabel: "邯郸", + placeCodes: { countryCode: "CN", provinceCode: null, cityCode: null, districtCode: null }, + placeId: null, + placeType: null, + placeProvider: null, + timezoneId: null, + timezoneSource: null, + latitude: 36.4, + longitude: 114.2, + timezoneOffset: 8, + }, +}; + +function windowPacket() { + return { + declared_range: { start: "14:00", end: "18:00", wraps_midnight: false }, + probe_count: 3, + probes: [ + { clock: "14:00", role: "range_start" as const }, + { clock: "16:00", role: "interior" as const }, + { clock: "18:00", role: "range_end" as const }, + ], + stable_layers: { planet_signs: { sun: "Cancer" } }, + varying_layers: { ascendant_signs: ["Libra", "Scorpio"] }, + blocked_layers: ["precise-timing"], + answer_policy: { + can_answer_direction: true, + can_answer_precise_timing: false as const, + birth_time_confidence: "declared_window" as const, + candidate_is_confirmed: false as const, + }, + result_hash: "fictional-window", + }; +} + +function makeWindowCtx(options: { fetchWindowChart?: () => Promise> } = {}) { + const state = createConsultationRuntimeState(); + const ctx = createWindowConsultationAgentContext({ + userId: "u", + sessionId: "s", + requestId: "r-window-precompute", + consultationMode: "declared_birth_window", + declaredWindow: declaredWindowFixture, + state, + fetchWindowChart: options.fetchWindowChart ?? (async () => windowPacket()), + runRangeReading: async () => { + throw new Error("range-reading-unused-in-this-test"); + }, + }); + return { ctx, state, tools: createWindowConsultationTools(ctx) }; +} + +async function writerToSend(send: (event: { type: "activity"; phase: "chart-calculation" | "evidence-validation" | "loading-method" | "answer-composition"; label: string }) => void) { + return { + custom: async (chunk: unknown) => { + if (!chunk || typeof chunk !== "object") return; + const value = chunk as { type?: unknown; data?: unknown }; + if (value.type !== "data-jyotish-activity") return; + const event = consultationPublicActivityEvent(value.data); + if (event) send(event); + }, + }; +} + +test("window precompute greens the contract without a model tool call (BUG-957)", async () => { + const { ctx, state } = makeWindowCtx(); + async function* chunks() { + yield { type: "text-delta", payload: { text: "方向上可以推进。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, requireTool: true, + pass4Mode: "declared_birth_window", + warmup: async (send) => { + await precomputeWindowConsultation(ctx, { + question: "未来半年事业如何", + writer: await writerToSend(send), + }); + }, + stream: chunks(), + toolStatus: () => "degraded", + receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(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.match(answer, /方向上可以推进/); + assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), false); + assert.equal(state.consultationToolSuccessCount, 1); + assert.equal(state.consultationToolCompleted, true); + assert.ok(state.steps.some((step) => + step.kind === "tool" && step.name === "run-jyotish-window-consultation" && step.status === "completed")); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); + assert.ok(events.some((event) => { + const item = event as { type?: string; phase?: string }; + return item.type === "activity" && item.phase === "chart-calculation"; + })); +}); + +test("window precompute and a later tool call share one cache (BUG-957)", async () => { + let fetches = 0; + const { ctx, state, tools } = makeWindowCtx({ + fetchWindowChart: async () => { + fetches += 1; + return windowPacket(); + }, + }); + await precomputeWindowConsultation(ctx, { question: "事业如何" }); + const first = await tools["run-jyotish-window-consultation"].execute!( + { question: "事业如何" }, + toolContext, + ); + const reconstructed = createWindowConsultationTools(ctx); + const second = await reconstructed["run-jyotish-window-consultation"].execute!( + { question: "再问一次" }, + toolContext, + ); + assert.equal(fetches, 1); + assert.equal(state.consultationToolCallCount, 1); + assert.equal(state.consultationToolSuccessCount, 1); + assert.deepEqual(first, second); +}); + +test("window precompute failure still degrades when the model writes without a tool (BUG-957)", async () => { + const { ctx, state } = makeWindowCtx({ + fetchWindowChart: async () => { + throw new Error("window_unavailable"); + }, + }); + async function* chunks() { + yield { type: "text-delta", payload: { text: "方向上可以推进。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, requireTool: true, + pass4Mode: "declared_birth_window", + warmup: async () => { + await precomputeWindowConsultation(ctx, { question: "事业如何" }).catch(() => {}); + }, + stream: chunks(), + toolStatus: () => "blocked", + receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(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.match(answer, /方向上可以推进/); + assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true); + assert.equal(state.consultationToolSuccessCount, 0); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); +}); + +test("warmup runs after skill events and before the model stream (BUG-957)", async () => { + const state = toolOnlyRunState(); + const order: string[] = []; + async function* chunks() { + order.push("stream"); + yield { type: "text-delta", payload: { text: "方向上可以推进。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, requireTool: true, + warmup: async (send) => { + order.push("warmup"); + send({ type: "activity", phase: "chart-calculation", label: "正在比较声明出生窗口内的稳定层" }); + }, + stream: async () => { + order.push("open"); + return chunks(); + }, + toolStatus: () => "ready", + receipt: () => receipt(state), + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.deepEqual(order, ["warmup", "open", "stream"]); + const types = events.map((event) => (event as { type?: string }).type); + const skillIdx = types.lastIndexOf("skill.completed"); + const activityIdx = types.findIndex((type, index) => + type === "activity" && index > skillIdx && (events[index] as { phase?: string }).phase === "chart-calculation"); + const answerIdx = types.indexOf("answer.delta"); + assert.ok(skillIdx >= 0 && activityIdx > skillIdx && answerIdx > activityIdx); +}); + test("skill-binding abort is a distinct receipt step from a missing tool call (BUG-955)", async () => { const bindingState = createConsultationRuntimeState(); let bindingError = ""; diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index ab1b4e06..8a4a585a 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -347,3 +347,28 @@ test("agentic consultation is the safe default and legacy is explicit rollback", assert.match(route, /mode === "legacy"/); assert.match(route, /mode !== "canary"/); }); + +test("window calculation is precomputed on the agent context cache (BUG-957)", () => { + const windowFactory = tools.slice(tools.indexOf("export function createWindowConsultationTools")); + const natalFactory = tools.slice( + tools.indexOf("export function createConsultationTools"), + tools.indexOf("export async function precomputeWindowConsultation"), + ); + assert.match(tools, /export async function precomputeWindowConsultation/); + assert.match(tools, /calculationCache/); + assert.doesNotMatch(windowFactory, /let calculation/); + assert.match(natalFactory, /let calculation/); + assert.match(mastra, /tools: createWindowConsultationTools\(context\)/); + assert.match(mastra, /If this turn already includes a server-owned window packet/); + const windowBranch = route.slice( + route.indexOf("if (shouldRunDeclaredWindowWorkflow"), + route.indexOf("if (!prepared.serverChart)"), + ); + const natalBranch = route.slice(route.indexOf("if (!prepared.serverChart)")); + assert.match(windowBranch, /precomputeWindowConsultation/); + assert.match(windowBranch, /warmup:/); + assert.match(windowBranch, /windowPrecomputedPacketMessage/); + assert.doesNotMatch(windowBranch, /toolChoice:\s*"required"/); + assert.doesNotMatch(natalBranch, /precomputeWindowConsultation/); + assert.doesNotMatch(natalBranch, /warmup:/); +});