diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 536e57b5..37de57dd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4149,3 +4149,37 @@ - 相关记录:BUG-271(本条更正了它对触发路径的归因,并接手枚举化之后新出现的拒绝路径)、BUG-277(本条是它的上游)、BUG-267 与 BUG-270(同为「同一合同两份声明、没有一份权威」)、BUG-255(同为模型参数被 schema 拒白扔步数) - 复发自:无 - 修复版本:待提交 + +## BUG-279 | 模型手上从来没有副运边界:证据包读的是引擎从未写过的键,而精确应期的许可由另一个同名对象签发 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:`frontend/src/mastra/consultation-workflow.ts` 的 `local_layers` 与 `projectTimingEvidence`、`scripts/jyotish_api_server.py` 的 `_attach_local_consultation_layers` 与 `_build_consumer_context`、`scripts/unified_consultation_orchestrator.py` 的证据分节表。所有 `/api/consult` 个人盘运行。 +- 用户现象:staging 手测中两次成功运行(「未来一年」「财运」)的回答都出现「副运细节与行运触发未在本轮完整计算」,只能给到大运级别的方向;而同一次运行的回执写着 `preciseTiming: "allowed"`。用户直接问了这两句为什么对不上,以及副运为什么没算。 +- 触发条件:任何依赖应期的回答。恒定发生。 +- 根因:一个名字指着两个对象。模型侧 `local_layers.dasha_boundaries` 读 `chart.modules.dasha_boundaries`,而这个键在整个仓库里从未被赋值过——`_attach_local_consultation_layers` 只挂 varga_full / arudha_padas / narayana_dasha / ashtakavarga / kp_cusps——所以该字段恒为 `undefined`,投影时整段消失,模型手上只剩 `chart.dasha.periods`,也就是 6 到 20 年一段的大运列表。证据门里同名的分节 `dasha_boundaries` 读的却是 `modules.dasha`(大运),它当然是 `used`,于是 `timing_layers_ready` 成立、`can_answer_precise_timing` 为真。回执因此签发了「能给到月份」的许可,而模型连一条副运边界都没有,只能在回答里如实说副运没算全。两侧各自自洽:读的一侧永远拿不到数据,判的一侧永远看得见数据,中间没有任何断言比较过这两个名字是不是同一个对象。BUG-268 让 `skillReferenceReads` 可见时曾怀疑是模型没翻方法文档,本条说明缺的是数据本身。 +- 修复:三处。其一,服务端真的算出副运并挂到 `modules.dasha_sub_periods`:从 `chart.dasha.periods` 里取出覆盖参考日的那一段大运,用 `dasha_analyzer.build_antardasha` 按比例细分为 9 段。刻意不复用现成的 `_compute_vimshottari_analysis_layer`(它从月亮黄经另建一条时间线):两条线出自同一引擎、数值几乎一样,但「几乎」意味着模型手上会出现两套大运日期且无从判断该引用哪套;从包里已经展示给模型的 periods 上切,副运边界必然落在模型看得见的大运边界之内。其二,证据包新增独立分节 `dasha_sub_periods`(源路径写明 `modules.dasha_sub_periods`),精确应期改为同时要求 `dasha_boundaries` + `dasha_sub_periods` + `narayana_dasha`——大运列表只能定位十年,本就不该单独签发月份级许可。其三,模型侧字段随之改名为 `local_layers.dasha_sub_periods`,与服务端实际写入的键对齐;Agent 指令补一句:该层在场就用它的边界、不得再说副运没算,缺席则一句话说明。 +- 验证:真实排盘端到端跑通(1990-05-12 北京,参考日 2026-08-18):`current.mahadasha` 与 `chart.dasha.periods` 里的 Sun 段逐字段相同(2024-07-03→2030-07-03),当前副运为 Jupiter 2026-07-21→2027-05-09,9 段副运首尾正好贴合大运首尾,`local_consultation_layers.diagnostics` 为空,分节 `used`,`can_answer_precise_timing` 为真。`tests/test_consultation_consumer_context.py` 21 项通过(新增 4 项:副运边界必须切自包里展示的 periods 且覆盖参考日;跨语言键名守卫;只缺副运时精确应期必须被拒;副运在场时放行)。测试基准盘原先只写了 `{'current_md': 'Sun'}`,已改为用 `compute_vimshottari_timeline` 派生真实 periods,否则新层在 fixture 上根本不会被执行。前端除数据库套件(本机无 Postgres)外 1633 项通过,`tsc --noEmit` 与 `eslint` 清洁。 +- 待跟进:Pratyantardasha(第三层)与行运触发仍未计算,层内 `summary` 已明说这条边界,所以「哪一周」这类问题仍只能答到副运级别。`_compute_vimshottari_analysis_layer` 那条从月亮黄经另建的时间线仍留在 dasha 接口路径上,未与本层合并。未做的验证:没有在 staging 上复看真实回答是否不再出现「副运没算全」。 +- 防复发:跨语言的字段名不能靠人眼对齐。一端读 `modules.X` 而另一端从不写 `X`,两侧测试都不会失败——读的一侧只看到 `undefined` 被投影掉,写的一侧根本不知道有人在读。已加的守卫直接比较两端:把 `local_layers` 里所有 `modules.` 读法抽出来(先剥注释,否则解释性注释里的旧键名会被算成读法),与服务端真实挂载后的 `chart['modules']` 键集合求差,非空即失败。另一条教训是分节名要说清自己是什么:`dasha_boundaries` 既能读成「大运边界」也能读成「所有周期边界」,正是这层歧义让门以为自己检查过副运。 +- 相关记录:BUG-267(同一段代码里另一处「判据没接到权威来源」,精确应期的空判是那轮修的)、BUG-270(同为两端声明不一致且缺跨端断言)、BUG-268(同一批可观测性问题,本条是它排除掉的另一种解释) +- 复发自:无 +- 修复版本:待提交 + +## BUG-280 | 计算成功而模型没写回答时,用户被扣点并只收到一句「没有可展示的回答」 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:`frontend/src/lib/stream-agent-response.ts` 的最终结算段、`frontend/src/app/api/consult/route.ts` 的重试接法。两条 agentic 路径(个人盘与无出生分钟)都在内。 +- 用户现象:staging 手测一次多域运行 `run.completed`,回答全文只有「本次计算已完成,但暂时没有生成可展示的回答。请换一个角度提问……」;回执显示工具 63.5 秒成功、状态 ready。用户问「为什么这次就生成失败了,也没有 run failed」——问的正是这个矛盾:运行报成功、点数照扣、回答没有。 +- 触发条件:契约已绿(skill 已加载且恰好一次成功计算),而模型此后未输出任何非空文本。 +- 根因:兜底文案被当成回答交付并结算。`ensureFinalResponseText()` 在契约绿且输出为空时返回一句固定道歉,随后 `fullOutput` 被替换、`emitted` 置真、`onComplete` 照常调用 `complete_consultation_response`——用户为一句「没有回答」付了费。同时它让下方以「输出为空」为条件的分支全部变成死代码:兜底一定先把 `fullOutput` 填满,所以既有的 `empty_answer` 失败码从未有机会发出,`run.failed` 也就永远不会出现,这正是用户看到「没有 run failed」的原因。至于模型为何不写,本轮无法定案:最接近的证据是既有回归里同形状运行的 `modelFinishReason` 为 `tool-calls`(还想调工具却用尽了 `AGENT_MAX_STEPS = 8`),且已确认那次不是超时(工具 63.5 秒成功,110 秒预算尚余约 46 秒)。修复因此不押注单一根因,而是覆盖「计算成功、模型没写」这一整类。 +- 修复:两步。其一,先再问一次:契约已绿而输出为空时,用同一份 baseMessages 追加一句「上一轮没有输出任何回答文本,请直接给出回答」重跑一轮模型循环,并记一条 `answer-retry` 校验步。这轮重试**不关闭工具**——请求级缓存在 `execute` 之前就短路返回(`if (calculation) return calculation`,不计数、不记步、不重算,因此单次计算边界不受影响),模型再调一次工具即可原样取回同一份证据;反过来若用 `toolChoice: "none"`,模型手上没有任何计算结果,只能凭空写。新一轮也拿到全新的 `maxSteps` 预算,这正是「上一轮因步数耗尽而沉默」所需要的。其二,重试后仍为空则以 `empty_answer` 失败:不再交付兜底文案,`run.failed` 带回执与「不会扣点」的文案,账务走 `cancel`。固定道歉文案与 `ensureFinalResponseText()` 一并删除。 +- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 44 项通过。新增两项:计算成功但无文本时触发一次 answer-retry 并交付重试写出的回答(末步为 `answer-retry`);重试仍沉默则 `run.failed` / `empty_answer`、`onComplete` 不触发、文案含「不会扣点」。改写两项:BUG-277 那条「只有自述」的回归改为断言既不交付也不结算;步数耗尽那条(`modelFinishReason` 为 `tool-calls` 的生产形状)不再断言兜底被结算。计费契约测试改为钉住 4 次 `usages.push(retried.totalUsage)` 与 2 处 `retryForAnswer`(两条路径各有契约重试与回答重试,四次模型调用的 token 都必须计量)。前端除数据库套件外 1633 项通过,`tsc --noEmit` 与 `eslint` 清洁。未做的验证:没有在 staging 上复现一次空回答来观测重试是否救回。 +- 待跟进:仍未取到那次运行的 `[agent-observability]` 日志,`modelFinishReason` 未定案;若日后确认多域运行普遍在第 8 步耗尽,应当调预算而不是靠重试兜。回答重试没有独立时间预算:若上一轮已把 110 秒用尽,它会立即被 abort 并以 `calculation_failed` 结束(同样不扣点),代价是失败码不如 `empty_answer` 精确。 +- 防复发:兜底文案不能既当回答又当结算依据。「保证客户端总能收到点东西」和「这次咨询算完成」是两件事,用同一段文本表达两者,必然出现「没回答也扣点」。空回答的正确出口是失败码加不扣点,而不是一句道歉。另一条:一旦某个兜底把最终变量填满,它下游所有以「该变量为空」为条件的分支都成了死代码——`empty_answer` 就这样被埋了整轮。加兜底时要顺手确认它没有吃掉下游的诊断分支。 +- 相关记录:BUG-277(同一段结算逻辑;它删掉的缓冲正是兜底此前不触发的原因之一)、BUG-214(同为契约门与可见输出的耦合)、BUG-271(同一批「失败在回执里查不到」) +- 复发自:无 +- 修复版本:待提交 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 0b87c1dd..8599d913 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -676,6 +676,14 @@ export async function POST(request: Request) { usages.push(retried.totalUsage); return retried.fullStream; }; + const retryForAnswer = async () => { + const retried = await agent.stream([ + ...baseMessages, + { role: "user" as const, content: "上一轮没有输出任何回答文本。请直接给出这个问题的回答,不要只说明过程。" }, + ], streamOptions); + usages.push(retried.totalUsage); + return retried.fullStream; + }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", @@ -696,6 +704,7 @@ export async function POST(request: Request) { stream: result.fullStream, requireTool: false, retry, + retryForAnswer, continueAfterDisconnect: true, transformText: createBirthTimeModeOutputGuard(consultationMode, false), toolStatus: () => "ready", @@ -744,6 +753,20 @@ export async function POST(request: Request) { usages.push(retried.totalUsage); return retried.fullStream; }; + // The tool caches this request's calculation, so this attempt gets the same + // evidence back without paying for it twice; keeping the tools available is + // what puts that evidence in front of the model at all. + const retryForAnswer = async () => { + const retried = await agent.stream([ + ...baseMessages, + { + role: "user" as const, + content: "服务器计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答;不要只描述过程或工具调用。", + }, + ], streamOptions); + usages.push(retried.totalUsage); + return retried.fullStream; + }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", @@ -764,6 +787,7 @@ export async function POST(request: Request) { stream: result.fullStream, requireTool: true, retry, + retryForAnswer, continueAfterDisconnect: true, transformText: (text) => createBirthTimeModeOutputGuard( consultationMode, diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 47cf1042..e0da3fa6 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -36,14 +36,6 @@ async function* readChunks(stream: ChunkStream): AsyncIterable { } type Status = "ready" | "degraded" | "blocked"; -export const ENSURE_FINAL_RESPONSE_FALLBACK = - "本次计算已完成,但暂时没有生成可展示的回答。请换一个角度提问,我会基于已完成的计算继续说明。"; - -export function ensureFinalResponseText(output: string, contractIsReady: boolean) { - if (!contractIsReady || /\S/.test(output)) return null; - return ENSURE_FINAL_RESPONSE_FALLBACK; -} - type EventOptions = { runId: string; requestId: string; @@ -221,6 +213,7 @@ type StreamAgentResponseOptions = EventOptions & { transformText?: (text: string) => string; requireTool: boolean; retry?: () => Promise; + retryForAnswer?: () => Promise; continueAfterDisconnect?: boolean; headers?: HeadersInit; onFirstActivity?: () => void | Promise; @@ -263,17 +256,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { async function consumeAttempt(controller: ReadableStreamDefaultController | undefined, stream: ChunkStream) { const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value)); let held = ""; - let attemptOutput = ""; let composingSent = false; const outputText = async (text: string) => { - attemptOutput += text; // Text the model writes before the contract is ready is not the answer: it // is the model narrating its own in-progress or failed tool calls. Holding // it meant a later successful call released that narration as the entire // visible answer, so a run where the model recovered read as a run where it - // explained itself instead of answering. Drop it. attemptOutput still - // records that the model spoke, which is what separates an incomplete - // contract from silence below. + // explained itself instead of answering. Drop it. if (!contractReady(options)) return; held += text; if (!held) return; @@ -306,7 +295,6 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { } } await outputText(visible.finish("")); - return { attemptOutput }; } const body = new ReadableStream({ @@ -314,31 +302,28 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { void (async () => { send(controller, { type: "run.started", runId: options.runId, requestId: options.requestId }); try { - const first = await consumeAttempt(controller, options.stream); + await consumeAttempt(controller, options.stream); 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()); } if (!contractReady(options)) throw new Error("runtime_contract_incomplete"); - const ensuredFinalResponse = ensureFinalResponseText(fullOutput, contractReady(options)); - if (ensuredFinalResponse) { - appendConsultationRuntimeStep(options.state, { kind: "validation", name: "ensure-final-response", status: "completed" }); - if (!firstOutput) { - firstOutput = true; - await options.onFirstOutput?.(); - } - send(controller, { type: "answer.delta", text: ensuredFinalResponse }); - fullOutput = ensuredFinalResponse; - emitted = true; - } - if (!/\S/.test(fullOutput)) { - // attemptOutput records everything the model wrote, including the - // discarded pre-contract narration, so a model that spoke but never - // produced an answer is still distinguished from one that stayed silent. - if (/\S/.test(first.attemptOutput)) throw new Error("runtime_contract_incomplete"); - throw new Error("empty_answer"); + // A run whose calculation succeeded and whose model then wrote nothing + // used to be answered with a fixed apology and billed as a completed + // consultation: the user paid for a sentence saying there was nothing + // to say. Ask once more instead. The calculation is cached for the + // request, so the second attempt re-reads the same evidence without + // recomputing it, and whatever ended the first attempt—an exhausted + // step budget above all—does not carry into a fresh model loop. + 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()); } + // Still nothing to show. Failing is the honest outcome and it is the + // one that does not charge for the run. + if (!/\S/.test(fullOutput)) throw new Error("empty_answer"); settling = true; const receipt = agentExecutionReceiptSchema.parse(options.receipt()); await options.onComplete?.(fullOutput, receipt); @@ -368,7 +353,11 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { send(controller, { type: "run.failed", code, - message: code === "runtime_contract_incomplete" ? "Agent 未完成必要的方法与计算步骤,本次不会扣点。" : "咨询暂时无法完成,本次不会扣点。", + message: code === "runtime_contract_incomplete" + ? "Agent 未完成必要的方法与计算步骤,本次不会扣点。" + : code === "empty_answer" + ? "计算已完成,但这次没有生成回答,本次不会扣点。请再发送一次。" + : "咨询暂时无法完成,本次不会扣点。", ...(failureReceipt ? { receipt: failureReceipt } : {}), }); if (!disconnected) controller.close(); diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 81a1d08c..3f11560c 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -245,7 +245,7 @@ const natalFoundationKeys = new Set([ ]); const timingKeys = new Set([ - "dasha", "dashaboundaries", "narayanadasha", "status", "current", "next", "mahadasha", + "dasha", "dashaboundaries", "dashasubperiods", "narayanadasha", "status", "current", "next", "mahadasha", "antardasha", "pratyantardasha", "currentdasha", "period", "dashaperiod", "periods", "timeline", "boundaries", "boundarycount", "start", "end", "startyear", "endyear", "iscurrent", "activationdescription", "sign", "lord", "planet", "name", "strength", "score", "source", @@ -317,7 +317,7 @@ function projectDomainEvidence(value: unknown): ModelOutputValue { function projectTimingEvidence(context: ReturnType) { return projectAllowlistedTree({ dasha: context.chart.dasha, - dasha_boundaries: context.local_layers.dasha_boundaries, + dasha_sub_periods: context.local_layers.dasha_sub_periods, narayana_dasha: context.local_layers.narayana_dasha, }, timingKeys, { allowAstrologyEntityKeys: true }); } @@ -434,7 +434,10 @@ export function toAgentConsultationContext(data: JsonRecord) { local_layers: { shadbala_boundary: "Shadbala is a locally consistent relative-strength layer; external component-level absolute parity remains partial and must not be stated as closed.", varga_full: modules.varga_full, arudha_padas: modules.arudha_padas, ashtakavarga: modules.ashtakavarga, - dasha_boundaries: modules.dasha_boundaries, narayana_dasha: modules.narayana_dasha, + // `modules.dasha_boundaries` was read here for months and never written by the engine, so + // every answer was composed without sub-period boundaries while the receipt still reported + // precise timing as allowed. The field now names the layer the engine actually attaches. + dasha_sub_periods: modules.dasha_sub_periods, narayana_dasha: modules.narayana_dasha, functional_benefic_malefic: record(data.machine_evidence_packet).functional_benefic_malefic, }, rectification: { diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 7e5b5611..57cf362c 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -60,6 +60,7 @@ When reference_transparency is present: - If should_lead_with_limitations is false, do not lead with limitations. If a limitation is relevant, put it in one short sentence at the end. - Only say the chart calculation failed when evidence_contract.hard_blockers is non-empty. - Never claim D2, D11, D9, D10, A10, UL, or Narayana Dasha is missing when it appears in evidence_contract.available_layers, chart, or local_layers. +- local_layers.dasha_sub_periods carries the antardasha boundaries inside the running mahadasha. When it is present, use those boundaries and never say sub-periods were not calculated; when it is absent, say so once instead of implying the calculation broke. - Treat evidence_contract.answer_policy as a hard output contract. When can_answer_precise_timing is false, provide only direction or structure and do not state a month, date, or guaranteed timing outcome. - Treat answer_policy.deterministic_claims_forbidden_for as a hard prohibition. Do not use a restricted technique to make a deterministic conclusion. reference_only, partial, blocked, research_only_blocked, and partial_registry_only are commercial claim boundaries, not validated capabilities. - Treat rectification.boundary=not_auto_rectified as final: a candidate time or score is not a verified birth time and must not be presented as one. diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 2422b575..7cac6206 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -61,7 +61,10 @@ test("standard consultation awaits real usage before durable response settlement assert.match(consultRoute, /const actualUsage = await usagePayload\(usage\);[\s\S]*p_actual_usage: actualUsage/); assert.match(consultRoute, /function mergeUsage\(usages: Promise\[\]\): Promise \{[\s\S]*Promise\.all\(usages\)/); assert.equal(consultRoute.match(/usages\.push\(result\.totalUsage\)/g)?.length, 2); - assert.equal(consultRoute.match(/usages\.push\(retried\.totalUsage\)/g)?.length, 2); + // Two agentic paths, each with a contract retry and an answer retry: every one + // of those four model calls spends tokens and must be metered. + assert.equal(consultRoute.match(/usages\.push\(retried\.totalUsage\)/g)?.length, 4); + assert.equal(consultRoute.match(/const retryForAnswer = async \(\) => \{/g)?.length, 2); }); test("standard consultation forwards its stable reservation request as the usage event key", async () => { diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index a847ad1e..649ebe30 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -28,8 +28,6 @@ import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/l import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; import { collectAgentPublicEvents, - ENSURE_FINAL_RESPONSE_FALLBACK, - ensureFinalResponseText, streamAgentResponse, } from "../src/lib/stream-agent-response.ts"; @@ -944,8 +942,9 @@ test("text written before the contract completes is dropped, not released later" assert.doesNotMatch(JSON.stringify(events), /域名单有误/); }); -test("a run that only narrated its failures answers with the fallback, not the narration", async () => { +test("a run that only narrated its failures is not delivered or billed as an answer", async () => { const state = createConsultationRuntimeState(); + let completed = 0; async function* chunks() { yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } }; state.jyotishSkillLoaded = true; @@ -961,16 +960,18 @@ test("a run that only narrated its failures answers with the fallback, not the n const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), + onComplete: () => { completed += 1; }, + onError: () => {}, }); 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(""); - // Saying nothing usable is honest; presenting the narration as the reading is not. - assert.equal(answer, ENSURE_FINAL_RESPONSE_FALLBACK); + // Presenting the narration as the reading is dishonest, and so is charging for + // a fixed apology that says there is nothing to say. + assert.equal(completed, 0); + assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false); + const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string }; + assert.equal(failure.code, "empty_answer"); assert.doesNotMatch(JSON.stringify(events), /不被服务端接受/); }); @@ -1092,30 +1093,68 @@ test("incomplete runtime contract fails without saving a successful answer", asy assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1); }); -test("ensures a controlled final response after a successful tool-only run", async () => { +function toolOnlyRunState() { const state = createConsultationRuntimeState(); state.jyotishSkillLoaded = true; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; + return state; +} + +test("a calculation the model never wrote up is asked again instead of apologised for", async () => { + // Production shape: the tool succeeded in 63.5s and the model then produced no + // text at all. That used to be answered with a fixed apology and billed as a + // completed consultation, so the user paid for a sentence saying nothing. + const state = toolOnlyRunState(); let completedOutput = ""; + let retries = 0; async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; } + async function* answerChunks() { + yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } }; + } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), + retryForAnswer: async () => { retries += 1; return answerChunks(); }, onComplete: (output) => { completedOutput = output; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); - assert.equal(ensureFinalResponseText("", true), completedOutput); - assert.match(completedOutput, /计算已完成/); - assert.equal(events.filter((event) => (event as { type?: string }).type === "answer.delta").length, 1); + assert.equal(retries, 1); + assert.equal(completedOutput, "事业方向的判断如下。"); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); - assert.equal(state.steps.at(-1)?.name, "ensure-final-response"); + assert.equal(state.steps.at(-1)?.name, "answer-retry"); +}); + +test("a calculation still unanswered after the retry fails the run rather than billing it", async () => { + const state = toolOnlyRunState(); + let completed = 0; + async function* chunks() { + yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; + } + async function* silentChunks() { + yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "ready", receipt: () => receipt(state), + retryForAnswer: async () => silentChunks(), + onComplete: () => { completed += 1; }, + onError: () => {}, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.equal(completed, 0); + assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false); + const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string; message: string }; + assert.equal(failure.code, "empty_answer"); + assert.match(failure.message, /不会扣点/); }); @@ -1153,22 +1192,25 @@ test("a run that stops while still wanting tools records the exhausted step budg state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; // The production shape: the calculation succeeded, the model never wrote an // answer, and only the fallback text reached the user. Nothing in the public - // event stream said the step budget ran out. + // event stream said the step budget ran out. A model that stopped while it + // still wanted tool calls is the reading of `tool-calls` here, and it is what + // the answer retry exists to recover from. async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; yield { type: "finish", payload: { stepResult: { reason: "tool-calls" }, output: { usage: {} } } }; } - let completedOutput = ""; + let completed = 0; const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), - onComplete: (output) => { completedOutput = output; }, + onComplete: () => { completed += 1; }, + onError: () => {}, }); await response.text(); - assert.equal(completedOutput, ensureFinalResponseText("", true)); + assert.equal(completed, 0); assert.equal(state.modelFinishReason, "tool-calls"); assert.equal(state.modelStepCount, 2); }); diff --git a/frontend/tests/consultation-context.test.ts b/frontend/tests/consultation-context.test.ts index 3ec60579..3ba2339f 100644 --- a/frontend/tests/consultation-context.test.ts +++ b/frontend/tests/consultation-context.test.ts @@ -38,7 +38,11 @@ test("keeps strength, Ashtakavarga, and timing evidence available to the answer assert.match(workflowSource, /shadbala: chart\.shadbala/); assert.match(workflowSource, /shadbala_boundary:/); assert.match(workflowSource, /ashtakavarga: modules\.ashtakavarga/); - assert.match(workflowSource, /dasha_boundaries: modules\.dasha_boundaries/); + // This line used to pin the misspelling: it asserted the packet read + // `modules.dasha_boundaries` without anything checking that the engine writes + // that key. tests/test_consultation_consumer_context.py now compares the names + // across the boundary; this only pins that timing evidence is still passed. + assert.match(workflowSource, /dasha_sub_periods: modules\.dasha_sub_periods/); assert.match(workflowSource, /narayana_dasha: modules\.narayana_dasha/); assert.match(workflowSource, /evidence_contract:/); assert.match(workflowSource, /missing_route_layers: consumerContext\.missing_route_layers/); @@ -68,7 +72,11 @@ test("projects only bounded server-selected evidence to the model", () => { modules: { shadbala: { total: 412 }, ashtakavarga: { total: 28 }, - dasha_boundaries: { next: "2027-03", utc_offset: "private-module-utc-offset" }, + dasha_sub_periods: { + current: { antardasha: { lord: "Saturn", start: "2025-10-17", end: "2027-05-18" } }, + boundary_count: 9, + utc_offset: "private-module-utc-offset", + }, narayana_dasha: { current: "Aries" }, }, }, @@ -141,6 +149,11 @@ test("projects only bounded server-selected evidence to the model", () => { assert.equal(serialized.includes("house_scores"), true); assert.equal(serialized.includes('"dasha"'), true); assert.equal(serialized.includes("narayana_dasha"), true); + // Antardasha boundaries are what an answer needs to name a month at all, and + // they were absent from every packet while this field named a module key the + // engine never wrote. + assert.equal(serialized.includes("dasha_sub_periods"), true); + assert.equal(serialized.includes("2027-05-18"), true); assert.equal(serialized.includes('"status":"verified"'), true); const answerPolicy = output.evidence_contract.answer_policy as Record; assert.equal(answerPolicy.can_answer_precise_timing, false); diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index bbc32958..1caa1918 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -590,6 +590,79 @@ def _consultation_current_age(birth_payload: dict, body: dict) -> float: return max(0.0, (reference.replace(tzinfo=None) - born).total_seconds() / (365.2425 * 86400)) +def _consultation_period_datetime(value) -> datetime | None: + if isinstance(value, datetime): + return value.replace(tzinfo=None) + if isinstance(value, str) and value.strip(): + try: + return datetime.fromisoformat(value.strip().replace('Z', '+00:00')).replace(tzinfo=None) + except ValueError: + return None + return None + + +def _consultation_dasha_sub_periods(chart: dict, body: dict) -> dict | None: + """Cut the running mahadasha into its antardashas. + + A mahadasha is six to twenty years wide, so mahadasha boundaries alone cannot place an answer in + a month or a year; the packet has to carry the sub-period boundaries whose presence it claims + precise timing from. They are cut out of the periods the packet already shows rather than from a + second timeline built from the moon's longitude: both would come from the same engine, but only + this way are the sub-period boundaries guaranteed to sit inside the mahadasha boundaries the + model reads, instead of handing it two nearly-identical sets of dasha dates to choose between. + """ + + dasha = chart.get('dasha') if isinstance(chart.get('dasha'), dict) else {} + periods = dasha.get('periods') if isinstance(dasha.get('periods'), list) else [] + parsed = [] + for period in periods: + if not isinstance(period, dict) or not period.get('lord'): + continue + start = _consultation_period_datetime(period.get('start')) + end = _consultation_period_datetime(period.get('end')) + if start is None or end is None or end <= start: + continue + parsed.append({'lord': str(period['lord']), 'start': start, 'end': end}) + + reference = _consultation_reference_date(body).replace(tzinfo=None) + current = next((period for period in parsed if period['start'] <= reference < period['end']), None) + if current is None: + return None + + analyzer = _load_local_module('dasha_analyzer') + sub_periods = analyzer.build_antardasha(current) + if not sub_periods: + return None + current_sub = analyzer.find_current_sub(sub_periods, reference) + following = next( + (sub_periods[index + 1] for index, period in enumerate(sub_periods) + if period is current_sub and index + 1 < len(sub_periods)), + None, + ) + + def boundary(period: dict) -> dict: + return { + 'lord': period['lord'], + 'start': period['start'].strftime('%Y-%m-%d'), + 'end': period['end'].strftime('%Y-%m-%d'), + } + + return { + 'status': 'ready', + 'source': 'chart.dasha.periods + dasha_analyzer.build_antardasha', + 'method': 'vimshottari_antardasha_proportional', + 'current': {'mahadasha': boundary(current), 'antardasha': boundary(current_sub)}, + **({'next': boundary(following)} if following else {}), + 'boundaries': [boundary(period) for period in sub_periods], + 'boundary_count': len(sub_periods), + 'summary': ( + f"当前 {current['lord']} 大运下的小运为 {current_sub['lord']}," + f"边界 {boundary(current_sub)['start']} 至 {boundary(current_sub)['end']}。" + '更细的 Pratyantardasha 与行运触发不在本层计算范围内。' + ), + } + + def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, body: dict) -> dict: """Attach locally-computable consultation layers before reports/evidence are assembled. @@ -666,6 +739,20 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, except Exception as exc: diagnostics.append({'layer': 'ashtakavarga', 'status': 'unavailable', 'reason': exc.__class__.__name__}) + if not isinstance(modules.get('dasha_sub_periods'), dict): + try: + sub_periods = _consultation_dasha_sub_periods(chart, body) + if isinstance(sub_periods, dict): + modules['dasha_sub_periods'] = sub_periods + else: + diagnostics.append({ + 'layer': 'dasha_sub_periods', + 'status': 'unavailable', + 'reason': 'no_mahadasha_period_covers_reference_date', + }) + except Exception as exc: + diagnostics.append({'layer': 'dasha_sub_periods', 'status': 'unavailable', 'reason': exc.__class__.__name__}) + if planets and ascendant and not isinstance(modules.get('kp_cusps'), dict): try: normalized, _, asc_sign_idx = handler._normalized_planets_from_body({ @@ -683,7 +770,7 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, 'source': 'repository_local_engines', 'available': [ name - for name in ('varga_full', 'arudha_padas', 'narayana_dasha', 'ashtakavarga', 'kp_cusps') + for name in ('varga_full', 'arudha_padas', 'narayana_dasha', 'dasha_sub_periods', 'ashtakavarga', 'kp_cusps') if isinstance(modules.get(name), dict) and modules.get(name) ], 'diagnostics': diagnostics, @@ -831,9 +918,14 @@ def _build_consumer_context( # Read the sections directly. Gating on `missing_route_layers` made this vacuously true for every # route that does not require narayana_dasha, so precise timing was granted without that layer # ever being checked. + # + # `dasha_boundaries` is the mahadasha list, and a six-to-twenty-year period cannot place an + # answer in a month, so it alone never justified precise timing: the antardasha boundaries have + # to be there too. The gate has to name them itself, because a section that is missing is a + # section no route requirement can speak for (BUG-279). timing_layers_ready = all( isinstance(sections.get(name), dict) and sections[name].get('status') == 'used' - for name in ('dasha_boundaries', 'narayana_dasha') + for name in ('dasha_boundaries', 'dasha_sub_periods', 'narayana_dasha') ) precision_allows_timing = not any(name in disabled_vargas for name in ('D9', 'D10')) can_answer_precise_timing = d1_ready and timing_layers_ready and precision_allows_timing and not missing_route_layers diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py index 7bcab4b3..03853a55 100644 --- a/scripts/unified_consultation_orchestrator.py +++ b/scripts/unified_consultation_orchestrator.py @@ -694,6 +694,10 @@ class UnifiedConsultationOrchestrator: "planet_degrees": self._section(base_chart.get("planets"), "chart.planets"), "house_degrees": self._section(base_chart.get("houses") or chart_data.get("houses"), "chart.houses"), "dasha_boundaries": self._section(modules.get("dasha") or chart_data.get("dasha"), "modules.dasha"), + # The mahadasha list above and the antardasha cut below are different claims: one says + # which decade, the other which months. They are separate sections so that an answer + # policy can require the second without the first standing in for it. + "dasha_sub_periods": self._section(modules.get("dasha_sub_periods"), "modules.dasha_sub_periods"), "narayana_dasha": self._section(modules.get("narayana_dasha"), "modules.narayana_dasha"), "shadbala": self._section(modules.get("shadbala") or chart_data.get("shadbala"), "modules.shadbala"), "ashtakavarga": self._section(modules.get("ashtakavarga") or chart_data.get("ashtakavarga"), "modules.ashtakavarga"), diff --git a/tests/test_consultation_consumer_context.py b/tests/test_consultation_consumer_context.py index ebf347c5..f498c49e 100644 --- a/tests/test_consultation_consumer_context.py +++ b/tests/test_consultation_consumer_context.py @@ -6,11 +6,13 @@ from __future__ import annotations import os import re import sys +from datetime import datetime SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts') if SCRIPTS not in sys.path: sys.path.insert(0, SCRIPTS) +from domain_calculation_service import compute_vimshottari_timeline # noqa: E402 from jyotish_api_server import ( # noqa: E402 _PRECISE_BIRTH_TIME_ACCURACY, _ROUTE_DOMAIN_CONTEXT, @@ -26,6 +28,28 @@ def _handler() -> JyotishAPIHandler: return JyotishAPIHandler.__new__(JyotishAPIHandler) +_BIRTH = {'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0} +_REFERENCE_DATE = '2026-07-14' + + +def _canonical_dasha(moon_lon: float) -> dict: + """The mahadasha periods a real chart carries, from the engine the server uses. + + The sub-period layer is cut out of these periods, so a fixture that only names a current lord + would exercise nothing. Deriving them keeps the fixture from drifting away from the contract. + """ + + timeline = compute_vimshottari_timeline( + birth_dt=datetime(_BIRTH['year'], _BIRTH['month'], _BIRTH['day'], _BIRTH['hour'], _BIRTH['minute']), + moon_lon=moon_lon, + ) + return { + 'current_md': timeline['birth_balance']['lord'], + 'periods': timeline['periods'], + 'birth_balance': timeline['birth_balance'], + } + + def _base_chart() -> dict: longitudes = { 'Sun': 120.9, @@ -54,8 +78,8 @@ def _base_chart() -> dict: 'ascendant': {'lon': asc_lon, 'sign_idx': asc_idx, 'sign': 'Libra'}, 'planets': planets, 'houses': {house: {'sign_idx': (asc_idx + house - 1) % 12} for house in range(1, 13)}, - 'dasha': {'current_md': 'Sun'}, - 'modules': {'dasha': {'current_md': 'Sun'}}, + 'dasha': _canonical_dasha(longitudes['Moon']), + 'modules': {'dasha': _canonical_dasha(longitudes['Moon'])}, } @@ -88,6 +112,103 @@ def test_local_consultation_layers_supply_d10_a10_and_narayana_without_vedastro( assert packet['sections']['KP_cusp']['status'] == 'used' +def test_sub_period_boundaries_are_cut_out_of_the_periods_the_packet_shows() -> None: + """The model never received antardasha boundaries: it read `modules.dasha_boundaries`, a key the + engine never wrote. Mahadashas run six to twenty years, so every answer had to say sub-periods + were not calculated while the receipt still reported precise timing as allowed (BUG-279). + + The boundaries must also come from the mahadasha the packet already shows, or the model would + hold two sets of dasha dates and could quote either. + """ + + chart = _attach_local_consultation_layers( + _handler(), _base_chart(), dict(_BIRTH), {'current_date': _REFERENCE_DATE}, + ) + + layer = chart['modules']['dasha_sub_periods'] + reference = datetime.fromisoformat(_REFERENCE_DATE) + current_md = layer['current']['mahadasha'] + current_ad = layer['current']['antardasha'] + + assert layer['boundary_count'] == 9 + assert current_md in [ + {'lord': period['lord'], 'start': period['start'], 'end': period['end']} + for period in chart['dasha']['periods'] + ] + assert datetime.fromisoformat(current_md['start']) <= reference < datetime.fromisoformat(current_md['end']) + assert datetime.fromisoformat(current_ad['start']) <= reference < datetime.fromisoformat(current_ad['end']) + assert layer['boundaries'][0]['start'] == current_md['start'] + assert layer['boundaries'][-1]['end'] == current_md['end'] + assert chart['local_consultation_layers']['status'] == 'ready' + assert 'dasha_sub_periods' in chart['local_consultation_layers']['available'] + + packet = UnifiedConsultationOrchestrator().machine_evidence_packet( + chart=chart, + route_packet={'question_type': 'timing', 'primary_theme': 'timing'}, + vedastro_official={'status': 'blocked'}, + ) + assert packet['sections']['dasha_sub_periods']['status'] == 'used' + + +def test_every_module_the_model_packet_reads_is_a_module_the_server_writes() -> None: + """The guard that would have caught this: `local_layers` read `modules.dasha_boundaries`, which + nothing in this repository ever assigned, so the field was permanently undefined and no test on + either side of the boundary could see it. Reading a key the engine does not write is the failure + mode, so compare the names directly against a chart the server actually built. + """ + + workflow = os.path.join( + os.path.dirname(__file__), '..', 'frontend', 'src', 'mastra', 'consultation-workflow.ts', + ) + with open(workflow, encoding='utf-8') as handle: + source = handle.read() + block = re.search(r'local_layers:\s*\{(.*?)\n\s{4}\},', source, re.DOTALL) + assert block, 'local_layers block not found in consultation-workflow.ts' + code = re.sub(r'//[^\n]*', '', block.group(1)) + read_keys = set(re.findall(r'modules\.(\w+)', code)) + # Fail closed: a regex that matched nothing would make this vacuously pass. + assert len(read_keys) >= 3, sorted(read_keys) + + chart = _attach_local_consultation_layers( + _handler(), _base_chart(), dict(_BIRTH), {'current_date': _REFERENCE_DATE}, + ) + unwritten = sorted(read_keys - set(chart['modules'])) + assert not unwritten, f'the model packet reads modules the engine never writes: {unwritten}' + + +def test_a_chart_without_sub_periods_is_not_granted_precise_timing() -> None: + """`dasha_boundaries` is the mahadasha list, so on its own it can place a decade, never a month. + + Granting precise timing from it is how the receipt came to say `preciseTiming: allowed` for runs + whose answers had to admit the sub-periods were missing. + """ + + context = _build_consumer_context( + question='具体哪几个月适合行动', + route_packet={'question_type': 'timing', 'primary_theme': 'timing'}, + chart={'success': True}, + rectification=_confirmed_birth_time(), + machine_evidence_packet=_sections(dasha_sub_periods='missing'), + vedastro_official={'status': 'blocked'}, + ) + + assert context['answer_policy']['can_answer_precise_timing'] is False + assert context['answer_policy']['should_lead_with_limitations'] is True + + +def test_precise_timing_is_granted_once_the_sub_period_boundaries_are_there() -> None: + context = _build_consumer_context( + question='具体哪几个月适合行动', + route_packet={'question_type': 'timing', 'primary_theme': 'timing'}, + chart={'success': True}, + rectification=_confirmed_birth_time(), + machine_evidence_packet=_sections(), + vedastro_official={'status': 'blocked'}, + ) + + assert context['answer_policy']['can_answer_precise_timing'] is True + + def test_consumer_context_treats_unconfigured_vedastro_as_optional_cross_check() -> None: chart = _attach_local_consultation_layers( _handler(), @@ -295,6 +416,7 @@ def _sections(**overrides: str) -> dict: 'UL': 'used', 'ashtakavarga': 'used', 'dasha_boundaries': 'used', + 'dasha_sub_periods': 'used', 'narayana_dasha': 'used', 'external_oracle_status': 'official_blocked', }