fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流
schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user