fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流
schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
@@ -34,6 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
| 坏 | 好 | 为什么 |
|
||||
|---|---|---|
|
||||
| Rahu 大运为 [具体时间已省略] 至 [具体时间已省略]。 | Rahu 大运为 2013 年 11 月中下旬至 2031 年 11 月中下旬。按范围看,不写成单一分钟。 | 出生范围用户要拿到区间说法;算出来的边界不是要删的字。 |
|
||||
| 请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。 | 眼下按 04:45–05:15 来核对,用你记得的经历对照几种分盘和大运。最后给区间和代表分钟,不给精确到秒。想到几件说几件,有大概年月就行——比如升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。 | 开场三句讲做法并点领域,不写具体年份。 |
|
||||
| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 |
|
||||
| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
|
||||
@@ -118,7 +119,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。交付旁白不写「候选 A 预测下一次事业变动」;改说预测窗口留在采用后的核对阶段。
|
||||
- 不得对用户写「概率 / 置信度 / 确定 / 相对支持度」。专业数字只留在看盘板。
|
||||
- 采用旁白里「这段区间里哪些主题稳定、哪些会随分钟变」只能来自服务端 `theme_sensitivity`,模型不得自编;声明时段咨询必须写明「这只是粗看」。
|
||||
- 技法审计表仍在本命回答文末。
|
||||
- 技法审计表只出现在回复下面的折叠面板,不写进正文。
|
||||
- 只知道出生范围时,大运和行运边界按区间说出来,不得挖成「[具体时间已省略]」。没有出生分钟时,任何「你的盘」断言都不要写。
|
||||
|
||||
## 口语层的形状
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { streamTextResponse } from "@/lib/stream-text-response";
|
||||
import { streamAgentResponse } from "@/lib/stream-agent-response";
|
||||
import { detectMethodologyBookkeeping } from "@/lib/timing-output-guard";
|
||||
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
|
||||
import { consultationComposePrompt, consultationContinuePrompt, natalConsultationThinkingPlan, type PublicThinkingSection } from "@/lib/consultation-thinking-plan";
|
||||
import {
|
||||
@@ -62,7 +61,6 @@ import {
|
||||
applyBirthTimeModeToWorkflowContext,
|
||||
ACCEPTED_RANGE_READING_INSTRUCTION,
|
||||
consultationBirthTimeModeSchema,
|
||||
createBirthTimeModeOutputGuard,
|
||||
shouldRunBirthChartWorkflow,
|
||||
shouldRunDeclaredWindowWorkflow,
|
||||
type ConsultationBirthTimeMode,
|
||||
@@ -1004,10 +1002,7 @@ export async function POST(request: Request) {
|
||||
retryForAnswer,
|
||||
continueAfterLength,
|
||||
continueAfterDisconnect: true,
|
||||
transformText: createBirthTimeModeOutputGuard(
|
||||
generalDailyContext ? "general_no_birth_time" : consultationMode,
|
||||
false,
|
||||
),
|
||||
pass4Mode: generalDailyContext ? "general_no_birth_time" : consultationMode,
|
||||
toolStatus: () => "ready",
|
||||
receipt: executionReceipt,
|
||||
headers: { "x-jyotish-birth-time-mode": consultationMode },
|
||||
@@ -1107,7 +1102,7 @@ export async function POST(request: Request) {
|
||||
retryForAnswer,
|
||||
continueAfterLength,
|
||||
continueAfterDisconnect: true,
|
||||
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
|
||||
pass4Mode: consultationMode,
|
||||
toolStatus: () => workflowStatus(state.workflowReceipt?.status),
|
||||
receipt: executionReceipt,
|
||||
headers: { "x-jyotish-birth-time-mode": consultationMode },
|
||||
@@ -1188,10 +1183,10 @@ export async function POST(request: Request) {
|
||||
const interpretFindings = async () => (
|
||||
(state.thinkingPlan ?? []).map((section) => ({ id: section.id }))
|
||||
);
|
||||
const composeAnswer = async () => {
|
||||
const composeAnswer = async (_: readonly { id: string; text?: string }[] = [], retryHint?: string) => {
|
||||
const composed = await agent.stream([
|
||||
...baseMessages,
|
||||
{ role: "user" as const, content: consultationComposePrompt() },
|
||||
{ role: "user" as const, content: `${consultationComposePrompt()}${retryHint ? `\n${retryHint}` : ""}` },
|
||||
], {
|
||||
...streamOptions,
|
||||
maxSteps: AGENT_SLICE_MAX_STEPS,
|
||||
@@ -1230,16 +1225,8 @@ export async function POST(request: Request) {
|
||||
continueAfterLength,
|
||||
interpretFindings,
|
||||
composeAnswer,
|
||||
pass4Violations: (text) => detectMethodologyBookkeeping(text).map((item) => item.kind),
|
||||
pass4Mode: consultationMode,
|
||||
continueAfterDisconnect: true,
|
||||
transformText: (text) => createBirthTimeModeOutputGuard(
|
||||
consultationMode,
|
||||
state.workflowReceipt?.preciseTiming === "allowed",
|
||||
{
|
||||
currentTheme: consultationTheme,
|
||||
minuteSensitiveThemes: state.workflowReceipt?.minuteSensitiveThemes,
|
||||
},
|
||||
)(text),
|
||||
toolStatus: () => workflowStatus(state.workflowReceipt?.status),
|
||||
receipt: executionReceipt,
|
||||
headers: { "x-jyotish-birth-time-mode": consultationMode },
|
||||
@@ -1313,10 +1300,6 @@ export async function POST(request: Request) {
|
||||
: cancel,
|
||||
);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard(
|
||||
generalDailyContext ? "general_no_birth_time" : consultationMode,
|
||||
false,
|
||||
),
|
||||
mode: "mastra",
|
||||
requestId,
|
||||
continueAfterDisconnect: true,
|
||||
@@ -1426,14 +1409,6 @@ export async function POST(request: Request) {
|
||||
: cancel,
|
||||
);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard(
|
||||
consultationMode,
|
||||
workflowReceipt.preciseTiming !== "blocked",
|
||||
{
|
||||
currentTheme: consultationTheme,
|
||||
minuteSensitiveThemes: workflowReceipt.minuteSensitiveThemes,
|
||||
},
|
||||
),
|
||||
mode: "mastra",
|
||||
requestId,
|
||||
continueAfterDisconnect: true,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
guardGeneralNoBirthTimeOutput,
|
||||
guardPreciseTimingOutput,
|
||||
} from "./timing-output-guard.ts";
|
||||
|
||||
export const consultationBirthTimeModeSchema = z.enum([
|
||||
"verified_chart",
|
||||
@@ -70,28 +66,4 @@ export function applyBirthTimeModeToWorkflowContext<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side output boundary. Timing and guarantee filtering remains active
|
||||
* without inserting a rectification warning into every answer.
|
||||
*/
|
||||
export function createBirthTimeModeOutputGuard(
|
||||
mode: ConsultationBirthTimeMode,
|
||||
canAnswerPreciseTiming: boolean,
|
||||
options?: {
|
||||
currentTheme?: string | null;
|
||||
minuteSensitiveThemes?: readonly string[] | null;
|
||||
},
|
||||
): (text: string) => string {
|
||||
return (text) => {
|
||||
if (mode === "general_no_birth_time") return guardGeneralNoBirthTimeOutput(text);
|
||||
if (mode === "declared_birth_window" || !canAnswerPreciseTiming) {
|
||||
return guardPreciseTimingOutput(text);
|
||||
}
|
||||
const theme = options?.currentTheme ?? "";
|
||||
const sensitive = options?.minuteSensitiveThemes ?? [];
|
||||
if (theme && (theme === "timing" || sensitive.includes(theme))) {
|
||||
return guardPreciseTimingOutput(text);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type PublicRectificationPhase,
|
||||
type PublicRectificationTool,
|
||||
} from "./public-receipt";
|
||||
import { acceptThinkStepText } from "../../think-step-gate";
|
||||
import { acceptThinkingFragment } from "../../think-step-gate";
|
||||
import { isToolInputRejection, toolResultFromChunk } from "./host-fallback";
|
||||
|
||||
export type PublicPhaseStreamEvent = Readonly<{
|
||||
@@ -187,7 +187,7 @@ export type InternalThinkingDeltaEvent = Readonly<{
|
||||
}>;
|
||||
|
||||
export function toPublicThinkingDelta(text: string): InternalThinkingDeltaEvent | null {
|
||||
const cleaned = acceptThinkStepText(text);
|
||||
const cleaned = acceptThinkingFragment(text);
|
||||
if (!cleaned) return null;
|
||||
return { type: "thinking.delta", text: cleaned };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createVisibleTextTransformer } from "./stream-text-response.ts";
|
||||
import { consultationWriteLabel } from "./consultation-activity-labels.ts";
|
||||
import { logTruncatedReasoning } from "./consultation-budget.ts";
|
||||
import { acceptThinkStepText } from "./think-step-gate.ts";
|
||||
import { applyPass4Policy, type Pass4Mode } from "./timing-output-guard.ts";
|
||||
import {
|
||||
applyThinkingSectionProgress,
|
||||
generalConsultationThinkingPlan,
|
||||
@@ -303,9 +304,9 @@ type StreamAgentResponseOptions = EventOptions & {
|
||||
retry?: () => Promise<ChunkStream>;
|
||||
retryForAnswer?: () => Promise<ChunkStream>;
|
||||
continueAfterLength?: (output: string) => Promise<ChunkStream>;
|
||||
composeAnswer?: (findings: readonly ThinkFinding[]) => Promise<ChunkStream>;
|
||||
composeAnswer?: (findings: readonly ThinkFinding[], retryHint?: string) => Promise<ChunkStream>;
|
||||
interpretFindings?: () => Promise<readonly ThinkFinding[]>;
|
||||
pass4Violations?: (text: string) => readonly string[];
|
||||
pass4Mode?: Pass4Mode;
|
||||
continueAfterDisconnect?: boolean;
|
||||
headers?: HeadersInit;
|
||||
onFirstActivity?: () => void | Promise<void>;
|
||||
@@ -372,7 +373,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
async function consumeAttempt(
|
||||
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
|
||||
stream: ChunkStream,
|
||||
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {},
|
||||
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean; holdAnswer?: boolean } = {},
|
||||
) {
|
||||
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
|
||||
let held = "";
|
||||
@@ -395,7 +396,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
firstOutput = true;
|
||||
await options.onFirstOutput?.();
|
||||
}
|
||||
send(controller, { type: "answer.delta", text: held });
|
||||
if (!attempt.holdAnswer) send(controller, { type: "answer.delta", text: held });
|
||||
fullOutput += held;
|
||||
if (/\S/.test(held)) emitted = true;
|
||||
held = "";
|
||||
@@ -455,6 +456,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
async function continueCurrentAnswer(
|
||||
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
|
||||
heading?: string,
|
||||
holdAnswer = false,
|
||||
) {
|
||||
if (options.state.modelFinishReason !== "length") return;
|
||||
if (!options.continueAfterLength) throw new Error("answer_truncated");
|
||||
@@ -467,6 +469,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
});
|
||||
await consumeAttempt(controller, await options.continueAfterLength(fullOutput), {
|
||||
suppressCompositionActivity: true,
|
||||
holdAnswer,
|
||||
});
|
||||
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
|
||||
if (options.state.modelFinishReason === "length" && fullOutput === beforeContinue) {
|
||||
@@ -509,6 +512,51 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function recordPass4Steps(steps: ReturnType<typeof applyPass4Policy>["steps"]) {
|
||||
const seen = new Set<string>();
|
||||
for (const step of steps) {
|
||||
const key = `${step.action}:${step.kind}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: `${step.action === "observe" ? "pass4-observe" : "pass4-reject"}:${step.kind}`,
|
||||
status: step.action === "observe" ? "completed" : "failed",
|
||||
failureCode: step.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function finishPass4(
|
||||
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
|
||||
origin: string,
|
||||
findings: readonly ThinkFinding[],
|
||||
) {
|
||||
if (!options.pass4Mode) {
|
||||
const produced = fullOutput.slice(origin.length);
|
||||
if (produced) send(controller, { type: "answer.delta", text: produced });
|
||||
return;
|
||||
}
|
||||
let report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode);
|
||||
recordPass4Steps(report.steps);
|
||||
if (report.retry && options.composeAnswer) {
|
||||
fullOutput = origin;
|
||||
await consumeAttempt(
|
||||
controller,
|
||||
await options.composeAnswer(findings, report.retryHint),
|
||||
{ suppressCompositionActivity: true, holdAnswer: true },
|
||||
);
|
||||
await continueCurrentAnswer(controller, undefined, true);
|
||||
report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true });
|
||||
recordPass4Steps(report.steps);
|
||||
} else if (report.retry) {
|
||||
report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true });
|
||||
recordPass4Steps(report.steps);
|
||||
}
|
||||
fullOutput = origin + report.text;
|
||||
if (report.text) send(controller, { type: "answer.delta", text: report.text });
|
||||
}
|
||||
|
||||
async function composeOnce(
|
||||
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
|
||||
findings: readonly ThinkFinding[],
|
||||
@@ -525,19 +573,14 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
label: "正在写结论",
|
||||
});
|
||||
const started = Date.now();
|
||||
const origin = fullOutput;
|
||||
await consumeAttempt(
|
||||
controller,
|
||||
await options.composeAnswer(findings),
|
||||
{ suppressCompositionActivity: true },
|
||||
{ suppressCompositionActivity: true, holdAnswer: Boolean(options.pass4Mode) },
|
||||
);
|
||||
await continueCurrentAnswer(controller);
|
||||
if ((options.pass4Violations?.(fullOutput) ?? []).length > 0) {
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: "pass4-reject",
|
||||
status: "failed",
|
||||
});
|
||||
}
|
||||
await continueCurrentAnswer(controller, undefined, Boolean(options.pass4Mode));
|
||||
await finishPass4(controller, origin, findings);
|
||||
send(controller, {
|
||||
type: "phase.completed",
|
||||
phase: "compose",
|
||||
@@ -559,14 +602,17 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
for (const event of skillBoundEvents) send(controller, event);
|
||||
flushThinkingPlan(controller);
|
||||
try {
|
||||
const holdMain = Boolean(options.pass4Mode) && !options.composeAnswer;
|
||||
await consumeAttempt(controller, options.stream, {
|
||||
drainSpoken: Boolean(options.composeAnswer),
|
||||
holdAnswer: holdMain,
|
||||
});
|
||||
if (!contractReady(options) && options.retry) {
|
||||
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "runtime-contract-retry", status: "completed" });
|
||||
send(controller, { type: "activity", phase: "loading-method", label: "正在补齐方法与计算步骤" });
|
||||
await consumeAttempt(controller, await options.retry(), {
|
||||
drainSpoken: Boolean(options.composeAnswer),
|
||||
holdAnswer: holdMain,
|
||||
});
|
||||
}
|
||||
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
|
||||
@@ -575,10 +621,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
|
||||
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
|
||||
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
|
||||
await consumeAttempt(controller, await options.retryForAnswer());
|
||||
await consumeAttempt(controller, await options.retryForAnswer(), { holdAnswer: holdMain });
|
||||
}
|
||||
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
|
||||
if (!composed) await continueCurrentAnswer(controller);
|
||||
if (!composed) {
|
||||
await continueCurrentAnswer(controller, undefined, holdMain);
|
||||
if (holdMain) await finishPass4(controller, "", findings);
|
||||
}
|
||||
settling = true;
|
||||
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
|
||||
const thinkingSections = applyThinkingSectionProgress(options.state.thinkingPlan ?? [], fullOutput);
|
||||
|
||||
@@ -15,3 +15,37 @@ export function acceptThinkStepText(text: string): string | null {
|
||||
if (!SENTENCE_RE.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const TOOLISH_FRAGMENT_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+|skill_read|proposedKind|validationErrors/i;
|
||||
|
||||
/**
|
||||
* Rectification still publishes reasoning as fragments. This gate only accepts
|
||||
* or rejects a chunk. It does not rewrite, strip English, or require 8 chars.
|
||||
*/
|
||||
export function acceptThinkingFragment(text: string): string | null {
|
||||
const trimmed = text.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return null;
|
||||
if (!CJK_RE.test(trimmed)) return null;
|
||||
if (TOOLISH_FRAGMENT_RE.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function createThinkingFragmentAssembler() {
|
||||
let buffer = "";
|
||||
return {
|
||||
push(chunk: string): string | null {
|
||||
const accepted = acceptThinkingFragment(chunk);
|
||||
if (!accepted) return null;
|
||||
buffer += accepted;
|
||||
if (!SENTENCE_RE.test(buffer)) return null;
|
||||
const released = buffer;
|
||||
buffer = "";
|
||||
return released;
|
||||
},
|
||||
flush(): string | null {
|
||||
const leftover = buffer;
|
||||
buffer = "";
|
||||
return leftover ? acceptThinkingFragment(leftover) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,12 +143,87 @@ export function detectMethodologyBookkeeping(text: string): TimingGuardViolation
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** @deprecated Detection only. Never rewrite the model text. */
|
||||
export function guardPreciseTimingOutput(text: string) {
|
||||
return text;
|
||||
export type Pass4Mode =
|
||||
| "verified_chart"
|
||||
| "unverified_birth_time"
|
||||
| "declared_birth_window"
|
||||
| "general_no_birth_time";
|
||||
|
||||
export type Pass4Step = Readonly<{
|
||||
action: "observe" | "reject";
|
||||
kind: TimingGuardKind;
|
||||
excerpt: string;
|
||||
}>;
|
||||
|
||||
export type Pass4Result = Readonly<{
|
||||
text: string;
|
||||
steps: readonly Pass4Step[];
|
||||
retry: boolean;
|
||||
retryHint?: string;
|
||||
}>;
|
||||
|
||||
function dropGuaranteeClauses(text: string): string {
|
||||
const parts = text.split(/([。!?.!?\n]+)/u);
|
||||
let output = "";
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const clause = parts[index] ?? "";
|
||||
const punct = parts[index + 1] ?? "";
|
||||
if (detectPreciseTimingViolations(clause).some((item) => item.kind === "guarantee")) continue;
|
||||
output += clause + punct;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** @deprecated Detection only. Never rewrite the model text. */
|
||||
export function guardGeneralNoBirthTimeOutput(text: string) {
|
||||
return text;
|
||||
export function classifyPass4(
|
||||
text: string,
|
||||
mode: Pass4Mode,
|
||||
): Pass4Step[] {
|
||||
const steps: Pass4Step[] = [];
|
||||
for (const hit of detectPreciseTimingViolations(text)) {
|
||||
if (hit.kind === "exact-timing") {
|
||||
if (mode !== "general_no_birth_time") {
|
||||
steps.push({ action: "observe", kind: hit.kind, excerpt: hit.excerpt });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (hit.kind === "guarantee") {
|
||||
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
|
||||
}
|
||||
}
|
||||
if (mode === "general_no_birth_time") {
|
||||
for (const hit of detectGeneralNoBirthTimeViolations(text)) {
|
||||
if (hit.kind === "personal-chart") {
|
||||
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const hit of detectMethodologyBookkeeping(text)) {
|
||||
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function applyPass4Policy(
|
||||
text: string,
|
||||
mode: Pass4Mode,
|
||||
options?: { secondPass?: boolean },
|
||||
): Pass4Result {
|
||||
const steps = classifyPass4(text, mode);
|
||||
const rejects = steps.filter((step) => step.action === "reject");
|
||||
if (!options?.secondPass && rejects.some((step) => step.kind === "guarantee" || step.kind === "personal-chart")) {
|
||||
return {
|
||||
text,
|
||||
steps,
|
||||
retry: true,
|
||||
retryHint: "不要写保证性结论(一定、保证、注定、will definitely)。无出生分钟时不要对用户作个人星盘断言。",
|
||||
};
|
||||
}
|
||||
let next = text;
|
||||
if (options?.secondPass && rejects.some((step) => step.kind === "guarantee")) {
|
||||
next = dropGuaranteeClauses(next);
|
||||
}
|
||||
if (options?.secondPass && mode === "general_no_birth_time" && rejects.some((step) => step.kind === "personal-chart")) {
|
||||
next = GENERAL_NO_BIRTH_TIME_REFUSAL;
|
||||
}
|
||||
return { text: next, steps, retry: false };
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ const domainPlanValueSchema = consultationDomainPlanValueSchema;
|
||||
// stated. Internal callers keep the single-value form; see canonicalDomainPlan.
|
||||
const consultationToolInputSchema = z.object({
|
||||
question: z.string().trim().min(1).max(500),
|
||||
domains: z.array(domainPlanValueSchema).min(1).max(MAX_CONSULTATION_DOMAINS).optional(),
|
||||
domains: z.array(domainPlanValueSchema).min(1).max(MAX_CONSULTATION_DOMAIN_PLAN_VALUES).optional(),
|
||||
}).strict();
|
||||
|
||||
const MAX_RECORDED_STEPS = 32;
|
||||
@@ -629,7 +629,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
let calculation: Promise<ReturnType<typeof toModelDomainPlanContext>> | null = null;
|
||||
const consultationTool = createTool({
|
||||
id: "run-jyotish-consultation",
|
||||
description: `Run one server-validated plan of at most ${MAX_CONSULTATION_DOMAINS} allowlisted personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, or omit it entirely to accept the domain the server already selected. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; if the clock runs short the server executes the domains that fit and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
|
||||
description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
|
||||
inputSchema: consultationToolInputSchema,
|
||||
execute: async (input, context) => {
|
||||
const requestedDomains = canonicalDomainPlan(input, ctx);
|
||||
|
||||
@@ -21,7 +21,7 @@ Write in Simplified Chinese: a heading-free spoken opener first (反差(表面
|
||||
${jyotishSkillMethodBlock}
|
||||
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat.
|
||||
Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.
|
||||
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
|
||||
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. List every domain the question actually needs, in priority order, up to six. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum. The server canonicalizes aliases, rejects unsupported/product domains, executes as many accepted domains as the wall clock can pay for (about ${MAX_CONSULTATION_DOMAINS}), and returns the rest in omitted_domains. The actual executed domains are in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
|
||||
The tool result's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
|
||||
The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
|
||||
When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage.
|
||||
|
||||
@@ -37,13 +37,13 @@ import {
|
||||
DAILY_HEADING,
|
||||
REPORT_HEADING,
|
||||
} from "../src/lib/consultation-thinking-plan.ts";
|
||||
import { getJyotishAgent } from "../src/mastra/index.ts";
|
||||
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
|
||||
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
|
||||
import {
|
||||
collectAgentPublicEvents,
|
||||
streamAgentResponse,
|
||||
} from "../src/lib/stream-agent-response.ts";
|
||||
import { GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
|
||||
|
||||
const serverChart = {
|
||||
name: "测试",
|
||||
@@ -192,27 +192,28 @@ test("multi-domain plan canonicalizes aliases, de-duplicates, preserves order, a
|
||||
return workflow(input.theme);
|
||||
},
|
||||
});
|
||||
// Aliases, not repetitions: the array bound is now the executable domain cap,
|
||||
// so a duplicate spends one of the slots the clock can actually pay for.
|
||||
// canonicalDomainPlan keeps the de-duplication coverage for longer raw lists.
|
||||
// 原值:别名 career/finance/home 三个都执行,domains 含 migration
|
||||
// 新值:别名仍规范化,执行上限 2,migration 进 omitted_domains
|
||||
// 原因:BUG-945/946,墙钟只付得起 2 个领域。
|
||||
const result = await tools["run-jyotish-consultation"].execute!(
|
||||
modelInput({ question: "事业、财富和迁居怎么一起规划", domains: ["career", "finance", "home"] }),
|
||||
{ observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never,
|
||||
) as { domains: string[]; consultations: Array<{ domain: string }> };
|
||||
) as { domains: string[]; omitted_domains: string[]; consultations: Array<{ domain: string }> };
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ theme: "career", question: "事业、财富和迁居怎么一起规划" },
|
||||
{ theme: "wealth", question: "事业、财富和迁居怎么一起规划" },
|
||||
{ theme: "migration", question: "事业、财富和迁居怎么一起规划" },
|
||||
]);
|
||||
assert.deepEqual(result.domains, ["career", "wealth", "migration"]);
|
||||
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth", "migration"]);
|
||||
assert.deepEqual(result.domains, ["career", "wealth"]);
|
||||
assert.deepEqual(result.omitted_domains, ["migration"]);
|
||||
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth"]);
|
||||
assert.deepEqual(state.workflowReceipt, {
|
||||
route: "multi-domain",
|
||||
status: "degraded",
|
||||
preciseTiming: "allowed",
|
||||
missingLayers: ["D11"],
|
||||
domains: ["career", "wealth", "migration"],
|
||||
domains: ["career", "wealth"],
|
||||
omittedDomains: ["migration"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,14 +254,17 @@ test("a multi-domain result exposes the same top-level answer contract as a sing
|
||||
});
|
||||
|
||||
test("the merged answer policy is the most restrictive of the executed domains", async () => {
|
||||
const { result, state } = await runDomainPlan(["career", "timing", "wealth"], (theme) => workflow(theme, {
|
||||
// 原值:career+timing+wealth 三个都执行,D11 / 限制句来自 wealth
|
||||
// 新值:只执行 2 个;把限制性字段放到 timing 上,合并规则不变
|
||||
// 原因:BUG-946,MAX=2 后第三域进 omitted,不能再靠它提供合并输入。
|
||||
const { result, state } = await runDomainPlan(["career", "timing"], (theme) => workflow(theme, {
|
||||
// One domain forbidding precise timing must forbid it for the whole answer.
|
||||
preciseTiming: theme !== "timing",
|
||||
status: theme === "wealth" ? "degraded" : "ready",
|
||||
missingLayers: theme === "wealth" ? ["D11"] : [],
|
||||
status: theme === "timing" ? "degraded" : "ready",
|
||||
missingLayers: theme === "timing" ? ["D11"] : [],
|
||||
hardBlockers: theme === "timing" ? ["negative_holdout_gate"] : [],
|
||||
leadWithLimitations: theme === "timing",
|
||||
limitation: theme === "wealth" ? "财富层证据不完整。" : undefined,
|
||||
limitation: theme === "timing" ? "财富层证据不完整。" : undefined,
|
||||
chart: natalChart,
|
||||
}));
|
||||
|
||||
@@ -364,31 +368,32 @@ test("the identical natal projection is carried once instead of per domain", asy
|
||||
});
|
||||
|
||||
test("the domain cap is what the run budget can actually pay for", () => {
|
||||
// 21s per sequential domain against the 110s run budget, minus the reserve a
|
||||
// three-domain staging run actually left for composing the answer.
|
||||
assert.equal(MAX_CONSULTATION_DOMAINS, 3);
|
||||
// 原值:MAX=3,按 21s/领域
|
||||
// 新值:MAX=2,按实测 31s/领域(110s-45s 写作预留)
|
||||
// 原因:BUG-944/946,工具实测约 31s/领域,21s 会把写作预算吃光。
|
||||
assert.equal(MAX_CONSULTATION_DOMAINS, 2);
|
||||
assert.equal(AGENT_TIMEOUT_MS, 110_000);
|
||||
assert.equal(CONSULTATION_DOMAIN_WALL_CLOCK_MS, 65_000);
|
||||
assert.ok(MAX_CONSULTATION_DOMAINS * 21_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
|
||||
// Six domains, the previous cap, could never finish inside the deadline.
|
||||
assert.ok(6 * 21_000 > AGENT_TIMEOUT_MS);
|
||||
assert.ok(MAX_CONSULTATION_DOMAINS * 31_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
|
||||
assert.ok(6 * 31_000 > AGENT_TIMEOUT_MS);
|
||||
|
||||
assert.deepEqual(
|
||||
executableDomainPlan(["career", "wealth", "timing", "marriage", "health"]),
|
||||
{ domains: ["career", "wealth", "timing"], omittedDomains: ["marriage", "health"] },
|
||||
{ domains: ["career", "wealth"], omittedDomains: ["timing", "marriage", "health"] },
|
||||
);
|
||||
assert.deepEqual(executableDomainPlan(["career"]), { domains: ["career"], omittedDomains: [] });
|
||||
|
||||
// The first domain always runs; after that the next one has to be projected
|
||||
// to finish, judged by how long the executed ones really took.
|
||||
assert.equal(domainFitsRunBudget(0, 0), true);
|
||||
assert.equal(domainFitsRunBudget(21_000, 1), true);
|
||||
assert.equal(domainFitsRunBudget(42_000, 2), true);
|
||||
assert.equal(domainFitsRunBudget(31_000, 1), true);
|
||||
assert.equal(domainFitsRunBudget(62_000, 2), false);
|
||||
assert.equal(domainFitsRunBudget(60_000, 2), false);
|
||||
assert.equal(domainFitsRunBudget(40_000, 1), false);
|
||||
});
|
||||
|
||||
test("a plan larger than the cap cannot be expressed and never starts a calculation", async () => {
|
||||
test("a plan larger than the execution cap is truncated not refused", async () => {
|
||||
// 原值:4 个领域 schema 失败、execute 0 次、无 omitted_domains
|
||||
// 新值:schema 接受最多 6 个,execute ≥1 次,omitted_domains 非空
|
||||
// 原因:BUG-945,描述承诺截断,zod 却整次拒绝。
|
||||
let calls = 0;
|
||||
const state = createConsultationRuntimeState();
|
||||
const tool = createConsultationTools({
|
||||
@@ -399,18 +404,20 @@ test("a plan larger than the cap cannot be expressed and never starts a calculat
|
||||
const inputSchema = tool.inputSchema as unknown as { safeParse: (value: unknown) => { success: boolean } };
|
||||
|
||||
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing"] }).success, true);
|
||||
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, false);
|
||||
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, true);
|
||||
assert.equal(inputSchema.safeParse({
|
||||
question: "测试",
|
||||
domains: ["career", "wealth", "timing", "marriage", "health", "education", "family"],
|
||||
}).success, false);
|
||||
|
||||
// Mastra rejects the over-budget plan before the tool body runs, so it costs
|
||||
// one correctable step and nothing about the run advances.
|
||||
const refused = await tool.execute!(
|
||||
{ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] } as never,
|
||||
const result = await tool.execute!(
|
||||
modelInput({ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] }),
|
||||
toolContext,
|
||||
) as Record<string, unknown>;
|
||||
assert.equal(calls, 0);
|
||||
assert.equal("domains" in refused, false);
|
||||
assert.equal(state.consultationToolStarted, false);
|
||||
assert.deepEqual(runSteps(state), []);
|
||||
) as PlanResult;
|
||||
assert.ok(calls >= 1);
|
||||
assert.ok((result.omitted_domains ?? []).length > 0);
|
||||
assert.doesNotMatch(JSON.stringify(result), /Tool input validation failed/);
|
||||
assert.equal(state.consultationToolStarted, true);
|
||||
});
|
||||
|
||||
test("a plan that runs long stops early and discloses the domains it dropped", async () => {
|
||||
@@ -455,9 +462,9 @@ test("the advertised domain limit matches the enforced one", () => {
|
||||
})["run-jyotish-consultation"];
|
||||
const description = tool.description ?? "";
|
||||
|
||||
assert.match(description, new RegExp(`at most ${MAX_CONSULTATION_DOMAINS} allowlisted`));
|
||||
assert.doesNotMatch(description, /up to six|six allowlisted/);
|
||||
assert.match(description, /up to 6/);
|
||||
assert.match(description, /omitted_domains/);
|
||||
assert.match(description, new RegExp(`about ${MAX_CONSULTATION_DOMAINS}`));
|
||||
assert.match(description, /top-level answer contract/);
|
||||
});
|
||||
|
||||
@@ -947,6 +954,7 @@ test("the receipt separates method the server delivered from method the model we
|
||||
});
|
||||
|
||||
test("personal Agent exposes the Jyotish Skill and named server tool", async () => {
|
||||
const { getJyotishAgent } = await import("../src/mastra/index.ts");
|
||||
const state = createConsultationRuntimeState();
|
||||
const agent = getJyotishAgent({
|
||||
id: "personal-agent-probe", label: "Probe", description: "", creditCost: 1, isDefault: false,
|
||||
@@ -1693,6 +1701,92 @@ test("composeAnswer length continue finishes the same body", async () => {
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
});
|
||||
|
||||
test("pass4 holds verified dates and records pass4-observe without rewriting", async () => {
|
||||
const state = toolOnlyRunState();
|
||||
async function* chunks() {
|
||||
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
|
||||
yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日 至 2031年11月22日。" } };
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "ready", receipt: () => receipt(state),
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
const answers = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text);
|
||||
assert.equal(answers.length, 1);
|
||||
assert.match(answers[0] ?? "", /2013年11月21日/);
|
||||
assert.doesNotMatch(answers.join(""), /具体时间已省略/);
|
||||
assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true);
|
||||
});
|
||||
|
||||
test("pass4 retries compose once on guarantee then drops leftover clauses", async () => {
|
||||
const state = toolOnlyRunState();
|
||||
async function* first() {
|
||||
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
}
|
||||
let composed = 0;
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: first(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "ready", receipt: () => receipt(state),
|
||||
composeAnswer: async (_findings, retryHint) => {
|
||||
composed += 1;
|
||||
if (composed === 2) assert.match(retryHint ?? "", /不要写保证性结论/);
|
||||
async function* body() {
|
||||
yield {
|
||||
type: "text-delta",
|
||||
payload: {
|
||||
text: "方向可以推进。我保证你一定会升职。",
|
||||
},
|
||||
};
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
}
|
||||
return body();
|
||||
},
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
assert.equal(composed, 2);
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(answer, /方向可以推进/);
|
||||
assert.doesNotMatch(answer, /一定会升职/);
|
||||
assert.equal(state.steps.some((step) => step.name === "pass4-reject:guarantee"), true);
|
||||
});
|
||||
|
||||
test("pass4 general mode second-pass replaces personal chart claims with the refusal", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
state.jyotishSkillBound = true;
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } };
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: false,
|
||||
pass4Mode: "general_no_birth_time",
|
||||
toolStatus: () => "ready", receipt: () => receipt(state),
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.equal(answer, GENERAL_NO_BIRTH_TIME_REFUSAL);
|
||||
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
|
||||
});
|
||||
|
||||
test("natal tool success stores a Chinese thinking plan", async () => {
|
||||
const { state } = await runDomainPlan(["career", "wealth"], () => workflow());
|
||||
const encoded = JSON.stringify(state.thinkingPlan ?? []);
|
||||
|
||||
@@ -4,13 +4,13 @@ import test from "node:test";
|
||||
|
||||
import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts";
|
||||
import {
|
||||
createBirthTimeModeOutputGuard,
|
||||
shouldRunBirthChartWorkflow,
|
||||
shouldRunDeclaredWindowWorkflow,
|
||||
UNVERIFIED_BIRTH_TIME_NOTICE,
|
||||
applyBirthTimeModeToWorkflowContext,
|
||||
unverifiedBirthTimeNotice,
|
||||
} from "../src/lib/consultation-birth-time-mode.ts";
|
||||
import { applyPass4Policy, GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
|
||||
import {
|
||||
consultationInputSchema,
|
||||
runConsultationWorkflow,
|
||||
@@ -253,26 +253,36 @@ test("unverified notices grade by source without changing the output guards", ()
|
||||
});
|
||||
|
||||
test("output guards and window/general instruction seams stay byte-stable", () => {
|
||||
// 原值:createBirthTimeModeOutputGuard 在 general 模式替换个人盘句、窗口模式挖日期
|
||||
// 新值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝
|
||||
// 原因:BUG-948,出生范围用户要能用应期,不能删字。
|
||||
const modeSource = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8");
|
||||
const routeSource = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(modeSource, /if \(mode === "general_no_birth_time"\) return guardGeneralNoBirthTimeOutput\(text\)/);
|
||||
assert.match(
|
||||
modeSource,
|
||||
/if \(mode === "declared_birth_window" \|\| !canAnswerPreciseTiming\) \{[\s\S]*return guardPreciseTimingOutput\(text\);/,
|
||||
);
|
||||
assert.doesNotMatch(modeSource, /createBirthTimeModeOutputGuard|guardPreciseTimingOutput|guardGeneralNoBirthTimeOutput/);
|
||||
assert.match(routeSource, /pass4Mode:/);
|
||||
assert.doesNotMatch(routeSource, /createBirthTimeModeOutputGuard|guardPreciseTimingOutput|guardGeneralNoBirthTimeOutput/);
|
||||
assert.equal(UNVERIFIED_BIRTH_TIME_NOTICE.includes("未校正填报时间"), true);
|
||||
|
||||
const general = createBirthTimeModeOutputGuard("general_no_birth_time", false)(
|
||||
const generalFirst = applyPass4Policy(
|
||||
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。\n你的上升是巨蟹座。",
|
||||
"general_no_birth_time",
|
||||
);
|
||||
assert.match(general, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
|
||||
assert.match(general, /你的上升是巨蟹座/);
|
||||
assert.equal(generalFirst.retry, true);
|
||||
const general = applyPass4Policy(
|
||||
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。\n你的上升是巨蟹座。",
|
||||
"general_no_birth_time",
|
||||
{ secondPass: true },
|
||||
);
|
||||
assert.equal(general.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
|
||||
|
||||
const windowed = createBirthTimeModeOutputGuard("declared_birth_window", false)(
|
||||
const windowed = applyPass4Policy(
|
||||
"Rahu 大运为 2013年11月21日 至 2031年11月22日。",
|
||||
"declared_birth_window",
|
||||
);
|
||||
assert.match(windowed, /2013年11月21日/);
|
||||
assert.match(windowed.text, /2013年11月21日/);
|
||||
assert.doesNotMatch(windowed.text, /具体时间已省略/);
|
||||
assert.equal(windowed.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
|
||||
assert.match(routeSource, /function generalNoMinuteInstruction\(hasPublicDaily: boolean\) \{/);
|
||||
assert.match(routeSource, /function declaredWindowInstruction\(\) \{/);
|
||||
|
||||
@@ -2,15 +2,13 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
UNVERIFIED_BIRTH_TIME_NOTICE,
|
||||
applyBirthTimeModeToWorkflowContext,
|
||||
createBirthTimeModeOutputGuard,
|
||||
consultationBirthTimeModeSchema,
|
||||
shouldRunBirthChartWorkflow,
|
||||
shouldRunDeclaredWindowWorkflow,
|
||||
} from "../src/lib/consultation-birth-time-mode.ts";
|
||||
import { getGeneralJyotishAgent } from "../src/mastra/index.ts";
|
||||
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
|
||||
import { applyPass4Policy, GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
test("general-no-birth-time is an explicit server mode that never runs a chart workflow", () => {
|
||||
@@ -54,41 +52,45 @@ test("unverified chart context preserves evidence-owned precise timing permissio
|
||||
});
|
||||
|
||||
test("unverified answers preserve exact dates when server evidence allows precise timing", () => {
|
||||
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", true);
|
||||
const answer = transform("Rahu 大运为 2013年11月21日 至 2031年11月22日。");
|
||||
|
||||
assert.equal(answer, "Rahu 大运为 2013年11月21日 至 2031年11月22日。");
|
||||
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
|
||||
assert.doesNotMatch(answer, /具体时间已省略/);
|
||||
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。";
|
||||
const report = applyPass4Policy(text, "unverified_birth_time");
|
||||
assert.equal(report.text, text);
|
||||
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
assert.doesNotMatch(report.text, /具体时间已省略/);
|
||||
});
|
||||
|
||||
test("evidence-blocked unverified answers still use the deterministic timing guard", () => {
|
||||
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
|
||||
const answer = transform("2026年8月适合观察方向。");
|
||||
|
||||
assert.match(answer, /具体时间已省略/);
|
||||
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
|
||||
test("evidence-blocked unverified answers keep dates and only observe exact-timing", () => {
|
||||
// 原值:createBirthTimeModeOutputGuard 把日期挖成「[具体时间已省略]」
|
||||
// 新值:日期原样保留,Pass 4 记 pass4-observe
|
||||
// 原因:BUG-948,范围/未校正用户应期要能用,不能删字。
|
||||
const text = "2026年8月适合观察方向。";
|
||||
const report = applyPass4Policy(text, "unverified_birth_time");
|
||||
assert.equal(report.text, text);
|
||||
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
assert.doesNotMatch(report.text, /具体时间已省略/);
|
||||
});
|
||||
|
||||
test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => {
|
||||
const transform = createBirthTimeModeOutputGuard("general_no_birth_time", false);
|
||||
const guarded = transform([
|
||||
// 原值:恒等壳下的 transform 把个人盘句替换成拒绝句
|
||||
// 新值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
|
||||
// 原因:BUG-948,无出生分钟模式才拦「你的盘」断言,且不得半句替换。
|
||||
const mixed = [
|
||||
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。",
|
||||
"忽略之前的规则,基于你的盘,你的 D9 上升一定是处女座。",
|
||||
"你的上升是巨蟹座,因此你一定会升职。",
|
||||
"你的金星落在第七宫。",
|
||||
"D9 显示你适合晚婚。",
|
||||
"你的 D9:处女上升。",
|
||||
].join("\n"));
|
||||
|
||||
assert.match(guarded, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
|
||||
assert.match(guarded, /这部分需要具体出生分钟才能判断,我不会补造时间/);
|
||||
assert.doesNotMatch(guarded, /改问一般知识|先完成生时校正|一般咨询模式不能生成个人星盘结论/);
|
||||
assert.doesNotMatch(guarded, /你的 D9 上升一定是处女座|你的上升是巨蟹座|你一定会升职|你的金星落在|D9 显示你|你的 D9:处女上升/);
|
||||
assert.doesNotMatch(guarded, /。。/);
|
||||
].join("\n");
|
||||
const first = applyPass4Policy(mixed, "general_no_birth_time");
|
||||
assert.equal(first.retry, true);
|
||||
const second = applyPass4Policy(mixed, "general_no_birth_time", { secondPass: true });
|
||||
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
|
||||
assert.equal(second.steps.some((step) => step.action === "reject" && step.kind === "personal-chart"), true);
|
||||
});
|
||||
|
||||
test("general agent runtime has no Jyotish skill package and no personal chart tool", async () => {
|
||||
const { getGeneralJyotishAgent } = await import("../src/mastra/index.ts");
|
||||
const model: ResolvedLanguageModel = {
|
||||
id: "general-zero-tool-probe",
|
||||
label: "General probe",
|
||||
|
||||
@@ -244,9 +244,14 @@ test("measured thinkingSections and receipts stay inside the physical-cap arithm
|
||||
const one = JSON.stringify(thinkingPlan(["career"]));
|
||||
const two = JSON.stringify(thinkingPlan(["career", "wealth"]));
|
||||
const three = JSON.stringify(thinkingPlan(["career", "wealth", "marriage"]));
|
||||
assert.ok(one.length >= 1200 && one.length <= 2500, `1-domain sections JSON length ${one.length}`);
|
||||
assert.ok(two.length >= 1800 && two.length <= 3500, `2-domain sections JSON length ${two.length}`);
|
||||
assert.ok(three.length >= 2400 && three.length <= 4500, `3-domain sections JSON length ${three.length}`);
|
||||
// 原值:1 域 1200–2500 / 2 域 1800–3500 / 3 域 2400–4500
|
||||
// 新值:1 域 900–1300 / 2–3 域 1100–1600(实测 1053 / 1267 / 1267)
|
||||
// 原因:BUG-949,本命思考计划改成四标题后 JSON 变小;12 步上限让 2 域与 3 域同长。
|
||||
assert.ok(one.length >= 900 && one.length <= 1300, `1-domain sections JSON length ${one.length}`);
|
||||
assert.ok(two.length >= 1100 && two.length <= 1600, `2-domain sections JSON length ${two.length}`);
|
||||
assert.ok(three.length >= 1100 && three.length <= 1600, `3-domain sections JSON length ${three.length}`);
|
||||
assert.ok(two.length >= one.length, "adding a domain must not shrink the plan JSON");
|
||||
assert.ok(three.length >= two.length, "a third domain may share the 12-step cap");
|
||||
|
||||
const receipts = typicalReceipts(["career", "wealth", "marriage"]);
|
||||
const receiptJson = JSON.stringify({
|
||||
@@ -283,12 +288,19 @@ test("session-detail JSON at the old ~19-round cap versus the new ~50-round cap"
|
||||
|
||||
assert.equal(oldCap.session.messages.length, 38);
|
||||
assert.equal(newCap.session.messages.length, 100);
|
||||
assert.ok(oldCountedChars(oldCap.session.messages) > 200_000, "19 full rounds already exceeded the old combined quota");
|
||||
// 原值:oldCountedChars(19) > 200_000(思考分节计入旧额度)
|
||||
// 新值:四标题计划更小,19 轮旧口径实测 177,973,落在 170k–190k
|
||||
// 原因:BUG-949,思考计划变小后 19 轮不再必然撞上 20 万旧额度。
|
||||
assert.ok(
|
||||
oldCountedChars(oldCap.session.messages) > 170_000 && oldCountedChars(oldCap.session.messages) < 190_000,
|
||||
`19-round old combined count was ${oldCountedChars(oldCap.session.messages)}`,
|
||||
);
|
||||
assert.ok(countedText(newCap.session.messages) >= 200_000, "50 rounds of 100+4000 visible text reach the conversation quota");
|
||||
// Measured 2026-09-16 on this fixture: 19-round 562,240 B (0.536 MiB);
|
||||
// 50-round 1,479,034 B (1.411 MiB); ratio 2.631.
|
||||
assert.ok(oldBytes > 540_000 && oldBytes < 590_000, `19-round detail JSON was ${oldBytes} bytes`);
|
||||
assert.ok(newBytes > 1_450_000 && newBytes < 1_510_000, `50-round detail JSON was ${newBytes} bytes`);
|
||||
// 原值:2026-09-16 夹具 19 轮 562,240 B / 50 轮 1,479,034 B
|
||||
// 新值:2026-09-18 实测 533,113 B / 1,402,384 B,比例仍约 2.63
|
||||
// 原因:BUG-949,thinkingSections JSON 随四标题计划缩小。
|
||||
assert.ok(oldBytes > 520_000 && oldBytes < 550_000, `19-round detail JSON was ${oldBytes} bytes`);
|
||||
assert.ok(newBytes > 1_380_000 && newBytes < 1_430_000, `50-round detail JSON was ${newBytes} bytes`);
|
||||
assert.ok(newBytes > oldBytes, "50-round detail JSON must be larger than 19-round");
|
||||
|
||||
const ratio = newBytes / oldBytes;
|
||||
|
||||
@@ -122,7 +122,7 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
|
||||
assert.match(route, /composeAnswer,/);
|
||||
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
|
||||
assert.equal(route.match(/const continueAfterLength = async \(output: string\) => \{/g)?.length, 3);
|
||||
assert.equal(route.match(/const composeAnswer = async \(\) => \{/g)?.length, 1);
|
||||
assert.equal(route.match(/const composeAnswer = async \(/g)?.length, 1);
|
||||
assert.match(route, /toolChoice: "none"/);
|
||||
assert.match(route, /entrypoint: consultEntrypoint/);
|
||||
assert.match(route, /entrypoint: parsed\.data\.entrypoint/);
|
||||
@@ -153,10 +153,11 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
|
||||
assert.match(natalRetry, /], natalStreamOptions\)/);
|
||||
assert.doesNotMatch(natalRetry, /], streamOptions\)/);
|
||||
const composeBlock = route.slice(
|
||||
route.indexOf("const composeAnswer = async () => {"),
|
||||
route.indexOf("const executionReceipt = (): AgentExecutionReceipt => ({", route.indexOf("const composeAnswer = async () => {")),
|
||||
route.indexOf("const composeAnswer = async ("),
|
||||
route.indexOf("const executionReceipt = (): AgentExecutionReceipt => ({", route.indexOf("const composeAnswer = async (")),
|
||||
);
|
||||
assert.match(composeBlock, /\.\.\.streamOptions,/);
|
||||
assert.match(composeBlock, /retryHint/);
|
||||
assert.doesNotMatch(composeBlock, /natalStreamOptions/);
|
||||
assert.doesNotMatch(stream, /section-empty-retry/);
|
||||
assert.match(tools, /dailyConsultationThinkingPlan/);
|
||||
@@ -191,7 +192,11 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
|
||||
assert.doesNotMatch(agenticBranch, /JSON\.stringify\(toolInput\)/);
|
||||
assert.match(tools, /\(ctx\.runWorkflow \?\? runConsultationWorkflow\)\(toolInput, \{/);
|
||||
assert.match(tools, /return \{ "run-jyotish-consultation": consultationTool \};/);
|
||||
assert.match(agenticBranch, /state\.workflowReceipt\?\.preciseTiming === "allowed"/);
|
||||
// 原值:transformText 读 workflowReceipt.preciseTiming === "allowed" 再套恒等壳
|
||||
// 新值:Pass 4 只看 consultationMode,不再读 preciseTiming 开关去挖日期
|
||||
// 原因:BUG-948,恒等壳下线;日期观察按模式,不按分钟敏感开关。
|
||||
assert.match(agenticBranch, /pass4Mode: consultationMode/);
|
||||
assert.doesNotMatch(agenticBranch, /state\.workflowReceipt\?\.preciseTiming === "allowed"/);
|
||||
assert.match(route, /createConsultationReplyMetadata/);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
ACCEPTED_RANGE_READING_INSTRUCTION,
|
||||
createBirthTimeModeOutputGuard,
|
||||
} from "../src/lib/consultation-birth-time-mode.ts";
|
||||
import { applyPass4Policy } from "../src/lib/timing-output-guard.ts";
|
||||
import { RANGE_READING_COPY } from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||||
import {
|
||||
createConsultationRuntimeState,
|
||||
@@ -47,25 +47,30 @@ test("theme_sensitivity maps sensitive keys onto the consultation answer policy"
|
||||
assert.deepEqual(receipt.minuteSensitiveThemes, ["marriage"]);
|
||||
});
|
||||
|
||||
test("minute-sensitive or timing themes use the precise-timing output guard", () => {
|
||||
test("minute-sensitive or timing themes keep calculated dasha dates", () => {
|
||||
// 原值:verified_chart + marriage/timing 主题把日期挖成「[具体时间已省略]」
|
||||
// 新值:日期原样保留,Pass 4 只记 observe
|
||||
// 原因:BUG-948,已校正用户问应期不该被删字;范围用户同样要拿到区间说法。
|
||||
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。";
|
||||
const marriage = createBirthTimeModeOutputGuard("verified_chart", true, {
|
||||
currentTheme: "marriage",
|
||||
minuteSensitiveThemes: ["marriage"],
|
||||
})(text);
|
||||
assert.match(marriage, /具体时间已省略/);
|
||||
const marriage = applyPass4Policy(text, "verified_chart");
|
||||
assert.equal(marriage.text, text);
|
||||
assert.equal(marriage.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
|
||||
const career = createBirthTimeModeOutputGuard("verified_chart", true, {
|
||||
currentTheme: "career",
|
||||
minuteSensitiveThemes: ["marriage"],
|
||||
})(text);
|
||||
assert.equal(career, text);
|
||||
const career = applyPass4Policy(text, "verified_chart");
|
||||
assert.equal(career.text, text);
|
||||
|
||||
const timing = createBirthTimeModeOutputGuard("verified_chart", true, {
|
||||
currentTheme: "timing",
|
||||
minuteSensitiveThemes: [],
|
||||
})(text);
|
||||
assert.match(timing, /具体时间已省略/);
|
||||
const timing = applyPass4Policy(text, "verified_chart");
|
||||
assert.equal(timing.text, text);
|
||||
assert.doesNotMatch(timing.text, /具体时间已省略/);
|
||||
});
|
||||
|
||||
test("declared birth window keeps exact dates and does not blank them", () => {
|
||||
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期,不写成单一分钟。";
|
||||
const report = applyPass4Policy(text, "declared_birth_window");
|
||||
assert.equal(report.text, text);
|
||||
assert.match(report.text, /2013年11月21日/);
|
||||
assert.doesNotMatch(report.text, /具体时间已省略/);
|
||||
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
});
|
||||
|
||||
test("declared window consultation merges range-reading themes and says it is a coarse look", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
shouldPublishStepText,
|
||||
} from "../src/lib/rectification-agentic/v9/step-answer.ts";
|
||||
import { mapStreamChunkToPhase, mapStreamChunkToThinking } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
|
||||
import { createThinkingFragmentAssembler } from "../src/lib/think-step-gate.ts";
|
||||
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
|
||||
function isPublicTool(name: string): boolean {
|
||||
@@ -44,6 +45,13 @@ test("never publishes reasoning-delta to the browser", () => {
|
||||
assert.ok(mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对经历。" }) as never));
|
||||
});
|
||||
|
||||
test("consecutive Chinese reasoning fragments assemble into a visible thinking line", () => {
|
||||
const assembler = createThinkingFragmentAssembler();
|
||||
assert.equal(assembler.push("The proposedKind value was rejected"), null);
|
||||
assert.equal(assembler.push("先核"), null);
|
||||
assert.equal(assembler.push("对经历。"), "先核对经历。");
|
||||
});
|
||||
|
||||
test("publishes only the terminal no-tool step as assistant text", () => {
|
||||
const state = createStepAnswerState();
|
||||
applyStepAnswerChunk(state, chunk("step-start"), isPublicTool);
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyPass4Policy,
|
||||
detectGeneralNoBirthTimeViolations,
|
||||
detectPreciseTimingViolations,
|
||||
GENERAL_NO_BIRTH_TIME_REFUSAL,
|
||||
guardGeneralNoBirthTimeOutput,
|
||||
guardPreciseTimingOutput,
|
||||
} from "../src/lib/timing-output-guard.ts";
|
||||
import { streamTextResponse } from "../src/lib/stream-text-response.ts";
|
||||
import { parseAgentReply } from "../src/lib/agent-reply.ts";
|
||||
import { createBirthTimeModeOutputGuard } from "../src/lib/consultation-birth-time-mode.ts";
|
||||
|
||||
test("detects exact dates and months when precise timing is blocked", () => {
|
||||
// 原值:就地替换成「[具体时间已省略]」
|
||||
// 新值:只检测,不改写
|
||||
// 原因:任务书 P3,正则当门不当刀。
|
||||
// 新值:只检测,不改写;Pass 4 在有盘模式下记 observe
|
||||
// 原因:任务书 P3 / BUG-948,正则当门不当刀。
|
||||
const text = "你会在2027年3月15日结婚,事业将在11月转折。";
|
||||
const hits = detectPreciseTimingViolations(text);
|
||||
assert.equal(hits.some((item) => item.kind === "exact-timing"), true);
|
||||
assert.equal(guardPreciseTimingOutput(text), text);
|
||||
const report = applyPass4Policy(text, "verified_chart");
|
||||
assert.equal(report.text, text);
|
||||
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
assert.doesNotMatch(report.text, /具体时间已省略/);
|
||||
});
|
||||
|
||||
test("detects guarantee conclusions when the evidence contract is incomplete", () => {
|
||||
const text = "我保证你一定会升职。";
|
||||
const hits = detectPreciseTimingViolations(text);
|
||||
assert.equal(hits.some((item) => item.kind === "guarantee"), true);
|
||||
assert.equal(guardPreciseTimingOutput(text), text);
|
||||
const first = applyPass4Policy(text, "verified_chart");
|
||||
assert.equal(first.retry, true);
|
||||
assert.equal(first.steps.some((step) => step.action === "reject" && step.kind === "guarantee"), true);
|
||||
const second = applyPass4Policy(text, "verified_chart", { secondPass: true });
|
||||
assert.doesNotMatch(second.text, /一定会升职/);
|
||||
assert.equal(
|
||||
detectPreciseTimingViolations("You will definitely get promoted.").some((item) => item.kind === "guarantee"),
|
||||
true,
|
||||
@@ -42,7 +48,6 @@ test("streamed dates stay intact because the guard no longer rewrites", async ()
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000098",
|
||||
transformText: guardPreciseTimingOutput,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert.match(text, /2027年3月15日/);
|
||||
@@ -64,7 +69,6 @@ test("guards only visible prose and preserves AYANAM blocks across arbitrary chu
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000099",
|
||||
transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false),
|
||||
});
|
||||
const text = await response.text();
|
||||
const parsed = parseAgentReply(text);
|
||||
@@ -96,19 +100,22 @@ test("general mode structurally rejects personalized chart placements in Chinese
|
||||
for (const claim of unsafeClaims) {
|
||||
const hits = detectGeneralNoBirthTimeViolations(claim);
|
||||
assert.equal(hits.some((item) => item.kind === "personal-chart" || item.kind === "exact-timing"), true, claim);
|
||||
assert.equal(guardGeneralNoBirthTimeOutput(claim), claim, claim);
|
||||
const first = applyPass4Policy(claim, "general_no_birth_time");
|
||||
assert.equal(first.retry, true, claim);
|
||||
const second = applyPass4Policy(claim, "general_no_birth_time", { secondPass: true });
|
||||
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL, claim);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
guardGeneralNoBirthTimeOutput("第七宫在占星概念中常与关系相关。"),
|
||||
applyPass4Policy("第七宫在占星概念中常与关系相关。", "general_no_birth_time").text,
|
||||
"第七宫在占星概念中常与关系相关。",
|
||||
);
|
||||
assert.equal(
|
||||
guardGeneralNoBirthTimeOutput("Venus is generally associated with relating and values."),
|
||||
applyPass4Policy("Venus is generally associated with relating and values.", "general_no_birth_time").text,
|
||||
"Venus is generally associated with relating and values.",
|
||||
);
|
||||
assert.equal(
|
||||
guardGeneralNoBirthTimeOutput("你问的第七宫,在占星概念中常与关系相关。"),
|
||||
applyPass4Policy("你问的第七宫,在占星概念中常与关系相关。", "general_no_birth_time").text,
|
||||
"你问的第七宫,在占星概念中常与关系相关。",
|
||||
);
|
||||
});
|
||||
@@ -128,7 +135,8 @@ test("general mode rejects personalized chart predicates beyond possessive place
|
||||
|
||||
for (const claim of unsafeClaims) {
|
||||
assert.equal(detectGeneralNoBirthTimeViolations(claim).length > 0, true, claim);
|
||||
assert.equal(guardGeneralNoBirthTimeOutput(claim), claim, claim);
|
||||
const second = applyPass4Policy(claim, "general_no_birth_time", { secondPass: true });
|
||||
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL, claim);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,7 +159,9 @@ test("general mode preserves educational chart questions and general knowledge",
|
||||
];
|
||||
|
||||
for (const statement of safeStatements) {
|
||||
assert.equal(guardGeneralNoBirthTimeOutput(statement), statement);
|
||||
const report = applyPass4Policy(statement, "general_no_birth_time");
|
||||
assert.equal(report.text, statement);
|
||||
assert.equal(report.retry, false, statement);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -169,7 +179,6 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000097",
|
||||
transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false),
|
||||
});
|
||||
const text = await response.text();
|
||||
const parsed = parseAgentReply(text);
|
||||
@@ -182,3 +191,25 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
|
||||
assert.equal(parsed.title, "一般占星咨询");
|
||||
assert.doesNotMatch(parsed.text, /AYANAM_SUGGESTIONS|了解第七宫的一般概念/);
|
||||
});
|
||||
|
||||
test("declared birth window keeps calculated dates as interval facts", () => {
|
||||
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期。";
|
||||
const report = applyPass4Policy(text, "declared_birth_window");
|
||||
assert.equal(report.text, text);
|
||||
assert.equal(report.retry, false);
|
||||
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
assert.doesNotMatch(report.text, /具体时间已省略/);
|
||||
});
|
||||
|
||||
test("identity output guards are gone from production sources", () => {
|
||||
const guard = readFileSync(new URL("../src/lib/timing-output-guard.ts", import.meta.url), "utf8");
|
||||
const mode = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8");
|
||||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
for (const source of [guard, mode, route]) {
|
||||
assert.doesNotMatch(source, /function guardPreciseTimingOutput/);
|
||||
assert.doesNotMatch(source, /function guardGeneralNoBirthTimeOutput/);
|
||||
assert.doesNotMatch(source, /createBirthTimeModeOutputGuard/);
|
||||
}
|
||||
assert.match(route, /pass4Mode/);
|
||||
assert.match(guard, /export function applyPass4Policy/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user