fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流

schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
jesse-ux
2026-09-18 13:17:35 +08:00
parent b4fcb9466f
commit e32ce6247e
22 changed files with 617 additions and 205 deletions
+5 -30
View File
@@ -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 };
}
+64 -15
View File
@@ -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);
+34
View File
@@ -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;
},
};
}
+81 -6
View File
@@ -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 };
}
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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.