fix(consult): compute the antardasha boundaries answers need, and stop billing an apology for an unwritten answer
Independent Staging Quality Gate / validate (push) Failing after 8m32s
Independent Staging Quality Gate / publish (push) Has been skipped

The model packet read chart.modules.dasha_boundaries, a key the engine never
wrote, so no answer ever had sub-period boundaries while the receipt still
reported precise timing as allowed. The server now cuts the running mahadasha
into antardashas out of the periods the packet already shows, exposes them as
their own evidence section, and precise timing requires that section.

A run whose calculation succeeded and whose model then wrote nothing was
answered with a fixed apology and billed as completed. It now asks once more
against the cached calculation, and fails with empty_answer—no charge—if that
attempt is silent too.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 18:32:49 +08:00
co-authored by Cursor
parent 46f38422f6
commit 927bdd7a21
11 changed files with 388 additions and 61 deletions
+34
View File
@@ -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-099 段副运首尾正好贴合大运首尾,`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.<key>` 读法抽出来(先剥注释,否则解释性注释里的旧键名会被算成读法),与服务端真实挂载后的 `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(同一批「失败在回执里查不到」)
- 复发自:无
- 修复版本:待提交
+24
View File
@@ -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,
+22 -33
View File
@@ -36,14 +36,6 @@ async function* readChunks(stream: ChunkStream): AsyncIterable<Chunk> {
}
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<ChunkStream>;
retryForAnswer?: () => Promise<ChunkStream>;
continueAfterDisconnect?: boolean;
headers?: HeadersInit;
onFirstActivity?: () => void | Promise<void>;
@@ -263,17 +256,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
async function consumeAttempt(controller: ReadableStreamDefaultController<Uint8Array> | 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<Uint8Array>({
@@ -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();
+6 -3
View File
@@ -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<typeof toAgentConsultationContext>) {
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: {
+1
View File
@@ -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.
@@ -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<Usage>\[\]\): Promise<Usage> \{[\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 () => {
@@ -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);
});
+15 -2
View File
@@ -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<string, unknown>;
assert.equal(answerPolicy.can_answer_precise_timing, false);
+94 -2
View File
@@ -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
@@ -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"),
+124 -2
View File
@@ -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',
}