From 4ceb3a515758f420af5bb97fd4aa51dff9d84a0e Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sun, 19 Jul 2026 22:23:29 +0800 Subject: [PATCH] feat: enforce commercial rectification evidence contracts * feat: enforce precise timing output contract * fix: recognize package imports in fragment audit * test: make workflow stream contract formatting-independent * fix: preserve VedAstro evidence across async workflows * feat: enforce commercial technique truth contract * feat: add rectification technique receipt * feat: extend rectification event evidence * feat: score rectification arudha evidence * feat: gate high rigor rectification confirmation * feat: add controlled transit to rectification * feat: include d11 in rectification finance scoring * feat: add ashtakavarga rectification auxiliary * feat: show rectification technique receipt * feat: use verified shadbala components in rectification * fix: trace transitive script references in fragment audit * feat: run request-level rectification parity packet --- SKILL.md | 15 +- docs/research/pre_work_error_ledger.md | 5 + frontend/src/app/api/consult/route.ts | 104 ++++++++--- frontend/src/app/globals.css | 1 + frontend/src/app/page.tsx | 4 +- .../birth-time-candidate-result.tsx | 19 ++ .../birth-time-evidence-draft-card.tsx | 1 + frontend/src/lib/birth-time-evidence.ts | 11 ++ frontend/src/lib/birth-time-guide-agent.ts | 3 +- .../lib/birth-time-guided-draft-revision.ts | 2 +- .../src/lib/birth-time-journey-adapters.ts | 21 +++ .../lib/birth-time-journey-response-schema.ts | 2 +- .../lib/birth-time-journey-turn-protocol.ts | 2 +- .../src/lib/birth-time-question-planner.ts | 7 +- frontend/src/lib/stream-text-response.ts | 42 +++-- frontend/src/lib/timing-output-guard.ts | 26 +++ frontend/src/mastra/index.ts | 6 + .../tests/birth-time-agent-flow-e2e.test.ts | 2 +- frontend/tests/birth-time-guide-agent.test.ts | 1 + .../birth-time-rectification-contract.test.ts | 39 ++++ .../consultation-workflow-contract.test.ts | 10 +- frontend/tests/timing-output-guard.test.ts | 40 ++++ .../commercial_skill_truth_overlay.v1.json | 47 +++++ scripts/active_rectification_event_engine.py | 176 ++++++++++++++++-- scripts/active_rectification_events.py | 1 + scripts/active_rectification_questions.py | 37 +++- scripts/active_rectification_scoring.py | 1 + scripts/audit_fragments.py | 29 ++- scripts/commercial_skill_truth.py | 108 +++++++++++ scripts/jyotish_api_server.py | 52 +++++- scripts/rectification_technique_contract.py | 28 +++ scripts/rectification_three_engine_packet.py | 79 ++++++++ scripts/vedastro_gateway.py | 1 + scripts/vedastro_runtime_context.py | 24 +++ scripts/vedastro_service_adapter.py | 6 + tests/test_api_async_job_contract.py | 9 + tests/test_api_server_security.py | 9 + tests/test_commercial_skill_truth_contract.py | 51 +++++ .../test_rectification_technique_contract.py | 31 +++ .../test_rectification_three_engine_packet.py | 15 ++ tests/test_vedastro_gateway.py | 1 + .../test_vedastro_service_adapter_executor.py | 12 ++ 42 files changed, 995 insertions(+), 85 deletions(-) create mode 100644 frontend/src/lib/timing-output-guard.ts create mode 100644 frontend/tests/timing-output-guard.test.ts create mode 100644 references/oracle/commercial_skill_truth_overlay.v1.json create mode 100644 scripts/commercial_skill_truth.py create mode 100644 scripts/rectification_technique_contract.py create mode 100644 scripts/rectification_three_engine_packet.py create mode 100644 scripts/vedastro_runtime_context.py create mode 100644 tests/test_commercial_skill_truth_contract.py create mode 100644 tests/test_rectification_technique_contract.py create mode 100644 tests/test_rectification_three_engine_packet.py diff --git a/SKILL.md b/SKILL.md index 9908c3d1..b602dc71 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: jyotish-vedic-astrology version: 6.9.14 -description: "印度占星(Jyotish)专业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→精确推运应期输出。35种Dasha、405+Yoga规则、KP系统、Prashna卜卦、16因子合盘、Remedies补救、Sahams部分覆盖、Sudarshana三参考点、PMC完整检测、Tajika年度星盘、案例验证+误区纠正。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga。GitHub: https://github.com/732642856/yinduzhanxing" +description: "印度占星(Jyotish)商业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→受合同约束的推运方向输出。Dasha、Yoga、分盘、合盘与补救建议按计算回执和能力状态交付;精确应期及受限技法不会作为确定性商业结论。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga。GitHub: https://github.com/732642856/yinduzhanxing" --- # 印度占星专业解盘与推运系统 @@ -14,6 +14,7 @@ description: "印度占星(Jyotish)专业解盘与推运系统。核心能 > **严格路由**:`references/strict-workflow-router.md`(涉及事业/婚恋/财务/应期/技法验证时必须优先读取) > **机器注册表**:`references/technique_registry.json` + `scripts/audit_capabilities.py` > **能力真相边界**:回答前必须参考 `references/oracle/skill_truth_overlay_2026_07_19.json` 与 `references/oracle/effective_skill_capability_view_2026_07_19.json`;不得直接把 `references/technique_registry.json` 的旧 `covered` 当作完整闭环。 +> **商业声明合同**:`references/oracle/commercial_skill_truth_overlay.v1.json`。`reference_only`、`partial`、`blocked`、`research_only_blocked`、`partial_registry_only` 不得进入确定性结论。 > **文章级细节模板**:`references/interpretation_template_registry.json` + `scripts/validate_interpretation_templates.py` ### Skill truth overlay 硬边界 @@ -36,7 +37,7 @@ KP/Muhurta/Gochara/Sahams/Sphuta/Tajika等高阶分支必须按 skill truth over | Sudarshana | Asc/Moon/Sun 三参考点盘 + 宫位收敛分析 | | Shadbala | absolute Rupa 分量求和;内部不变量通过,外部绝对值 oracle 扩充中 | | Ashtakavarga | BAV+SAV+PAV(展开式)+Sodhita(净化式) | -| KP系统 | Sublord+Subsublord+ABCD Significator(输出可用,细粒度传统口径仍以实测与案例闭环为准) | +| KP系统 | `reference_only`;不得作为主结论或精确应期依据 | | 合盘 | 16因子36分制(Ashtakoot+Kuta) | | 补救 | 5类(宝石/咒语/捐赠/斋戒/Dosha专项) | | 自动化测试 | pytest/quality gate 分层守门;以当前仓库质量门输出为准 | @@ -58,7 +59,7 @@ KP/Muhurta/Gochara/Sahams/Sphuta/Tajika等高阶分支必须按 skill truth over ## ⚠️ 核心定位 -**三种输入 → 严谨解盘 → 精确推运应期输出** +**三种输入 → 严谨解盘 → 受合同约束的推运方向输出** | 路径 | 用户输入 | AI行为 | |------|---------|--------| @@ -261,7 +262,7 @@ adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit r - Dasha-only 外部证据当前目标集已闭环:`dasha_external_oracle_evidence_validation.valid_dasha_packets: 3/3`;Steve Jobs / Lahiri、synthetic Lahiri template 与 1800 Delhi historical epoch 的 Vimshottari 起始边界来自 PyJHora 4.8.7 隔离黑盒 stdout artifact。 - 全局 Dasha/Shadbala Calibration Status 仍未完成:`external_oracle_evidence_validation.valid_packets: 4`,`ready_for_calibration: 4`;Shadbala 外部绝对值当前目标集已通过 4/4,Raman 扩展样本与非 Dasha 靶点尚未封顶。 - 历史 UI 静态门禁仍保留旧提示 `ready_for_calibration: 0` 作为“不得过度宣称”的保守文案;实际进度必须以当前 `oracle_collection_queue.py` / `oracle_evidence_validator.py` 输出为准。 -- Tajika/Sahams 年运外部样本已开始闭环:`tajika_sahams_annual_benchmark_dashboard.ready_for_calibration: 1/5`;Steve Jobs 1984 Varshaphala/Lahiri 的 solar return、Varsha Lagna、Muntha、Year Lord、Mudda Dasha 首主、三项 Sahams 与 Tajika Yogas 已由 PyJHora 4.8.7 隔离黑盒 artifact 验证,下一优先级为 Einstein 1905。仍不得声称 Tajika/Sahams 年运体系已全局封顶。 +- Tajika/Sahams 年运资料仅保留为研发基准记录;商业声明合同将 Sahams 设为 `blocked`、Tajika 设为 `partial`,两者均不得进入确定性结论或精确应期。 - D1/D9/SAV 高可信;Dasha 精细日期可引用已验证 Dasha-only 样本的局部进度,但不得把全部大运边界、Shadbala 绝对值或全局精度说成已完成外部校准。 - 不得把大运起点或 Shadbala 绝对值说成已完成外部校准;涉及具体日期/绝对力量值时,必须同时报告 `Dasha/Shadbala Calibration Status`、`external_oracle_evidence_validation` 与 `production_tuning_allowed: false` 边界。 - `production_tuning_allowed: false` 前,禁止为了贴合单份 PDF、单个 JHora 截图或本仓库本地输出而改生产常数。 @@ -334,7 +335,7 @@ adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit r | **关系占星** | Koota 36分、Mahendra/Stree Deergha/Vedha/Rajju、D9伴侣、DK、Mangal Dosha、Papasamya、配偶六层确认 | `spouse-multi-layer-methodology.md` `darakaraka-complete-guide.md` `relationship-astrology-guide.md` | | **出生时间矫正** | 八大方法、自动化流程、验证报告、分盘调用决策树 | `birth-time-rectification-advanced.md` `birth-time-rectification-decision-tree.md` | | **PDF读取** | JH/PL PDF全量提取、完整性门、交叉校验 | `pdf-chart-reading-guide.md` `data-bridge-mapping.md` | -| **Prashna问事** | 十步断卦、AL、Sphuta、Sahams、失物查询 | `prashna-complete-guide.md` `single-event-inquiry-protocol.md` | +| **Prashna问事** | 十步断卦、AL、失物查询;Sphuta/Sahams 不属于可交付商业结论 | `prashna-complete-guide.md` `single-event-inquiry-protocol.md` | | **多元技法** | Yogi/Ava Yogi、Tithi Lord、Rashi Tulya Navamsa、BCP、Bhrigu Pada、Pancha Pakshi、Tara Bala、Deha/Jeeva、Moolatrikona、Shodasavarga/Vimsopaka、Ashwini/Abhijit/Ketu星宿专题(需保留成熟度边界) | `yogi-avayogi-system.md` `yogi-asc-tight-orb-wealth-freeze-guide.md` `tithi-lord-relationship-system.md` `tithi-lord-freeze-execution-guide.md` `rtn-high-order-d9-freeze-execution-guide.md` `bhrigu-pada-all-event-freeze-execution-guide.md` `ashwini-abhijit-ketu-nakshatra-freeze-guide.md` `bhrigu-chakra-paddhati.md` `shodasavarga-complete-guide.md` `planetary-dignity-complete-reference.md` `alternative-dasha-systems.md` | | **精准方法论** | PACDARES框架、九层复合方法、L3矛盾检查、三级置信度 | `precision-reading-methodology.md` | | **解读质检** | 真实解读结构质检、参数冻结、分盘强制展开、oracle 诚信边界 | `real-reading-quality-checklist.md` | @@ -545,7 +546,7 @@ $PYTHON $SCRIPT <子命令> [参数] **PACDARES框架**:P位置→A相位→C合相→D财富Yoga→A灾厄Yoga→R皇家Yoga→E互换→S特殊 -**九层复合方法**:L1 PACDARES → L2 分盘 → L3 矛盾检查(关键) → L4 Vimshottari → L5 AV+Transit → L6 条件Dasha → L7 Jaimini → L8 其他Jaimini → L9 Tajika +**九层复合方法(研究参考)**:L1 PACDARES → L2 分盘 → L3 矛盾检查(关键) → L4 Vimshottari → L5 AV+Transit → L6 条件Dasha → L7 Jaimini → L8 其他Jaimini → L9 Tajika;商业输出须遵守声明合同,Tajika 不得形成确定性结论。 **三级置信度**:✅[A]已验证 / ⭐[B]强推断(3+维度) / ⚡[C]假设(单一维度) @@ -583,7 +584,7 @@ $PYTHON $SCRIPT <子命令> [参数] - [ ] Dasha推运(大运+小运+Pratyantar) - [ ] Dasa Convergence五系统交叉验证 - [ ] Jaimini分析(Karaka/Karakamsha;Chara Dasha 已通过 KN Rao Method benchmark,剩余共主仲裁差异需声明) -- [ ] KP系统分析(Significator+Sub-Lord) +- [ ] KP系统背景参考(不得作为商业主结论或精确应期依据) - [ ] Transit分析(多参考点强制) - [ ] **Transit Actionable Output**(时间段+行动+置信度+案例检索) - [ ] 分盘验证 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index b08d2f76..9ba7759b 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -124,6 +124,11 @@ Prevention: add a new domain only after its public cases satisfy the same source ## Fragment Sweep Command Set +## ERR-084 | Pre-work fragment test assumes zero candidates despite current audited candidates | active 2026-07-19 +`scripts/pre_work_check.py` reports `fragment_audit.candidate_count=2`, while `tests/test_preflight_fragment_scan.py` requires exactly zero. The pre-work command therefore cannot be reported green until the two candidates are classified or the test is updated to validate the reviewed state rather than a hard-coded count. + +Prevention: retain candidate identity and classification in the sweep artifact; do not mask candidates or weaken the pre-work result. + Use split scans, not one unbounded full-home command: ```bash diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 445124a3..01b1db91 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -19,6 +19,7 @@ import { reserveConsultationModel } from "@/lib/consultation-model-selection"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { streamTextResponse } from "@/lib/stream-text-response"; +import { guardPreciseTimingOutput } from "@/lib/timing-output-guard"; import { z } from "zod"; export const runtime = "nodejs"; @@ -29,10 +30,15 @@ const chatRequestSchema = consultationInputSchema.extend({ modelId: z.string().trim().min(1).max(64), entrypoint: consultationEntrypointSchema.optional(), name: z.string().trim().max(80).optional().default(""), - history: z.array(z.object({ - role: z.enum(["user", "assistant"]), - text: z.string().max(4000), - })).max(20).default([]), + history: z + .array( + z.object({ + role: z.enum(["user", "assistant"]), + text: z.string().max(4000), + }), + ) + .max(20) + .default([]), }); function currentTimeContext(now = new Date()) { @@ -67,10 +73,15 @@ async function recordModelUsage( .eq("transaction_type", "reserve") .eq("request_id", requestId); - if (error) console.warn(`[billing] unable to record model usage request=${requestId} model=${modelId}`); + if (error) + console.warn( + `[billing] unable to record model usage request=${requestId} model=${modelId}`, + ); } catch (error) { const reason = error instanceof Error ? error.name : "UnknownError"; - console.warn(`[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`); + console.warn( + `[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`, + ); } } @@ -87,7 +98,10 @@ export async function POST(request: Request) { ); } - const { data: { user }, error: authError } = await supabase.auth.getUser(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json( { error: "请先登录", message: "登录后才能开始咨询。" }, @@ -95,7 +109,9 @@ export async function POST(request: Request) { ); } - const parsed = chatRequestSchema.safeParse(await request.json().catch(() => null)); + const parsed = chatRequestSchema.safeParse( + await request.json().catch(() => null), + ); if (!parsed.success) { return NextResponse.json( { error: "出生资料或问题格式不正确", details: parsed.error.flatten() }, @@ -111,7 +127,11 @@ export async function POST(request: Request) { ].join("\n"); if (blocksPromptExtraction(userControlledPrompt)) { return NextResponse.json( - { error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。" }, + { + error: "无法处理该请求", + message: + "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。", + }, { status: 400 }, ); } @@ -130,11 +150,19 @@ export async function POST(request: Request) { modelSelection = await reserveConsultationModel( parsed.data.modelId, resolveLanguageModel, - () => runCreditRpc(accounting, "begin_consultation_credit", userId, requestId), + () => + runCreditRpc( + accounting, + "begin_consultation_credit", + userId, + requestId, + ), ); } catch (error) { const reason = error instanceof Error ? error.name : "UnknownError"; - console.error(`[billing] reservation failed request=${requestId} reason=${reason}`); + console.error( + `[billing] reservation failed request=${requestId} reason=${reason}`, + ); return NextResponse.json( { error: "暂时无法确认咨询点数", message: "请稍后重试。" }, { status: 503 }, @@ -143,7 +171,10 @@ export async function POST(request: Request) { if (modelSelection.status === "unavailable") { return NextResponse.json( - { error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" }, + { + error: "模型暂不可用", + message: "请选择其他模型后重新发送,本次不会扣除点数。", + }, { status: 409 }, ); } @@ -156,7 +187,9 @@ export async function POST(request: Request) { return NextResponse.json( { error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数", - message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.error_code || "请稍后重试。", + message: insufficient + ? "请先兑换咨询点数后再继续。" + : reserveResult.error_code || "请稍后重试。", }, { status: insufficient ? 402 : 503 }, ); @@ -164,16 +197,29 @@ export async function POST(request: Request) { async function cancel() { try { - await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId); + await runCreditRpc( + accounting, + "cancel_consultation_credit", + userId, + requestId, + ); } catch (error) { const reason = error instanceof Error ? error.name : "UnknownError"; - console.error(`[billing] cancellation failed request=${requestId} reason=${reason}`); + console.error( + `[billing] cancellation failed request=${requestId} reason=${reason}`, + ); } } async function complete() { - const result = await runCreditRpc(accounting, "complete_consultation_credit", userId, requestId); - if (!result.success) throw new CreditRpcError(result.error_code || "completion_rejected"); + const result = await runCreditRpc( + accounting, + "complete_consultation_credit", + userId, + requestId, + ); + if (!result.success) + throw new CreditRpcError(result.error_code || "completion_rejected"); } let settlement: Promise | null = null; @@ -208,15 +254,27 @@ export async function POST(request: Request) { ]); const completeAndRecordUsage = async () => { await complete(); - void recordModelUsage(accounting, userId, requestId, modelSelection.usageModelId, result.totalUsage); + void recordModelUsage( + accounting, + userId, + requestId, + modelSelection.usageModelId, + result.totalUsage, + ); }; - const settleInterrupted = (emitted: boolean) => settle(emitted ? completeAndRecordUsage : cancel); + const settleInterrupted = (emitted: boolean) => + settle(emitted ? completeAndRecordUsage : cancel); return streamTextResponse(result.textStream, { + transformText: + workflowReceipt.preciseTiming === "blocked" + ? guardPreciseTimingOutput + : undefined, mode: "mastra", requestId, headers: { "x-jyotish-workflow-route": workflowReceipt.route, "x-jyotish-workflow-status": workflowReceipt.status, + "x-jyotish-technique-truth": workflowReceipt.techniqueTruth, "x-jyotish-precise-timing": workflowReceipt.preciseTiming, "x-jyotish-missing-layers": workflowReceipt.missingLayers, }, @@ -227,12 +285,16 @@ export async function POST(request: Request) { } catch (error) { await cancel(); const reason = error instanceof Error ? error.name : "UnknownError"; - console.error(`[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`); + console.error( + `[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`, + ); return NextResponse.json( { error: "暂时无法生成解读", message: "咨询服务暂时不可用,请稍后再试。", - recovery: languageModelConfigurationMessage() ? "当前没有可用的咨询模型,请联系管理员。" : "稍后重试,或换一个模型继续。", + recovery: languageModelConfigurationMessage() + ? "当前没有可用的咨询模型,请联系管理员。" + : "稍后重试,或换一个模型继续。", }, { status: 503 }, ); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 18da97d6..7482a731 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -475,6 +475,7 @@ button:disabled { cursor: default; opacity: .45; } .message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; } .message p, .message-markdown { color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; word-break: auto-phrase; } +.message-evidence-status { margin: var(--space-3) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-muted); font-size: var(--type-body-sm); line-height: 1.5; } .message-user p { line-height: 1.55; color: var(--color-ink); font-size: var(--type-body-sm); } .message-markdown h2, .message-markdown h3 { margin: 24px 0 10px; color: var(--color-ink); font-family: var(--font-display); font-weight: 400; letter-spacing: -.3px; } .message-markdown h2 { font-size: var(--type-display-sm); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 44c5bce6..4b305bee 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2119,6 +2119,7 @@ export default function Home() { throw new Error(payloadMessage(errorPayload, "服务暂时不可用")); } if (!response.body) throw new Error("浏览器未收到可读取的回答流"); + const techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown"; const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -2147,7 +2148,7 @@ export default function Home() { const completedSession: ChatSession = { ...userSession, title: currentSession.messages.length === 0 && reply.title ? reply.title : userSession.title, - messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions }], + messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions, techniqueTruth }], updatedAt: timestamp(), }; updateSession(sessionId, () => completedSession); @@ -2511,6 +2512,7 @@ export default function Home() { {chatMessageViews(activeSession.messages, isLoading, activeStreamingText).map((message) => ( ))} + ))} {activeError &&

{activeError}

}
diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index 0b3471dd..b85e3a75 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -20,6 +20,15 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul const action = journey.nextAction; const dynamic = journey.journeyProtocol === "dynamic-choice-v2"; const terminalPath = guidedTerminalPath(journey); + if (result?.eventCount === 0) { + return ( +
+

尚未进入分钟计算:还没有可评分的关键经历资料。

+

当前范围仅为填报范围,不是校正结果;请补充跨领域、可注明年月的重大事件后重新评估。

+ {terminalPath && } +
+ ); + } if (!result && action.kind === "present_low_result") { return (
@@ -30,6 +39,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul } if (!result) return null; const winner = result.winningSegment; + const receipt = result.techniqueReceipt; return (
@@ -48,6 +58,15 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul

候选仍然并列或缺少必要计算层,系统不会选择具体分钟。

)}

这是可复现的候选评分,不代表已经证明出生记录中的具体分钟。

+ {receipt && ( +
+ 本次计算回执 +

已用:{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}

+

辅助:{receipt.auxiliaryLayers.join("、") || "无"}

+

未完成:{receipt.missingLayers.join("、") || "无"}

+ {receipt.hardBlockers.length > 0 &&

阻止确认:{receipt.hardBlockers.join("、")}

} +
+ )} {action.kind === "present_low_result" && (
diff --git a/frontend/src/components/birth-time-evidence-draft-card.tsx b/frontend/src/components/birth-time-evidence-draft-card.tsx index 32fc0bae..d04a9ebc 100644 --- a/frontend/src/components/birth-time-evidence-draft-card.tsx +++ b/frontend/src/components/birth-time-evidence-draft-card.tsx @@ -10,6 +10,7 @@ const domainLabels = { relocation: "搬迁与长期居住地", relationship: "重要关系", career: "工作与身份变化", + finance: "收入、资产或资源变化", health_pressure: "健康或生活压力", } as const satisfies Readonly>; diff --git a/frontend/src/lib/birth-time-evidence.ts b/frontend/src/lib/birth-time-evidence.ts index 8af94d77..bca333fb 100644 --- a/frontend/src/lib/birth-time-evidence.ts +++ b/frontend/src/lib/birth-time-evidence.ts @@ -83,6 +83,16 @@ const candidateEvidenceSchema = z.object({ points: z.number(), }).strict().readonly(); +const rectificationTechniqueReceiptSchema = z.object({ + calculationStatus: z.enum(["not_started", "evaluated"]), + usedDivisionalCharts: z.array(z.string()), + usedArudha: z.array(z.string()), + dashaTracks: z.array(z.string()), + missingLayers: z.array(z.string()), + auxiliaryLayers: z.array(z.string()).default([]), + hardBlockers: z.array(z.string()), +}).strict().readonly(); + export const candidateResultSchema = z.object({ resultId: z.string().uuid(), confidence: z.enum(["low", "medium", "high"]), @@ -101,6 +111,7 @@ export const candidateResultSchema = z.object({ reasons: z.array(z.string().trim().min(1)), evidence: z.array(candidateEvidenceSchema), algorithmVersion: z.string().trim().min(1), + techniqueReceipt: rectificationTechniqueReceiptSchema.optional(), }).strict().readonly().superRefine((value, context) => { const eligible = value.confidence === "high" && highCandidateMeetsSafetyGates(value); if (value.confidence === "high" && value.eventCount < 4) { diff --git a/frontend/src/lib/birth-time-guide-agent.ts b/frontend/src/lib/birth-time-guide-agent.ts index 60434fb3..3259e0ac 100644 --- a/frontend/src/lib/birth-time-guide-agent.ts +++ b/frontend/src/lib/birth-time-guide-agent.ts @@ -47,7 +47,7 @@ const guideQuestionOutputSchema = z.object({ }).strict().readonly(); export const evidenceDraftModelOutputSchema = z.object({ - domain: z.enum(["education", "relocation", "relationship", "career", "health_pressure"]), + domain: z.enum(["education", "relocation", "relationship", "career", "finance", "health_pressure"]), precision: z.enum(["year", "month", "day"]).nullable(), date: z.string().trim().min(1).max(10).nullable(), }).strict().readonly(); @@ -98,6 +98,7 @@ const subjectByDomain = { relocation: "一次搬家、离乡或长期居住地变化", relationship: "一次关系进入、关系结束或关系明显转变", career: "一次明显的工作、职业方向或身份变化", + finance: "一次收入、资产、负债或资源渠道的明显变化", health_pressure: "一次持续的健康压力或生活压力变化", } as const satisfies Readonly>; diff --git a/frontend/src/lib/birth-time-guided-draft-revision.ts b/frontend/src/lib/birth-time-guided-draft-revision.ts index 463e43b4..08b0d3f3 100644 --- a/frontend/src/lib/birth-time-guided-draft-revision.ts +++ b/frontend/src/lib/birth-time-guided-draft-revision.ts @@ -12,7 +12,7 @@ type ProposeDraft = ( caseId: string, actionId: string, expectedVersion: number, - proposal: { readonly domain: "education" | "relocation" | "relationship" | "career" | "health_pressure"; readonly precision: EvidenceDatePrecision; readonly date: string }, + proposal: { readonly domain: "education" | "relocation" | "relationship" | "career" | "finance" | "health_pressure"; readonly precision: EvidenceDatePrecision; readonly date: string }, ) => Promise; type DraftRevision = { readonly userId: string; diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts index 4cc9517e..4fae3142 100644 --- a/frontend/src/lib/birth-time-journey-adapters.ts +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -54,6 +54,7 @@ const sampleSchema = z.object({ ascendant: signSchema, varga_lagna: z.object({ D4: signSchema, + D2: signSchema, D9: signSchema, D10: signSchema, D24: signSchema, @@ -81,6 +82,7 @@ const eventDomainSchema = z.enum([ "relocation", "relationship", "career", + "finance", "health_pressure", ]); const candidateResultApiSchema = z.object({ @@ -107,6 +109,15 @@ const candidateResultApiSchema = z.object({ points: z.number(), })), algorithm_version: z.string(), + technique_contract: z.object({ + calculation_status: z.enum(["not_started", "evaluated"]), + used_divisional_charts: z.array(z.string()), + used_arudha: z.array(z.string()), + dasha_tracks: z.array(z.string()), + missing_layers: z.array(z.string()), + auxiliary_layers: z.array(z.string()).default([]), + hard_blockers: z.array(z.string()), + }).optional(), }).passthrough(); class UnexpectedProfileSourceError extends Error { @@ -161,6 +172,7 @@ export function parseRectificationQuestionnaire(value: unknown): RectificationQu questions: parsed.questions.map(normalizeQuestion), samples: parsed.candidate_scan.samples.map((sample) => ({ ascendantSign: sample.ascendant?.sign ?? null, + ...(sample.varga_lagna?.D2?.sign ? { d2Sign: sample.varga_lagna.D2.sign } : {}), d4Sign: sample.varga_lagna?.D4?.sign ?? null, d9Sign: sample.varga_lagna?.D9?.sign ?? null, d10Sign: sample.varga_lagna?.D10?.sign ?? null, @@ -226,5 +238,14 @@ function adaptCandidateResult(parsed: z.infer): points: item.points, })), algorithmVersion: parsed.algorithm_version, + ...(parsed.technique_contract ? { techniqueReceipt: { + calculationStatus: parsed.technique_contract.calculation_status, + usedDivisionalCharts: parsed.technique_contract.used_divisional_charts, + usedArudha: parsed.technique_contract.used_arudha, + dashaTracks: parsed.technique_contract.dasha_tracks, + missingLayers: parsed.technique_contract.missing_layers, + auxiliaryLayers: parsed.technique_contract.auxiliary_layers, + hardBlockers: parsed.technique_contract.hard_blockers, + } } : {}), }); } diff --git a/frontend/src/lib/birth-time-journey-response-schema.ts b/frontend/src/lib/birth-time-journey-response-schema.ts index 44fb759a..616322d3 100644 --- a/frontend/src/lib/birth-time-journey-response-schema.ts +++ b/frontend/src/lib/birth-time-journey-response-schema.ts @@ -11,7 +11,7 @@ const rectificationQuestionSchema = z.object({ }).strict().readonly(); const candidateVargaSampleSchema = z.object({ ascendantSign: z.string().nullable(), d4Sign: z.string().nullable(), d9Sign: z.string().nullable(), - d10Sign: z.string().nullable(), d24Sign: z.string().nullable(), d30Sign: z.string().nullable(), + d2Sign: z.string().nullable().optional(), d10Sign: z.string().nullable(), d24Sign: z.string().nullable(), d30Sign: z.string().nullable(), }).strict().readonly(); export const questionnaireSchema = z.object({ questions: z.array(rectificationQuestionSchema).readonly(), samples: z.array(candidateVargaSampleSchema).readonly(), raw: z.record(z.unknown()).readonly(), diff --git a/frontend/src/lib/birth-time-journey-turn-protocol.ts b/frontend/src/lib/birth-time-journey-turn-protocol.ts index 96830dd0..ab951b7e 100644 --- a/frontend/src/lib/birth-time-journey-turn-protocol.ts +++ b/frontend/src/lib/birth-time-journey-turn-protocol.ts @@ -10,7 +10,7 @@ const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); export const questionSpecSchema: z.ZodType = z.object({ questionId: z.string().trim().min(1), phase: z.enum(["baseline", "adaptive"]), - domain: z.enum(["education", "relocation", "relationship", "career", "health_pressure"]), + domain: z.enum(["education", "relocation", "relationship", "career", "finance", "health_pressure"]), requestedPrecision: z.array(z.enum(["day", "month", "year"])).min(1), allowUnknown: z.literal(true), purposeCode: z.string().trim().min(1), diff --git a/frontend/src/lib/birth-time-question-planner.ts b/frontend/src/lib/birth-time-question-planner.ts index a353a7df..c49916b9 100644 --- a/frontend/src/lib/birth-time-question-planner.ts +++ b/frontend/src/lib/birth-time-question-planner.ts @@ -1,5 +1,5 @@ export const evidenceDomains = [ - "education", "relocation", "relationship", "career", "health_pressure", + "education", "relocation", "relationship", "career", "finance", "health_pressure", ] as const; export type EvidenceDomain = (typeof evidenceDomains)[number]; @@ -7,6 +7,7 @@ export type EvidenceQuestionPhase = "baseline" | "adaptive"; export type EvidenceDatePrecision = "day" | "month" | "year"; export type CandidateVargaSample = { + readonly d2Sign?: string | null; readonly d4Sign: string | null; readonly d9Sign: string | null; readonly d10Sign: string | null; @@ -37,6 +38,7 @@ const layerByDomain = { relocation: "d4Sign", relationship: "d9Sign", career: "d10Sign", + finance: "d2Sign", health_pressure: "d30Sign", } as const; @@ -57,7 +59,8 @@ function questionSpecFor( } export function planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec | null { - const available = evidenceDomains.filter((domain) => !input.askedDomains.includes(domain)); + const available = evidenceDomains.filter((domain) => !input.askedDomains.includes(domain) + && (domain !== "finance" || (input.phase === "adaptive" && input.coveredDomains.length >= 2))); const ranked = available.map((domain) => ({ domain, split: new Set(input.samples.map((sample) => sample[layerByDomain[domain]]).filter(Boolean)).size, diff --git a/frontend/src/lib/stream-text-response.ts b/frontend/src/lib/stream-text-response.ts index 4f6acb83..bfb7dc17 100644 --- a/frontend/src/lib/stream-text-response.ts +++ b/frontend/src/lib/stream-text-response.ts @@ -8,6 +8,7 @@ type StreamTextResponseOptions = StreamHooks & { readonly mode: "engine" | "mastra"; readonly requestId: string; readonly headers?: Record; + readonly transformText?: (text: string) => string; }; export function streamTextResponse( @@ -16,27 +17,46 @@ export function streamTextResponse( ) { const iterator = stream[Symbol.asyncIterator](); const encoder = new TextEncoder(); + // Keep a full natural-language clause unflushed so a later stream chunk cannot + // turn an allowed prefix into a disallowed timing or guaranteed conclusion. + const guardTailLength = options.transformText ? 1024 : 0; + let pending = ""; let settled = false; let emitted = false; const body = new ReadableStream({ async pull(controller) { try { - const { done, value } = await iterator.next(); - if (done) { - settled = true; - if (!emitted) { - const error = new Error("empty_stream"); - await options.onError?.(error, false); - controller.error(error); + while (true) { + const { done, value } = await iterator.next(); + if (done) { + if (pending) + controller.enqueue( + encoder.encode(options.transformText?.(pending) ?? pending), + ); + settled = true; + if (!emitted) { + const error = new Error("empty_stream"); + await options.onError?.(error, false); + controller.error(error); + return; + } + await options.onComplete?.(); + controller.close(); return; } - await options.onComplete?.(); - controller.close(); + if (/\S/.test(value)) emitted = true; + pending += value; + if (pending.length <= guardTailLength) continue; + + const stableLength = pending.length - guardTailLength; + const stable = pending.slice(0, stableLength); + pending = pending.slice(stableLength); + controller.enqueue( + encoder.encode(options.transformText?.(stable) ?? stable), + ); return; } - if (/\S/.test(value)) emitted = true; - controller.enqueue(encoder.encode(value)); } catch (error) { if (!settled) { settled = true; diff --git a/frontend/src/lib/timing-output-guard.ts b/frontend/src/lib/timing-output-guard.ts new file mode 100644 index 00000000..72fa147b --- /dev/null +++ b/frontend/src/lib/timing-output-guard.ts @@ -0,0 +1,26 @@ +const exactTimingPatterns = [ + /\b(?:19|20)\d{2}[-/.年]\s?\d{1,2}(?:[-/.月]\s?\d{1,2}(?:日|号)?)?\b/g, + /\b(?:19|20)\d{2}\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)(?:\s+\d{1,2})?\b/gi, + /\b\d{1,2}\/\d{1,2}\/(?:\d{2}|\d{4})\b/g, + /(?:今年|明年|后年|(?:19|20)\d{2}年)?\s*\d{1,2}月(?:\s*\d{1,2}[日号])?/g, + /\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s*(?:19|20)\d{2})?\b/gi, +]; + +const guaranteeConclusionPatterns = [ + /(?:^|[。!?.!?\n])[^。!?.!?\n]*(?:一定|必然|保证|肯定|必定|注定|绝对)(?:会|能|将|发生|成功|结婚|复合|怀孕|发财|升职|得到|实现|出现)[^。!?.!?\n]*/g, + /(?:^|[.?!\n])[^.?!\n]*\b(?:will definitely|guaranteed? to|certain to|without doubt)\b[^.?!\n]*/gi, +]; + +/** Removes claims the evidence contract does not permit the model to make. */ +export function guardPreciseTimingOutput(text: string) { + let guarded = text; + for (const pattern of exactTimingPatterns) + guarded = guarded.replace(pattern, "[具体时间已省略]"); + for (const pattern of guaranteeConclusionPatterns) { + guarded = guarded.replace(pattern, (sentence) => { + const prefix = /^[。!?.!?\n]/.exec(sentence)?.[0] ?? ""; + return prefix + "[保证性结论已省略]"; + }); + } + return guarded; +} diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 53704e30..115a85ef 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -30,6 +30,7 @@ const workflowConsumerContextSchema = z.object({ available_layers: z.array(z.string()), missing_route_layers: z.array(z.string()), hard_blockers: z.array(z.string()), + technique_truth: z.record(z.unknown()).optional(), answer_policy: z.object({ can_answer_direction: z.boolean(), can_answer_precise_timing: z.boolean(), @@ -85,6 +86,8 @@ export function consultationWorkflowReceipt(data: JsonRecord) { status: consumerContext.core_status, preciseTiming: consumerContext.answer_policy.can_answer_precise_timing ? "allowed" : "blocked", missingLayers: consumerContext.missing_route_layers.join(",") || "none", + techniqueTruth: String(record(consumerContext.technique_truth).status || "unknown"), + evidenceStatus: record(consumerContext.commercial_evidence_status), }; } @@ -110,6 +113,8 @@ export function toAgentConsultationContext(data: JsonRecord) { available_layers: consumerContext.available_layers, missing_route_layers: consumerContext.missing_route_layers, hard_blockers: consumerContext.hard_blockers, + technique_truth: consumerContext.technique_truth, + commercial_evidence_status: consumerContext.commercial_evidence_status, answer_policy: consumerContext.answer_policy, user_facing_limitation: consumerContext.user_facing_limitation, }, @@ -180,6 +185,7 @@ When reference_transparency is present: - Only say the chart calculation failed when hard_blockers is non-empty. - Never claim D2, D11, D9, D10, A10, UL, or Narayana Dasha is missing when it appears in available_layers, chart, or local_layers. - 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. Usually answer in 2-5 short paragraphs. Ask one clarifying question only when the user's intent is genuinely unclear. After every substantive answer, append exactly two hidden blocks in this order and nothing after the second block: diff --git a/frontend/tests/birth-time-agent-flow-e2e.test.ts b/frontend/tests/birth-time-agent-flow-e2e.test.ts index ce22e378..b923ac76 100644 --- a/frontend/tests/birth-time-agent-flow-e2e.test.ts +++ b/frontend/tests/birth-time-agent-flow-e2e.test.ts @@ -36,7 +36,7 @@ test("fake Agent and engine complete baseline, adaptive, low, and no-apply flow" assert.equal(turn.snapshot.activeTime, null); assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null); await assert.rejects(harness.candidateActions.confirm({ - userId: "user-1", caseId: journeyCaseId, actionId: actionIds[8], expectedVersion: turn.turnVersion, + userId: "user-1", caseId: journeyCaseId, actionId: actionIds[10], expectedVersion: turn.turnVersion, resultId: turn.candidateResult?.resultId ?? "", time: "14:24", })); assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null); diff --git a/frontend/tests/birth-time-guide-agent.test.ts b/frontend/tests/birth-time-guide-agent.test.ts index 3dddb023..d9905fa2 100644 --- a/frontend/tests/birth-time-guide-agent.test.ts +++ b/frontend/tests/birth-time-guide-agent.test.ts @@ -171,6 +171,7 @@ test("server renders every approved variant from its QuestionSpec domain and pre relocation: /搬家|离乡|居住地/, relationship: /关系进入|关系结束|关系.*转变/, career: /工作|职业方向|身份变化/, + finance: /收入|资产|负债|资源渠道/, health_pressure: /健康压力|生活压力/, } as const; for (const domain of evidenceDomains) { diff --git a/frontend/tests/birth-time-rectification-contract.test.ts b/frontend/tests/birth-time-rectification-contract.test.ts index 6ac207d7..7a4242aa 100644 --- a/frontend/tests/birth-time-rectification-contract.test.ts +++ b/frontend/tests/birth-time-rectification-contract.test.ts @@ -5,6 +5,45 @@ import { isGuidedBirthTimePreview, } from "../src/lib/birth-time-guided-preview.ts"; +/* Legacy pre-dynamic-choice contract assertions intentionally omitted. */ +/* +const source = readFileSync( + new URL("../src/components/birth-time-rectification.tsx", import.meta.url), + "utf8", +); +const candidateSource = readFileSync( + new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), + "utf8", +); +const pageSource = readFileSync( + new URL("../src/app/page.tsx", import.meta.url), + "utf8", +); +const storeSource = readFileSync( + new URL("../src/lib/birth-time-journey-store.ts", import.meta.url), + "utf8", +); +const routeSource = readFileSync( + new URL("../src/app/api/birth-time-journey/route.ts", import.meta.url), + "utf8", +); +const stylesSource = readFileSync( + new URL("../src/app/globals.css", import.meta.url), + "utf8", +); + +test("rectification UI is driven only by the persisted guided action", () => { + assert.match(source, /journey\.nextAction/); + assert.doesNotMatch(source, /questions\.slice\(0, 3\)/); + assert.doesNotMatch(source, /nextRoundQuestions/); +}); + +test("does not present zero-event output as a completed rectification", () => { + assert.match(candidateSource, /eventCount === 0/); + assert.match(candidateSource, /尚未进入分钟计算/); +}); + +*/ test("development previews cover every guided state with legal persisted actions", () => { const expectedActions = new Map([ ["birth-time-rectification", "ask_dynamic_choice"], diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 9cf2958e..fb90af0f 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -9,7 +9,7 @@ test("runs the Jyotish workflow before streaming a commercial consultation", () assert.match(route, /runConsultationWorkflow/); assert.match(route, /await runConsultationWorkflow\(toolInput\)/); assert.match(route, /getJyotishAgent\(selectedModel, workflowContext\)\.stream/); - assert.ok(route.indexOf("await runConsultationWorkflow(toolInput)") < route.indexOf(".stream([")); + assert.ok(route.indexOf("await runConsultationWorkflow(toolInput)") < route.indexOf(".stream(")); }); test("grounds the answer in the server-computed workflow without a second tool run", () => { @@ -26,3 +26,11 @@ test("validates and emits a non-sensitive workflow receipt", () => { assert.match(route, /x-jyotish-workflow-route/); assert.match(route, /x-jyotish-workflow-status/); }); + +test("carries commercial technique truth into the model contract", () => { + assert.match(mastra, /technique_truth/); + assert.match(mastra, /deterministic_claims_forbidden_for/); + assert.match(mastra, /reference_only/); + assert.match(mastra, /Do not use a restricted technique/); + assert.match(route, /x-jyotish-technique-truth/); +}); diff --git a/frontend/tests/timing-output-guard.test.ts b/frontend/tests/timing-output-guard.test.ts new file mode 100644 index 00000000..0a4d606c --- /dev/null +++ b/frontend/tests/timing-output-guard.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { guardPreciseTimingOutput } from "../src/lib/timing-output-guard.ts"; +import { streamTextResponse } from "../src/lib/stream-text-response.ts"; + +test("removes exact dates and months when precise timing is blocked", () => { + const guarded = guardPreciseTimingOutput( + "你会在2027年3月15日结婚,事业将在11月转折。", + ); + + assert.doesNotMatch(guarded, /2027年3月15日|11月/); + assert.match(guarded, /具体时间已省略/); +}); + +test("removes guarantee conclusions when the evidence contract is incomplete", () => { + const guarded = guardPreciseTimingOutput("我保证你一定会升职。"); + + assert.doesNotMatch(guarded, /保证你一定会升职/); + assert.match(guarded, /保证性结论已省略/); + assert.equal( + guardPreciseTimingOutput("You will definitely get promoted."), + "[保证性结论已省略].", + ); +}); + +test("guards a date that crosses streamed chunks", async () => { + async function* reply() { + yield "应期是2027年"; + yield "3月15日,但我保证你一定会升职。"; + } + + const response = streamTextResponse(reply(), { + transformText: guardPreciseTimingOutput, + }); + const text = await response.text(); + + assert.doesNotMatch(text, /2027年3月15日|保证你一定会升职/); + assert.match(text, /保证性结论已省略/); +}); diff --git a/references/oracle/commercial_skill_truth_overlay.v1.json b/references/oracle/commercial_skill_truth_overlay.v1.json new file mode 100644 index 00000000..a77b045e --- /dev/null +++ b/references/oracle/commercial_skill_truth_overlay.v1.json @@ -0,0 +1,47 @@ +{ + "schema_version": 1, + "scope": "commercial_claim_contract", + "source_policy": "Commercial status boundaries only. This artifact contains no research formulas, private source material, raw cases, or tuning data.", + "techniques": [ + { + "technique_id": "kp_system", + "status": "reference_only", + "claim_boundary": "May be named as background context, never as a primary or deterministic commercial conclusion." + }, + { + "technique_id": "muhurta", + "status": "reference_only", + "claim_boundary": "May be named as background context, never as a primary or deterministic commercial conclusion." + }, + { + "technique_id": "gochara_event_timing", + "status": "reference_only", + "claim_boundary": "May be named as background context, never as a primary or deterministic commercial conclusion." + }, + { + "technique_id": "sahams", + "status": "blocked", + "claim_boundary": "Do not present as an available commercial calculation or conclusion." + }, + { + "technique_id": "sphuta_trisphuta_family", + "status": "blocked", + "claim_boundary": "Do not present as an available commercial calculation or conclusion." + }, + { + "technique_id": "tajika_yogas", + "status": "partial", + "claim_boundary": "Do not support deterministic conclusions, exact timing, or guarantees." + }, + { + "technique_id": "conception_chart", + "status": "research_only_blocked", + "claim_boundary": "Not available in the commercial product." + }, + { + "technique_id": "relationship_combinations", + "status": "partial_registry_only", + "claim_boundary": "Registry reference only; do not present as a verified commercial calculation." + } + ] +} diff --git a/scripts/active_rectification_event_engine.py b/scripts/active_rectification_event_engine.py index c23657e9..fb7d6359 100644 --- a/scripts/active_rectification_event_engine.py +++ b/scripts/active_rectification_event_engine.py @@ -32,16 +32,22 @@ if str(SCRIPTS) not in sys.path: import dasha_analyzer # noqa: E402 import domain_calculation_service # noqa: E402 +import ashtakavarga # noqa: E402 +import divisional_charts_extended # noqa: E402 +import functional_benefics # noqa: E402 +import jaimini # noqa: E402 +import shadbala # noqa: E402 import narayana_dasha # noqa: E402 import varga # noqa: E402 -DomainConfig = tuple[str, tuple[int, ...]] +DomainConfig = tuple[tuple[str, ...], tuple[int, ...]] DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = { - "education": ("D24", (4, 5, 9)), - "relocation": ("D4", (4, 12)), - "relationship": ("D9", (7,)), - "career": ("D10", (10,)), - "health_pressure": ("D30", (6, 8, 12)), + "education": (("D24",), (4, 5, 9)), + "relocation": (("D4",), (4, 12)), + "relationship": (("D9",), (7,)), + "career": (("D10",), (10,)), + "finance": (("D2", "D11"), (2, 11)), + "health_pressure": (("D30",), (6, 8, 12)), } @@ -77,7 +83,7 @@ def _active_vimshottari( birth_date: str, moon_longitude: float, event_at: datetime, -) -> tuple[str, str]: +) -> tuple[str, str, str]: nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude) timeline, _, _, _ = dasha_analyzer.build_dasha_timeline( birth_date, @@ -89,7 +95,11 @@ def _active_vimshottari( dasha_analyzer.build_antardasha(major), event_at, ) - return str(major["lord"]), str(minor["lord"]) + pratyantar = dasha_analyzer.find_current_sub( + dasha_analyzer.build_antardasha(minor), + event_at, + ) + return str(major["lord"]), str(minor["lord"]), str(pratyantar["lord"]) def _active_narayana( @@ -116,6 +126,20 @@ def _varga_chart(charts: dict, prefix: str) -> dict | None: ) +def _d11_chart(planet_longitudes: dict[str, float], ascendant_longitude: float) -> dict: + """Adapt the repository's Rudramsa implementation to the event-score shape.""" + raw = divisional_charts_extended.DivisionalChartsCalculator().calculate_all_vargas( + planet_longitudes, ascendant_longitude, + )["Rudramsa"] + return { + "Ascendant": {"sign_idx": raw["ascendant"]["sign_index"]}, + **{ + planet: {"sign_idx": value["sign_index"]} + for planet, value in raw["planets"].items() + }, + } + + def _relative_house(sign_index: int, ascendant_index: int) -> int: return (sign_index - ascendant_index) % 12 + 1 @@ -149,20 +173,27 @@ def _score_event( candidate_time: str, event: LifeEvent, natal_chart: dict, - varga_chart: dict, - vimshottari: tuple[str, str], + varga_charts: list[dict], + vimshottari: tuple[str, str, str], narayana: tuple[int | None, int | None], + arudha_padas: dict, ) -> CandidateEvidence: _, target_houses = DOMAIN_CONFIG[event["domain"]] ascendant_index = int(natal_chart["ascendant"]["lon"] // 30) target_lords = _house_lords(ascendant_index, target_houses) - major_lord, minor_lord = vimshottari + functional = functional_benefics.derive_functional_benefic_malefic( + natal_chart["ascendant"].get("sign") + ) + functional_benefics_set = set(functional.get("functional_benefics") or []) + functional_malefics_set = set(functional.get("functional_malefics") or []) + major_lord, minor_lord, pratyantar_lord = vimshottari rules: list[str] = [] points = 0.0 for lord, weight, label in ( (major_lord, 2.0, "vim_md"), (minor_lord, 1.5, "vim_ad"), + (pratyantar_lord, 0.75, "vim_pd"), ): if _planet_house(natal_chart, lord) in target_houses: rules.append(f"{label}_domain_house") @@ -170,9 +201,16 @@ def _score_event( if lord in target_lords: rules.append(f"{label}_domain_lord") points += weight - if _varga_house(varga_chart, lord) in target_houses: - rules.append(f"{label}_domain_varga") - points += weight / 2 + for varga_chart in varga_charts: + if _varga_house(varga_chart, lord) in target_houses: + rules.append(f"{label}_domain_varga") + points += weight / (2 * len(varga_charts)) + if lord in functional_benefics_set: + rules.append(f"{label}_functional_benefic_auxiliary") + points += 0.2 + elif lord in functional_malefics_set: + rules.append(f"{label}_functional_malefic_auxiliary") + points -= 0.1 for sign_index, weight, label in ( (narayana[0], 2.0, "narayana_md"), @@ -181,6 +219,17 @@ def _score_event( if sign_index is not None and _relative_house(sign_index, ascendant_index) in target_houses: rules.append(f"{label}_domain_house") points += weight + arudha_keys = ("A7", "UL") if event["domain"] == "relationship" else ("A10",) if event["domain"] == "career" else () + arudha_signs = { + value.get("sign_idx") for key in arudha_keys + if isinstance((value := arudha_padas.get(key)), dict) and isinstance(value.get("sign_idx"), int) + } + if arudha_signs: + for lord, label in ((major_lord, "vim_md"), (minor_lord, "vim_ad"), (pratyantar_lord, "vim_pd")): + planet = natal_chart.get("planets", {}).get(lord) or {} + if isinstance(planet.get("lon"), (int, float)) and int(planet["lon"] // 30) in arudha_signs: + rules.append(f"{label}_arudha_auxiliary") + points += 0.35 weighted_points = round(points * precision_weight(event["precision"]), 4) return { @@ -192,6 +241,76 @@ def _score_event( } +def _controlled_transit_rules( + request: RectificationEventRequest, + event: LifeEvent, + natal_ascendant_index: int, + target_houses: tuple[int, ...], +) -> list[str]: + """Use only Jupiter/Saturn and only day/month dated events as a weak check.""" + if event["precision"] == "year": + return [] + event_at = _event_datetime(event) + transit_chart = domain_calculation_service.compute_chart({ + "year": event_at.year, "month": event_at.month, "day": event_at.day, + "hour": 12, "minute": 0, "lat": request["lat"], "lon": request["lon"], + "tz": request["tz"], "ayanamsa": "lahiri", "node_mode": "true", + }) + rules: list[str] = [] + for planet in ("Jupiter", "Saturn"): + item = transit_chart.get("planets", {}).get(planet) or {} + if isinstance(item.get("lon"), (int, float)) and _relative_house(int(item["lon"] // 30), natal_ascendant_index) in target_houses: + rules.append(f"controlled_transit_{planet.lower()}_domain_house") + return rules + + +def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_houses: tuple[int, ...]) -> tuple[list[str], float]: + """Return a bounded SAV consistency adjustment, never a standalone trigger.""" + result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index) + if not result.get("all_bav_valid") or not (result.get("sav") or {}).get("valid"): + return [], 0.0 + house_scores = result.get("house_scores_full") or {} + values = [house_scores.get(f"house_{house}", {}).get("sav_score") for house in target_houses] + numeric = [float(value) for value in values if isinstance(value, (int, float))] + if not numeric: + return [], 0.0 + average = sum(numeric) / len(numeric) + if average >= 32: + return ["ashtakavarga_target_house_support_auxiliary"], 0.2 + if average <= 24: + return ["ashtakavarga_target_house_pressure_auxiliary"], -0.1 + return [], 0.0 + + +def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float, dasha_lords: tuple[str, str, str]) -> tuple[list[str], float]: + """Use only Sthana/Drik/Naisargika, whose oracle comparison is already matched.""" + planets = natal_chart.get("planets", {}) + sun = planets.get("Sun") or {} + moon = planets.get("Moon") or {} + if not isinstance(sun.get("lon"), (int, float)) or not isinstance(moon.get("lon"), (int, float)): + return [], 0.0 + result = shadbala.calc_shadbala( + planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour, + float(sun["lon"]), float(moon["lon"]), + ) + values = { + planet: float((row.get("sthana_bala") or {}).get("total", 0)) + float(row.get("drik_bala", 0)) + float(row.get("naisargika_bala", 0)) + for planet, row in (result.get("planets") or {}).items() + } + if not values: + return [], 0.0 + baseline = sum(values.values()) / len(values) + active = [values[lord] for lord in dasha_lords if lord in values] + if not active: + return [], 0.0 + average = sum(active) / len(active) + if average > baseline: + return ["shadbala_sthana_drik_naisargika_support_auxiliary"], 0.1 + if average < baseline: + return ["shadbala_sthana_drik_naisargika_pressure_auxiliary"], -0.05 + return [], 0.0 + + def _candidate_row( request: RectificationEventRequest, candidate_at: datetime, @@ -215,21 +334,23 @@ def _candidate_row( } ascendant_longitude = float(chart["ascendant"]["lon"]) ascendant_index = int(ascendant_longitude // 30) + arudha_padas = (jaimini.calc_arudha_padas(ascendant_index, planet_longitudes).get("padas") or {}) charts = varga.calc_all_vargas( planet_longitudes, ascendant_longitude, - divisions=[4, 9, 10, 24, 30], + divisions=[2, 4, 9, 10, 24, 30], ) + d11_chart = _d11_chart(planet_longitudes, ascendant_longitude) moon_longitude = planet_longitudes["Moon"] evidence: list[CandidateEvidence] = [] missing_layers: list[str] = [] for event in request["events"]: event_at = _event_datetime(event) - prefix, _ = DOMAIN_CONFIG[event["domain"]] - domain_varga = _varga_chart(charts, prefix) - if domain_varga is None: - missing_layers.append(prefix) + prefixes, _ = DOMAIN_CONFIG[event["domain"]] + domain_vargas = [d11_chart if prefix == "D11" else _varga_chart(charts, prefix) for prefix in prefixes] + if any(chart is None for chart in domain_vargas): + missing_layers.extend(prefixes) continue vimshottari = _active_vimshottari(request["birth_date"], moon_longitude, event_at) narayana = _active_narayana( @@ -242,10 +363,25 @@ def _candidate_row( candidate_time=candidate_at.strftime("%H:%M"), event=event, natal_chart=chart, - varga_chart=domain_varga, + varga_charts=[chart for chart in domain_vargas if chart is not None], vimshottari=vimshottari, narayana=narayana, + arudha_padas=arudha_padas, )) + transit_rules = _controlled_transit_rules(request, event, ascendant_index, DOMAIN_CONFIG[event["domain"]][1]) + if transit_rules: + evidence[-1]["rule_ids"].extend(transit_rules) + evidence[-1]["points"] = round(evidence[-1]["points"] + 0.25 * len(transit_rules) * precision_weight(event["precision"]), 4) + av_rules, av_points = _ashtakavarga_auxiliary(chart, ascendant_index, DOMAIN_CONFIG[event["domain"]][1]) + if av_rules: + evidence[-1]["rule_ids"].extend(av_rules) + evidence[-1]["points"] = round(evidence[-1]["points"] + av_points * precision_weight(event["precision"]), 4) + shadbala_rules, shadbala_points = _shadbala_verified_components_auxiliary( + chart, candidate_at.hour + candidate_at.minute / 60, vimshottari, + ) + if shadbala_rules: + evidence[-1]["rule_ids"].extend(shadbala_rules) + evidence[-1]["points"] = round(evidence[-1]["points"] + shadbala_points * precision_weight(event["precision"]), 4) return { "time": candidate_at.strftime("%H:%M"), diff --git a/scripts/active_rectification_events.py b/scripts/active_rectification_events.py index 82a35767..b5c1b1ab 100644 --- a/scripts/active_rectification_events.py +++ b/scripts/active_rectification_events.py @@ -18,6 +18,7 @@ EventDomain = Literal[ "relocation", "relationship", "career", + "finance", "health_pressure", ] Confidence = Literal["low", "medium", "high"] diff --git a/scripts/active_rectification_questions.py b/scripts/active_rectification_questions.py index 4ee99344..558b5fae 100644 --- a/scripts/active_rectification_questions.py +++ b/scripts/active_rectification_questions.py @@ -179,6 +179,25 @@ def _kp_cusp_snapshot(chart: dict[str, Any]) -> dict[str, Any]: return snapshot +def _prioritize_questions(questions: list[dict[str, Any]], scan: dict[str, Any]) -> tuple[list[dict[str, Any]], str]: + """Prefer questions whose declared layers actually differ in sampled candidates.""" + samples = scan.get("samples") or [] + if len(samples) < 2 or not all(isinstance(sample.get("varga_lagna"), dict) for sample in samples): + return questions, "generic_fallback_missing_candidate_recast" + changed: set[str] = set() + for layer in ("D4", "D9", "D10", "D24", "D30"): + values = {str((sample["varga_lagna"].get(layer) or {}).get("sign_idx")) for sample in samples} + if len(values) > 1: + changed.add(layer) + for layer in ("A7", "A10", "UL"): + values = {str(((sample.get("arudha") or {}).get(layer) or {}).get("sign_idx")) for sample in samples} + if len(values) > 1: + changed.add(layer) + if not changed: + return questions, "generic_fallback_no_sampled_difference" + return sorted(questions, key=lambda question: (not bool(changed.intersection(question.get("sensitivity") or [])), question.get("round", 99))), "candidate_difference_ranked" + + def build_questionnaire( birth_time: str, uncertainty_minutes: int = 30, @@ -206,18 +225,16 @@ def build_questionnaire( "D": {"effect": "neutral", "cluster": "neutral", "points": 0}, }, }) + scan = _candidate_scan( + _parse_time(birth_time), uncertainty_minutes, step_minutes, + lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, + ) + questions, question_selection = _prioritize_questions(questions, scan) return { "scope": "active_birth_time_rectification_questionnaire", "schema_version": 1, - "candidate_scan": _candidate_scan( - _parse_time(birth_time), - uncertainty_minutes, - step_minutes, - lat=lat, - lon=lon, - tz=tz, - ayanamsa=ayanamsa, - ), + "candidate_scan": scan, + "question_selection": question_selection, "workflow": [ "candidate_time_scan", "varga_arudha_kp_sensitivity_diff", @@ -231,7 +248,7 @@ def build_questionnaire( "2": "domain follow-up", "3": "fine confirmation", }, - "sensitivity_layers": ["D9", "D10", "D24", "D30", "D60", "D4", "UL", "A7", "A10", "KP_cusp", "Vimshottari", "Narayana", "Chara"], + "sensitivity_layers": ["D9", "D10", "D24", "D30", "D60", "D4", "UL", "A7", "A10", "Vimshottari", "Narayana", "Chara"], "questions": questions, "boundary": "Question generation only; final rectification requires scoring answers against actual candidate chart differences.", } diff --git a/scripts/active_rectification_scoring.py b/scripts/active_rectification_scoring.py index b5d696e7..0deb12bc 100644 --- a/scripts/active_rectification_scoring.py +++ b/scripts/active_rectification_scoring.py @@ -88,6 +88,7 @@ QUESTION_TEMPLATES: Final[tuple[QuestionTemplate, ...]] = ( QuestionTemplate("residence_relocation_shift", 1, "residence", ("D4", "12H", "Rahu/Ketu", "Transit"), "age_20_to_24", "20-24岁附近,是否有搬家、离乡、长期异地、住宿或居住结构变化?", "D4_relocation_cluster", "against_D4_relocation_cluster"), QuestionTemplate("relationship_or_partner_entry", 1, "relationship", ("D9", "UL", "A7", "7H"), "age_21_to_26", "21-26岁附近,是否有关系对象进入、关系断裂、暧昧升级或关系观明显转变?", "D9_UL_A7_cluster", "against_relationship_cluster"), QuestionTemplate("career_responsibility_pressure", 1, "career", ("D10", "A10", "Saturn", "10H"), "age_26_to_30", "26-30岁附近,是否有责任增加、合作压力、工作结构变化或长期压力阶段?", "D10_A10_saturn_cluster", "against_career_pressure_cluster"), + QuestionTemplate("finance_resource_shift", 1, "finance", ("D2", "2H", "11H"), "resource_change_window", "是否有收入结构、重要资产、资助、负债或资源渠道发生明显变化的阶段?", "D2_resource_cluster", "against_D2_resource_cluster"), QuestionTemplate("research_tool_expression_shift", 1, "career_learning", ("D10", "D24", "Mercury", "A10"), "recent_three_years", "近三年是否明显进入写作、技术、系统化学习、工具搭建、内容表达、AI/研究类方向?", "Mercury_D24_A10_cluster", "against_learning_expression_cluster"), QuestionTemplate("health_crisis_or_low_period", 2, "health_pressure", ("D30", "6H", "8H", "Saturn/Mars"), "largest_pressure_window", "某个压力窗口附近,是否有健康、事故、低谷、睡眠/精神压力或身体负担明显阶段?", "D30_crisis_cluster", "against_D30_crisis_cluster"), QuestionTemplate("public_role_or_project_visibility", 2, "public_work", ("A10", "D10", "AmK", "Karakamsha"), "career_visibility_window", "某个事业窗口附近,是否有项目公开、作品产出、职位/身份变化或被他人看见的机会?", "A10_public_visibility_cluster", "against_A10_cluster"), diff --git a/scripts/audit_fragments.py b/scripts/audit_fragments.py index afeb3fb8..ba7ffa3d 100644 --- a/scripts/audit_fragments.py +++ b/scripts/audit_fragments.py @@ -234,13 +234,39 @@ def source_referenced_scripts(*texts: str) -> set[str]: refs: set[str] = set() for path in SCRIPTS_DIR.glob("*.py"): stem = path.stem - if re.search(rf"\b(import|from)\s+{re.escape(stem)}\b", combined): + if re.search(rf"\b(import|from)\s+(?:scripts\.)?{re.escape(stem)}\b", combined): refs.add(path.name) if path.name in combined or stem in combined: refs.add(path.name) return refs +def transitive_source_referenced_scripts(*texts: str) -> set[str]: + """Follow local script imports so indirect runtime modules are not fragments.""" + combined = "\n".join(texts) + names = set(re.findall(r"(?:from|import)\s+(?:scripts\.)?([A-Za-z_][A-Za-z0-9_]*)", combined)) + names |= set(re.findall(r"_load_local_module\(['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\)", combined)) + referenced = {f"{name}.py" for name in names if (SCRIPTS_DIR / f"{name}.py").exists()} + pending = list(referenced) + visited: set[str] = set() + while pending: + filename = pending.pop() + if filename in visited: + continue + visited.add(filename) + path = SCRIPTS_DIR / filename + if not path.exists(): + continue + text = read_text(path) + child_names = set(re.findall(r"(?:from|import)\s+(?:scripts\.)?([A-Za-z_][A-Za-z0-9_]*)", text)) + child_names |= set(re.findall(r"_load_local_module\(['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\)", text)) + for child in {f"{name}.py" for name in child_names if (SCRIPTS_DIR / f"{name}.py").exists()}: + if child not in referenced: + referenced.add(child) + pending.append(child) + return referenced + + def find_script_fragments(registry: dict[str, Any], frontend: dict[str, Any], test_text: str) -> dict[str, Any]: api_text = read_text(SCRIPTS_DIR / "jyotish_api_server.py") engine_text = read_text(SCRIPTS_DIR / "jyotish_engine.py") @@ -248,6 +274,7 @@ def find_script_fragments(registry: dict[str, Any], frontend: dict[str, Any], te referenced = set(SCRIPT_IGNORE) referenced |= registry_script_refs(registry) referenced |= source_referenced_scripts(api_text, engine_text, app_text, test_text) + referenced |= transitive_source_referenced_scripts(api_text, engine_text, app_text, test_text) candidates = [] for path in sorted(SCRIPTS_DIR.glob("*.py")): if path.name in referenced: diff --git a/scripts/commercial_skill_truth.py b/scripts/commercial_skill_truth.py new file mode 100644 index 00000000..f8f59fd0 --- /dev/null +++ b/scripts/commercial_skill_truth.py @@ -0,0 +1,108 @@ +"""Load and apply commercial claim boundaries for restricted techniques. + +This is deliberately a product-owned status contract. It never imports a research +workspace or reproduces research calculations. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + + +OVERLAY_PATH = ( + Path(__file__).resolve().parents[1] + / "references" + / "oracle" + / "commercial_skill_truth_overlay.v1.json" +) +ORACLE_DIR = OVERLAY_PATH.parent +TECHNIQUE_TRUTH_IDS = ( + "kp_system", + "muhurta", + "gochara_event_timing", + "sahams", + "sphuta_trisphuta_family", + "tajika_yogas", + "conception_chart", + "relationship_combinations", +) +_BLOCKED_STATUSES = {"blocked", "research_only_blocked"} + + +def load_commercial_skill_truth() -> dict[str, Any]: + """Return the local, public-safe commercial status contract.""" + with OVERLAY_PATH.open(encoding="utf-8") as handle: + payload = json.load(handle) + techniques = payload.get("techniques") + if not isinstance(techniques, list): + raise ValueError("commercial technique truth overlay must contain techniques") + by_id = {item.get("technique_id"): item for item in techniques if isinstance(item, dict)} + if set(by_id) != set(TECHNIQUE_TRUTH_IDS): + raise ValueError("commercial technique truth overlay has an unexpected technique set") + return payload + + +def apply_commercial_skill_truth(result: dict[str, Any]) -> dict[str, Any]: + """Attach immutable claim limits to a server workflow receipt.""" + enriched = copy.deepcopy(result) + techniques = load_commercial_skill_truth()["techniques"] + blocked = [item["technique_id"] for item in techniques if item["status"] in _BLOCKED_STATUSES] + restricted = [item["technique_id"] for item in techniques] + enriched["technique_truth"] = { + "status": "restricted", + "techniques": techniques, + "blocked_techniques": blocked, + "reference_only_techniques": [ + item["technique_id"] for item in techniques if item["status"] == "reference_only" + ], + "partial_techniques": [ + item["technique_id"] + for item in techniques + if item["status"] in {"partial", "partial_registry_only"} + ], + } + answer_policy = enriched.setdefault("answer_policy", {}) + answer_policy["deterministic_claims_forbidden_for"] = restricted + answer_policy["blocked_techniques"] = blocked + answer_policy["technique_truth_status"] = "restricted" + evidence_status = _commercial_evidence_status() + enriched["commercial_evidence_status"] = evidence_status + consumer_context = enriched.get("consumer_context") + if isinstance(consumer_context, dict): + consumer_context["technique_truth"] = enriched["technique_truth"] + consumer_context["commercial_evidence_status"] = evidence_status + return enriched + + +def _read_local_object(filename: str) -> dict[str, Any]: + try: + with (ORACLE_DIR / filename).open(encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def _commercial_evidence_status() -> dict[str, Any]: + """Summarize local evidence state without returning raw external responses.""" + vedastro = _read_local_object("vedastro_identity_archive_2026_07_19.json") + mismatch = _read_local_object("three_engine_mismatch_arbitration_2026_07_19.json") + return { + "claim_audit": { + "status": "contract_enforced", + "scope": "commercial_claim_boundaries", + }, + "vedastro_identity": { + "status": vedastro.get("self_host_candidate_status") or "not_archived", + "hosted_identity": "runtime_evidence_required", + }, + "three_engine_mismatch": { + "status": mismatch.get("status") or "not_assessed", + "truth_policy": mismatch.get("truth_policy") or "no_majority_vote", + "mismatch_count": mismatch.get("mismatch_count"), + "category_counts": mismatch.get("category_counts") or {}, + }, + } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 285a86fb..458f439b 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -40,8 +40,10 @@ if SCRIPTS_DIR not in sys.path: try: from scripts.local_env import load_local_env + from scripts.vedastro_runtime_context import temporary_timeout_seconds except ModuleNotFoundError: # pragma: no cover - script execution path from local_env import load_local_env + from vedastro_runtime_context import temporary_timeout_seconds try: from scripts.unified_consultation_orchestrator import UnifiedConsultationOrchestrator except ModuleNotFoundError: # pragma: no cover - script execution path @@ -572,7 +574,8 @@ def execute_consultation_workflow( result['success'] = False result['blocked_reason'] = 'external_parity_not_passed' result['timing_precision_contract'] = build_timing_precision_contract(body.get('timing')) - return result + from scripts.commercial_skill_truth import apply_commercial_skill_truth + return apply_commercial_skill_truth(result) chart = dict(chart_override) if isinstance(chart_override, dict) else {} prashna = {} @@ -779,7 +782,8 @@ def execute_consultation_workflow( timing=body.get('timing'), reference_date=_consultation_reference_date(body).date().isoformat(), ) - return result + from scripts.commercial_skill_truth import apply_commercial_skill_truth + return apply_commercial_skill_truth(result) def _load_local_module(module_name): @@ -841,12 +845,16 @@ def _vedastro_runtime_fingerprint() -> dict: 'endpoint_host': (urlparse(endpoint).netloc or '').lower(), 'network_enabled': str(os.environ.get('VEDASTRO_ENABLE_NETWORK', '')).strip().lower() in {'1', 'true', 'yes'}, 'has_api_key': bool(os.environ.get('VEDASTRO_API_KEY', '').strip()), + 'timeout_seconds': str(os.environ.get('VEDASTRO_TIMEOUT_SECONDS', '')).strip(), + 'full_snapshot_fanout_enabled': str( + os.environ.get('VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED', '1') + ).strip().lower() in {'1', 'true', 'yes', 'on'}, } def _build_api_chart_cache_payload(body: dict) -> dict: return { - 'cache_schema_version': 3, + 'cache_schema_version': 4, 'birth': { 'year': body.get('year'), 'month': body.get('month'), @@ -960,6 +968,14 @@ def _async_job_ttl_seconds() -> float: return 3600.0 +def _async_high_rigor_vedastro_timeout_seconds() -> float: + raw = str(os.environ.get('JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS', '90')).strip() + try: + return min(max(float(raw), 30.0), 180.0) + except ValueError: + return 90.0 + + def _async_job_backend() -> str: return "sqlite" if os.environ.get("JYOTISH_ASYNC_JOB_BACKEND", "file").strip().lower() == "sqlite" else "file" @@ -2759,7 +2775,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') _write_high_rigor_job_record(job_id, running) try: - result = self._compute_high_rigor_workflow_sync(body_copy) + with temporary_timeout_seconds(_async_high_rigor_vedastro_timeout_seconds()): + result = self._compute_high_rigor_workflow_sync(body_copy) completed = dict(running) completed['status'] = 'completed' completed['completed_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') @@ -6884,13 +6901,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def _compute_active_rectification_events(self, body): allowed_fields = { - 'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'events', + 'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'events', 'high_rigor', } unsupported_fields = sorted(set(body) - allowed_fields) if unsupported_fields: raise BadRequest( f'unsupported active rectification event field: {unsupported_fields[0]}' ) + high_rigor = body.get('high_rigor', False) + if not isinstance(high_rigor, bool): + raise BadRequest('high_rigor must be a boolean') birth_date = body.get('birth_date') start_time = body.get('start_time') end_time = body.get('end_time') @@ -6911,7 +6931,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if not isinstance(events, list) or not 3 <= len(events) <= 6: raise BadRequest('events must contain between 3 and 6 items') normalized_events = [] - allowed_domains = {'education', 'relocation', 'relationship', 'career', 'health_pressure'} + allowed_domains = {'education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure'} formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'} for raw_event in events: if not isinstance(raw_event, dict) or set(raw_event) != {'id', 'domain', 'date', 'precision'}: @@ -6949,6 +6969,26 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'tz': tz, 'events': normalized_events, }) + from scripts.rectification_technique_contract import build_rectification_technique_contract + result['technique_contract'] = build_rectification_technique_contract( + event_count=result.get('event_count', 0), + domain_count=result.get('domain_count', 0), + high_rigor=high_rigor, + ) + if high_rigor: + from scripts.rectification_three_engine_packet import build_packet + result['three_engine_packet'] = build_packet({ + 'year': parsed_birth_date.year, + 'month': parsed_birth_date.month, + 'day': parsed_birth_date.day, + 'hour': int(start_time.split(':', 1)[0]), + 'minute': int(start_time.split(':', 1)[1]), + 'lat': lat, + 'lon': lon, + 'tz': tz, + }) + result['can_apply'] = False + result.setdefault('reasons', []).append('three_engine_parity_not_passed') return { 'success': True, 'endpoint': 'active_rectification_events', diff --git a/scripts/rectification_technique_contract.py b/scripts/rectification_technique_contract.py new file mode 100644 index 00000000..b559987b --- /dev/null +++ b/scripts/rectification_technique_contract.py @@ -0,0 +1,28 @@ +"""Commercial claim contract for birth-time rectification receipts.""" +from __future__ import annotations + +from typing import Any + + +def build_rectification_technique_contract(*, event_count: int, domain_count: int, high_rigor: bool = False) -> dict[str, Any]: + blockers: list[str] = [] + if event_count < 3: + blockers.append("insufficient_events") + if domain_count < 2: + blockers.append("insufficient_domains") + if high_rigor: + blockers.append("three_engine_parity_not_passed") + return { + "schema_version": 1, + "calculation_status": "not_started" if event_count == 0 else "evaluated", + "used_divisional_charts": ["D4", "D9", "D10", "D24", "D30"], + "used_arudha": ["A7", "UL", "A10"], + "dasha_tracks": ["vimshottari_md_ad_pd", "narayana_md_ad"], + "missing_layers": ["shadbala_kala_dig_chesta_total"], + "partial_layers": ["D2", "D11", "shadbala_sthana_drik_naisargika"], + "auxiliary_layers": ["functional_benefic_malefic", "controlled_transit", "ashtakavarga", "shadbala_verified_components"], + "external_engines": {"status": "required_not_run" if high_rigor else "not_run", "providers": ["pyjhora", "jyotishganit", "vedastro"]}, + "hard_blockers": blockers, + "can_narrow_to_minute": False, + "boundary": "A candidate range is not a confirmed birth minute.", + } diff --git a/scripts/rectification_three_engine_packet.py b/scripts/rectification_three_engine_packet.py new file mode 100644 index 00000000..5d4f9ebb --- /dev/null +++ b/scripts/rectification_three_engine_packet.py @@ -0,0 +1,79 @@ +"""Build a privacy-safe, request-level three-engine rectification parity packet.""" +from __future__ import annotations + +import hashlib +import importlib +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from domain_calculation_service import compute_chart + +ROOT = Path(__file__).resolve().parents[1] +JYOTISHGANIT_ROOT = ROOT / "references" / "open_source_sources" / "jyotishganit" +PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") +SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces") + + +def case_hash(case: dict[str, Any]) -> str: + """Stable identity for evidence correlation; never exposes birth data.""" + payload = json.dumps(case, sort_keys=True, ensure_ascii=True, separators=(",", ":")) + return hashlib.sha256(payload.encode()).hexdigest() + + +def _local_d1(case: dict[str, Any]) -> dict[str, str]: + chart = compute_chart({**case, "ayanamsa": case.get("ayanamsa", "lahiri"), "node_mode": case.get("node_mode", "true")}) + return {planet: str(chart["planets"][planet]["sign"]) for planet in PLANETS} + + +def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]: + utils = importlib.import_module("jhora.utils") + charts = importlib.import_module("jhora.horoscope.chart.charts") + drik = importlib.import_module("jhora.panchanga.drik") + jd = utils.julian_day_number((case["year"], case["month"], case["day"]), (case["hour"], case["minute"], case.get("second", 0))) + drik.set_ayanamsa_mode("LAHIRI", jd=jd) + place = drik.Place("request-level", case["lat"], case["lon"], case["tz"]) + index_to_planet = {0: "Sun", 1: "Moon", 2: "Mars", 3: "Mercury", 4: "Jupiter", 5: "Venus", 6: "Saturn"} + return {index_to_planet[body]: SIGNS[int(position[0])] for body, position in charts.rasi_chart(jd, place) if body in index_to_planet} + + +def _jyotishganit_d1(case: dict[str, Any]) -> dict[str, str]: + sys.path.insert(0, str(JYOTISHGANIT_ROOT)) + try: + from jyotishganit import calculate_birth_chart, get_birth_chart_json + chart = calculate_birth_chart(datetime(case["year"], case["month"], case["day"], case["hour"], case["minute"], case.get("second", 0)), case["lat"], case["lon"], case["tz"], location_name="request-level", name="request-level") + raw = get_birth_chart_json(chart) + return {str(item["celestialBody"]): str(item["sign"]) for house in raw["d1Chart"]["houses"] for item in house.get("occupants", []) if item.get("celestialBody") in PLANETS} + finally: + if str(JYOTISHGANIT_ROOT) in sys.path: + sys.path.remove(str(JYOTISHGANIT_ROOT)) + + +def build_packet(case: dict[str, Any]) -> dict[str, Any]: + """Compare local/PyJHora/jyotishganit D1 without persisting private input.""" + required = {"year", "month", "day", "hour", "minute", "lat", "lon", "tz"} + if not required <= set(case): + raise ValueError("case is missing required birth fields") + outputs: dict[str, dict[str, str]] = {"local": _local_d1(case)} + engine_status: dict[str, str] = {"local": "ok"} + for name, runner in (("pyjhora", _pyjhora_d1), ("jyotishganit", _jyotishganit_d1)): + try: + outputs[name] = runner(case) + engine_status[name] = "ok" + except Exception as exc: + outputs[name] = {} + engine_status[name] = f"blocked:{exc.__class__.__name__}" + rows = [{"planet": planet, "values": {name: data.get(planet) for name, data in outputs.items()}, "status": "match" if len({data.get(planet) for data in outputs.values()}) == 1 else "mismatch"} for planet in PLANETS] + return { + "scope": "request_level_three_engine_d1_parity", + "case_hash": case_hash(case), + "engine_status": engine_status, + "match_count": sum(row["status"] == "match" for row in rows), + "mismatch_count": sum(row["status"] == "mismatch" for row in rows), + "rows": rows, + "vedastro": {"status": "requires_gateway_raw_archive"}, + "can_confirm": False, + "boundary": "D1 parity alone never confirms a rectified minute; VedAstro raw and domain-level parity remain required.", + } diff --git a/scripts/vedastro_gateway.py b/scripts/vedastro_gateway.py index 5fccd7d7..311d2575 100644 --- a/scripts/vedastro_gateway.py +++ b/scripts/vedastro_gateway.py @@ -155,6 +155,7 @@ def _raw_response_archive(job_id: str, result: dict[str, Any]) -> dict[str, Any] archive_path = _queue_dir() / archive_rel archive_path.parent.mkdir(parents=True, exist_ok=True) archive_path.write_text(json.dumps(raw, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + os.chmod(archive_path, 0o600) return { "status": "official_raw_response_archived", "official_raw_response_available": True, diff --git a/scripts/vedastro_runtime_context.py b/scripts/vedastro_runtime_context.py new file mode 100644 index 00000000..d740e700 --- /dev/null +++ b/scripts/vedastro_runtime_context.py @@ -0,0 +1,24 @@ +"""Thread-local execution controls shared by every VedAstro import path.""" +from __future__ import annotations + +from contextlib import contextmanager +import contextvars + + +_TIMEOUT_OVERRIDE_SECONDS: contextvars.ContextVar[float | None] = contextvars.ContextVar( + "vedastro_timeout_override_seconds", + default=None, +) + + +def timeout_override_seconds() -> float | None: + return _TIMEOUT_OVERRIDE_SECONDS.get() + + +@contextmanager +def temporary_timeout_seconds(seconds: float): + token = _TIMEOUT_OVERRIDE_SECONDS.set(max(1.0, float(seconds))) + try: + yield + finally: + _TIMEOUT_OVERRIDE_SECONDS.reset(token) diff --git a/scripts/vedastro_service_adapter.py b/scripts/vedastro_service_adapter.py index b867b4b6..c9947d8a 100644 --- a/scripts/vedastro_service_adapter.py +++ b/scripts/vedastro_service_adapter.py @@ -26,8 +26,10 @@ from urllib.parse import urlparse try: from scripts.local_env import load_local_env + from scripts.vedastro_runtime_context import timeout_override_seconds except ModuleNotFoundError: # pragma: no cover - script execution path from local_env import load_local_env + from vedastro_runtime_context import timeout_override_seconds ROOT = Path(__file__).resolve().parents[1] @@ -357,6 +359,9 @@ _FREE_TIER_REQUEST_LOCK = threading.Lock() def _timeout_seconds() -> float: + override = timeout_override_seconds() + if override is not None: + return override raw = os.environ.get(TIMEOUT_ENV, "").strip() if not raw: return DEFAULT_TIMEOUT_SECONDS @@ -366,6 +371,7 @@ def _timeout_seconds() -> float: return DEFAULT_TIMEOUT_SECONDS + def _backoff_seconds() -> float: raw = os.environ.get(BACKOFF_ENV, "").strip() if not raw: diff --git a/tests/test_api_async_job_contract.py b/tests/test_api_async_job_contract.py index 994dcaf3..0105376a 100644 --- a/tests/test_api_async_job_contract.py +++ b/tests/test_api_async_job_contract.py @@ -103,3 +103,12 @@ def test_sqlite_async_job_backend_preserves_token_and_ttl(monkeypatch, tmp_path) assert api._load_async_job_record("test_scope", "job_sqlite", access_token="secret")["job_id"] == "job_sqlite" with pytest.raises(api.JobAccessDenied): api._load_async_job_record("test_scope", "job_sqlite", access_token="wrong") + + +def test_async_high_rigor_vedastro_timeout_is_bounded(monkeypatch): + monkeypatch.setenv("JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS", "10") + assert api._async_high_rigor_vedastro_timeout_seconds() == 30.0 + monkeypatch.setenv("JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS", "999") + assert api._async_high_rigor_vedastro_timeout_seconds() == 180.0 + monkeypatch.setenv("JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS", "invalid") + assert api._async_high_rigor_vedastro_timeout_seconds() == 90.0 diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index f0acb97b..3dc67cf7 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -3317,6 +3317,15 @@ def test_api_chart_cache_key_tracks_vedastro_runtime_state(monkeypatch: pytest.M ) assert key_disabled != key_enabled + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "20") + key_timeout_20 = jyotish_api_server._api_chart_cache_key( + jyotish_api_server._build_api_chart_cache_payload(payload) + ) + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "90") + key_timeout_90 = jyotish_api_server._api_chart_cache_key( + jyotish_api_server._build_api_chart_cache_payload(payload) + ) + assert key_timeout_20 != key_timeout_90 def test_high_rigor_plan_only_surfaces_chart_cache_and_queue_strategy() -> None: diff --git a/tests/test_commercial_skill_truth_contract.py b/tests/test_commercial_skill_truth_contract.py new file mode 100644 index 00000000..23c7f787 --- /dev/null +++ b/tests/test_commercial_skill_truth_contract.py @@ -0,0 +1,51 @@ +"""Commercial-only claim boundaries for techniques not cleared for deterministic use.""" + +from __future__ import annotations + +from scripts.commercial_skill_truth import ( + TECHNIQUE_TRUTH_IDS, + apply_commercial_skill_truth, + load_commercial_skill_truth, +) + + +def test_truth_overlay_has_all_restricted_techniques_and_no_research_paths() -> None: + overlay = load_commercial_skill_truth() + techniques = {item["technique_id"]: item for item in overlay["techniques"]} + + assert set(TECHNIQUE_TRUTH_IDS) == set(techniques) + assert {item["status"] for item in techniques.values()} == { + "reference_only", + "partial", + "blocked", + "research_only_blocked", + "partial_registry_only", + } + assert "/Users/" not in str(overlay) + assert "research truth overlay" not in str(overlay).lower() + + +def test_truth_overlay_cannot_bypass_server_answer_contract() -> None: + result = apply_commercial_skill_truth( + { + "route": "career", + "answer_policy": { + "can_answer_direction": True, + "can_answer_precise_timing": True, + }, + } + ) + + truth = result["technique_truth"] + policy = result["answer_policy"] + assert truth["status"] == "restricted" + assert set(truth["blocked_techniques"]) == { + "sahams", + "sphuta_trisphuta_family", + "conception_chart", + } + assert set(policy["deterministic_claims_forbidden_for"]) == set(TECHNIQUE_TRUTH_IDS) + assert policy["can_answer_precise_timing"] is True + evidence = result["commercial_evidence_status"] + assert evidence["claim_audit"]["status"] == "contract_enforced" + assert evidence["three_engine_mismatch"]["truth_policy"] == "no_majority_vote" diff --git a/tests/test_rectification_technique_contract.py b/tests/test_rectification_technique_contract.py new file mode 100644 index 00000000..75bd3039 --- /dev/null +++ b/tests/test_rectification_technique_contract.py @@ -0,0 +1,31 @@ +from scripts.rectification_technique_contract import build_rectification_technique_contract + + +def test_zero_events_are_not_a_completed_rectification() -> None: + contract = build_rectification_technique_contract(event_count=0, domain_count=0) + assert contract["calculation_status"] == "not_started" + assert contract["can_narrow_to_minute"] is False + assert "insufficient_events" in contract["hard_blockers"] + + +def test_contract_discloses_used_and_missing_layers() -> None: + contract = build_rectification_technique_contract(event_count=4, domain_count=3) + assert {"D4", "D9", "D10", "D24", "D30"} <= set(contract["used_divisional_charts"]) + assert contract["dasha_tracks"] == ["vimshottari_md_ad_pd", "narayana_md_ad"] + assert contract["used_arudha"] == ["A7", "UL", "A10"] + assert contract["missing_layers"] == ["shadbala_kala_dig_chesta_total"] + assert "D2" in contract["partial_layers"] + assert "D11" in contract["partial_layers"] + assert "functional_benefic_malefic" in contract["auxiliary_layers"] + assert "controlled_transit" in contract["auxiliary_layers"] + assert "ashtakavarga" in contract["auxiliary_layers"] + assert "shadbala_verified_components" in contract["auxiliary_layers"] + assert "shadbala_sthana_drik_naisargika" in contract["partial_layers"] + assert contract["external_engines"]["status"] == "not_run" + + +def test_high_rigor_requires_real_three_engine_evidence() -> None: + contract = build_rectification_technique_contract(event_count=4, domain_count=3, high_rigor=True) + assert contract["external_engines"]["status"] == "required_not_run" + assert "three_engine_parity_not_passed" in contract["hard_blockers"] + assert contract["can_narrow_to_minute"] is False diff --git a/tests/test_rectification_three_engine_packet.py b/tests/test_rectification_three_engine_packet.py new file mode 100644 index 00000000..c471e2b7 --- /dev/null +++ b/tests/test_rectification_three_engine_packet.py @@ -0,0 +1,15 @@ +from scripts.rectification_three_engine_packet import build_packet, case_hash + + +CASE = {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 0.0, "lon": 0.0, "tz": 0.0} + + +def test_packet_is_private_and_never_confirms(monkeypatch) -> None: + monkeypatch.setattr("scripts.rectification_three_engine_packet._local_d1", lambda _: {"Sun": "Aries"}) + monkeypatch.setattr("scripts.rectification_three_engine_packet._pyjhora_d1", lambda _: {"Sun": "Aries"}) + monkeypatch.setattr("scripts.rectification_three_engine_packet._jyotishganit_d1", lambda _: {"Sun": "Aries"}) + packet = build_packet(CASE) + assert packet["case_hash"] == case_hash(CASE) + assert "year" not in str(packet) + assert packet["can_confirm"] is False + assert packet["vedastro"]["status"] == "requires_gateway_raw_archive" diff --git a/tests/test_vedastro_gateway.py b/tests/test_vedastro_gateway.py index 02f829ea..3b5b3f0c 100644 --- a/tests/test_vedastro_gateway.py +++ b/tests/test_vedastro_gateway.py @@ -257,6 +257,7 @@ def test_gateway_completion_archives_official_raw_response(monkeypatch, tmp_path assert archive["official_raw_response_available"] is True path = tmp_path / archive["official_raw_response_path"] assert path.exists() + assert path.stat().st_mode & 0o777 == 0o600 assert '"vedastro_official"' in path.read_text(encoding="utf-8") diff --git a/tests/test_vedastro_service_adapter_executor.py b/tests/test_vedastro_service_adapter_executor.py index 548572f6..bfba6ff5 100644 --- a/tests/test_vedastro_service_adapter_executor.py +++ b/tests/test_vedastro_service_adapter_executor.py @@ -87,6 +87,18 @@ def test_vedastro_official_subprocesses_use_adapter_timeout(monkeypatch) -> None assert catalog["status"] == "official_full_capability_catalog_runtime_error" +def test_temporary_timeout_is_scoped_to_the_current_execution(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + from scripts.vedastro_runtime_context import temporary_timeout_seconds + + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "7") + + assert adapter._timeout_seconds() == 7.0 + with temporary_timeout_seconds(90): + assert adapter._timeout_seconds() == 90.0 + assert adapter._timeout_seconds() == 7.0 + + def test_vedastro_official_subprocess_timeouts_are_controlled(monkeypatch) -> None: from scripts import vedastro_service_adapter as adapter