feat: enforce commercial Jyotish workflow contracts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
Reference in New Issue
Block a user