feat: enforce commercial Jyotish workflow contracts

This commit is contained in:
732642856
2026-07-19 09:00:14 +08:00
parent 3d6d498892
commit 8b608781c9
14 changed files with 470 additions and 16 deletions
+11 -1
View File
@@ -1,7 +1,9 @@
import { NextResponse } from "next/server";
import {
consultationInputSchema,
consultationWorkflowReceipt,
getJyotishAgent,
runConsultationWorkflow,
} from "@/mastra";
import {
languageModelConfigurationMessage,
@@ -167,8 +169,10 @@ export async function POST(request: Request) {
try {
const { history, name } = parsed.data;
const toolInput = consultationInputSchema.parse(parsed.data);
const workflowContext = await runConsultationWorkflow(toolInput);
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
const result = await getJyotishAgent(selectedModel).stream([
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
...history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
@@ -191,6 +195,12 @@ export async function POST(request: Request) {
return streamTextResponse(result.textStream, {
mode: "mastra",
requestId,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
},
onComplete: () => settle(completeAndRecordUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
+11 -3
View File
@@ -1,12 +1,12 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { BirthTimeRectification } from "@/components/birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ModelSelector } from "@/components/model-selector";
import { Button } from "@/components/ui/button";
@@ -46,6 +46,14 @@ import {
} from "@/lib/public-models";
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
const BirthTimeRectification = dynamic(
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
{
ssr: false,
loading: () => <p className="birth-time-assistant-intent" role="status">...</p>,
},
);
type Theme = ReplyTheme;
type Message = { role: "user" | "assistant"; text: string; suggestions?: string[] };
type Profile = BirthTimeDraft & {
@@ -1165,7 +1173,7 @@ export default function Home() {
return () => {
cancelled = true;
};
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
}, [hydrated, profile, profileComplete]);
useEffect(() => {
if (!hydrated || !profileComplete) return;
@@ -1181,7 +1189,7 @@ export default function Home() {
return () => {
cancelled = true;
};
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
}, [hydrated, profile, profileComplete]);
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+2
View File
@@ -7,6 +7,7 @@ type StreamHooks = {
type StreamTextResponseOptions = StreamHooks & {
readonly mode: "engine" | "mastra";
readonly requestId: string;
readonly headers?: Record<string, string>;
};
export function streamTextResponse(
@@ -62,6 +63,7 @@ export function streamTextResponse(
"x-accel-buffering": "no",
"x-ayanam-mode": options.mode,
"x-ayanam-request-id": options.requestId,
...options.headers,
},
});
}
+78 -4
View File
@@ -24,6 +24,25 @@ export type ConsultationInput = z.infer<typeof consultationInputSchema>;
type JsonRecord = Record<string, unknown>;
const workflowConsumerContextSchema = z.object({
route: z.string().min(1),
core_status: z.enum(["ready", "degraded", "blocked"]),
available_layers: z.array(z.string()),
missing_route_layers: z.array(z.string()),
hard_blockers: z.array(z.string()),
answer_policy: z.object({
can_answer_direction: z.boolean(),
can_answer_precise_timing: z.boolean(),
}).passthrough(),
}).passthrough();
export const consultationWorkflowResponseSchema = z.object({
success: z.boolean(),
chart: z.record(z.unknown()),
routing: z.record(z.unknown()),
consumer_context: workflowConsumerContextSchema,
}).passthrough();
function record(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
@@ -52,7 +71,21 @@ export async function runConsultationWorkflow(input: ConsultationInput) {
if (!response.ok || !data) {
throw new Error(data?.error || data?.message || `Jyotish API returned ${response.status}`);
}
return data as JsonRecord;
const parsed = consultationWorkflowResponseSchema.safeParse(data);
if (!parsed.success) {
throw new Error("Jyotish API returned an incomplete consultation contract");
}
return parsed.data;
}
export function consultationWorkflowReceipt(data: JsonRecord) {
const consumerContext = workflowConsumerContextSchema.parse(data.consumer_context);
return {
route: consumerContext.route,
status: consumerContext.core_status,
preciseTiming: consumerContext.answer_policy.can_answer_precise_timing ? "allowed" : "blocked",
missingLayers: consumerContext.missing_route_layers.join(",") || "none",
};
}
export function toAgentConsultationContext(data: JsonRecord) {
@@ -64,12 +97,22 @@ export function toAgentConsultationContext(data: JsonRecord) {
const primaryTheme = String(routing.primary_theme || routing.question_type || "general");
const selectedTheme = record(themes[primaryTheme]);
const rectification = record(data.rectification);
const consumerContext = record(data.consumer_context);
return {
success: data.success === true,
question: data.question,
routing,
consumer_context: record(data.consumer_context),
consumer_context: consumerContext,
evidence_contract: {
route: consumerContext.route,
core_status: consumerContext.core_status,
available_layers: consumerContext.available_layers,
missing_route_layers: consumerContext.missing_route_layers,
hard_blockers: consumerContext.hard_blockers,
answer_policy: consumerContext.answer_policy,
user_facing_limitation: consumerContext.user_facing_limitation,
},
chart: {
birth: chart.birth,
ascendant: chart.ascendant,
@@ -80,18 +123,27 @@ export function toAgentConsultationContext(data: JsonRecord) {
yogas: chart.yogas,
},
local_layers: {
shadbala_boundary: "Shadbala is a locally consistent relative-strength layer; external component-level absolute parity remains partial and must not be stated as closed.",
varga_full: modules.varga_full,
arudha_padas: modules.arudha_padas,
ashtakavarga: modules.ashtakavarga,
dasha_boundaries: modules.dasha_boundaries,
narayana_dasha: modules.narayana_dasha,
functional_benefic_malefic: record(data.machine_evidence_packet).functional_benefic_malefic,
},
rectification: {
boundary: "not_auto_rectified",
summary: rectification.summary,
enabled_vargas: rectification.enabled_vargas,
lagna_boundary: rectification.lagna_boundary,
},
thematic_evidence: selectedTheme,
vedastro_gateway: record(data.vedastro_gateway),
external_engine_evidence: {
runtime_truth: record(data.runtime_truth),
numerical_parity: record(data.external_parity_gate),
real_case_calibration: record(data.real_case_calibration),
},
reference_transparency: record(data.reference_transparency),
};
}
@@ -123,6 +175,8 @@ When reference_transparency is present:
- If should_lead_with_limitations is false, do not lead with limitations. If a limitation is relevant, put it in one short sentence at the end.
- Only say the chart calculation failed when hard_blockers is non-empty.
- Never claim D9, D10, A10, UL, or Narayana Dasha is missing when it appears in available_layers 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 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:
<!--AYANAM_SUGGESTIONS:["问题一","问题二","问题三"]-->
@@ -135,7 +189,27 @@ Do not provide medical, legal, investment, or safety-critical instructions. Do n
const jyotishAgents = new Map<string, Agent>();
export function getJyotishAgent(model: ResolvedLanguageModel) {
function groundedJyotishInstructions(workflowContext: JsonRecord) {
return `${jyotishInstructions}
The server-computed Jyotish workflow below is the only source for this chart claim. Use it directly, preserve its truth boundaries, and do not run a second consultation workflow.
<server-computed-jyotish-workflow>
${JSON.stringify(toAgentConsultationContext(workflowContext))}
</server-computed-jyotish-workflow>`;
}
export function getJyotishAgent(model: ResolvedLanguageModel, workflowContext?: JsonRecord) {
if (workflowContext) {
return new Agent({
id: `jyotish-guide-${model.id}-grounded`,
name: "Jyotish Guide",
model: model.model,
instructions: groundedJyotishInstructions(workflowContext),
skills: [jyotishSkillPath],
tools: workflowContext ? {} : { consultationTool },
});
}
const cached = jyotishAgents.get(model.id);
if (cached) return cached;
const agent = new Agent({
@@ -144,7 +218,7 @@ export function getJyotishAgent(model: ResolvedLanguageModel) {
model: model.model,
instructions: jyotishInstructions,
skills: [jyotishSkillPath],
tools: { consultationTool },
tools: workflowContext ? {} : { consultationTool },
});
jyotishAgents.set(model.id, agent);
return agent;
@@ -17,3 +17,23 @@ test("passes transparent public-case references into the agent context", () => {
assert.match(source, /Jupiter and Saturn relative houses/);
assert.match(source, /exact_triggers as technical trigger points/);
});
test("keeps strength, Ashtakavarga, and timing evidence available to the answer model", () => {
const source = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
assert.match(source, /shadbala: chart\.shadbala/);
assert.match(source, /shadbala_boundary:/);
assert.match(source, /ashtakavarga: modules\.ashtakavarga/);
assert.match(source, /dasha_boundaries: modules\.dasha_boundaries/);
assert.match(source, /narayana_dasha: modules\.narayana_dasha/);
assert.match(source, /evidence_contract:/);
assert.match(source, /missing_route_layers: consumerContext\.missing_route_layers/);
assert.match(source, /answer_policy: consumerContext\.answer_policy/);
assert.match(source, /evidence_contract\.answer_policy/);
assert.match(source, /can_answer_precise_timing/);
assert.match(source, /boundary: "not_auto_rectified"/);
assert.match(source, /rectification\.boundary=not_auto_rectified/);
assert.match(source, /external_engine_evidence:/);
assert.match(source, /runtime_truth: record\(data\.runtime_truth\)/);
assert.match(source, /numerical_parity: record\(data\.external_parity_gate\)/);
assert.match(source, /real_case_calibration: record\(data\.real_case_calibration\)/);
});
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
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(["));
});
test("grounds the answer in the server-computed workflow without a second tool run", () => {
assert.match(mastra, /function getJyotishAgent\(model: ResolvedLanguageModel, workflowContext\?/);
assert.match(mastra, /workflowContext \? \{\} : \{ consultationTool \}/);
assert.match(mastra, /server-computed Jyotish workflow/);
});
test("validates and emits a non-sensitive workflow receipt", () => {
assert.match(mastra, /consultationWorkflowResponseSchema/);
assert.match(mastra, /safeParse\(data\)/);
assert.match(mastra, /consultationWorkflowReceipt/);
assert.match(route, /workflowReceipt/);
assert.match(route, /x-jyotish-workflow-route/);
assert.match(route, /x-jyotish-workflow-status/);
});
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const root = new URL("../../", import.meta.url);
const readRoot = (path: string) => readFileSync(new URL(path, root), "utf8");
const mastra = readRoot("frontend/src/mastra/index.ts");
const rectification = readRoot("frontend/src/lib/birth-time-journey-engine.ts");
const synastry = readRoot("frontend/src/app/api/synastry/route.ts");
const apiServer = readRoot("scripts/jyotish_api_server.py");
test("commercial Jyotish paths resolve to a registered Python handler", () => {
for (const path of [
"/api/consultation_workflow",
"/api/active_rectification_questions",
"/api/active_rectification_score",
"/api/active_rectification_events",
"/api/varga_full",
"/api/synastry",
]) {
assert.match(apiServer, new RegExp(`['\"]${path.replaceAll("/", "\\/")}['\"]`));
}
assert.match(mastra, /\/api\/consultation_workflow/);
assert.match(rectification, /\/api\/active_rectification_questions/);
assert.match(rectification, /\/api\/active_rectification_score/);
assert.match(rectification, /\/api\/active_rectification_events/);
assert.match(synastry, /\/api\/varga_full/);
assert.match(synastry, /\/api\/synastry/);
});
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
test("loads birth-time rectification only when its onboarding stage is reached", () => {
assert.match(page, /import dynamic from "next\/dynamic"/);
assert.match(page, /const BirthTimeRectification = dynamic\(/);
assert.match(page, /import\("@\/components\/birth-time-rectification"\)/);
assert.match(page, /ssr: false/);
assert.match(page, /role="status"/);
});