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
This commit is contained in:
@@ -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**(时间段+行动+置信度+案例检索)
|
||||
- [ ] 分盘验证
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> | 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 },
|
||||
);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) => (
|
||||
<ChatMessageRow key={message.renderKey} message={message} />
|
||||
))}
|
||||
))}
|
||||
{activeError && <p className="error-message">{activeError}</p>}
|
||||
<div ref={conversationEnd} />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
<p className="birth-time-assessment-unavailable">尚未进入分钟计算:还没有可评分的关键经历资料。</p>
|
||||
<p className="birth-time-evidence-boundary">当前范围仅为填报范围,不是校正结果;请补充跨领域、可注明年月的重大事件后重新评估。</p>
|
||||
{terminalPath && <button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.editBirthTimeDetails}>补充资料并重新评估</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!result && action.kind === "present_low_result") {
|
||||
return (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
@@ -30,6 +39,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
|
||||
}
|
||||
if (!result) return null;
|
||||
const winner = result.winningSegment;
|
||||
const receipt = result.techniqueReceipt;
|
||||
|
||||
return (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
@@ -48,6 +58,15 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
|
||||
<p className="birth-time-assessment-unavailable">候选仍然并列或缺少必要计算层,系统不会选择具体分钟。</p>
|
||||
)}
|
||||
<p className="birth-time-evidence-boundary">这是可复现的候选评分,不代表已经证明出生记录中的具体分钟。</p>
|
||||
{receipt && (
|
||||
<details className="birth-time-evidence-receipt">
|
||||
<summary>本次计算回执</summary>
|
||||
<p>已用:{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}</p>
|
||||
<p>辅助:{receipt.auxiliaryLayers.join("、") || "无"}</p>
|
||||
<p>未完成:{receipt.missingLayers.join("、") || "无"}</p>
|
||||
{receipt.hardBlockers.length > 0 && <p>阻止确认:{receipt.hardBlockers.join("、")}</p>}
|
||||
</details>
|
||||
)}
|
||||
|
||||
{action.kind === "present_low_result" && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
|
||||
@@ -10,6 +10,7 @@ const domainLabels = {
|
||||
relocation: "搬迁与长期居住地",
|
||||
relationship: "重要关系",
|
||||
career: "工作与身份变化",
|
||||
finance: "收入、资产或资源变化",
|
||||
health_pressure: "健康或生活压力",
|
||||
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Record<EvidenceDomain, string>>;
|
||||
|
||||
|
||||
@@ -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<VersionedJourneyResponse>;
|
||||
type DraftRevision = {
|
||||
readonly userId: string;
|
||||
|
||||
@@ -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<typeof candidateResultApiSchema>):
|
||||
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,
|
||||
} } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -10,7 +10,7 @@ const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
||||
export const questionSpecSchema: z.ZodType<QuestionSpec> = 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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ type StreamTextResponseOptions = StreamHooks & {
|
||||
readonly mode: "engine" | "mastra";
|
||||
readonly requestId: string;
|
||||
readonly headers?: Record<string, string>;
|
||||
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<Uint8Array>({
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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, /保证性结论已省略/);
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -18,6 +18,7 @@ EventDomain = Literal[
|
||||
"relocation",
|
||||
"relationship",
|
||||
"career",
|
||||
"finance",
|
||||
"health_pressure",
|
||||
]
|
||||
Confidence = Literal["low", "medium", "high"]
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user