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;
|
||||
|
||||
Reference in New Issue
Block a user