Merge remote-tracking branch 'origin/staging' into codex/billing-pricing-20260830
This commit is contained in:
@@ -11,6 +11,11 @@ export type DynamicStopInput = {
|
||||
readonly forcedReason: "user_finished" | "generation_unavailable" | null;
|
||||
};
|
||||
|
||||
// Keep the existing dynamic-stop safety cap as a shared server-owned value.
|
||||
// Rectification's authoritative decision consumes the same cap; it does not
|
||||
// define a second threshold.
|
||||
export const EFFECTIVE_ANSWER_SAFETY_CAP = 10;
|
||||
|
||||
export type DynamicStopDecision =
|
||||
| {
|
||||
readonly kind: "finish";
|
||||
@@ -42,7 +47,9 @@ export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision
|
||||
if (input.result?.confidence === "high" && input.result.canApply) {
|
||||
return { kind: "finish", reason: "high_confidence", plateauCount };
|
||||
}
|
||||
if (input.effectiveAnswerCount >= 10) return { kind: "finish", reason: "safety_cap", plateauCount };
|
||||
if (input.effectiveAnswerCount >= EFFECTIVE_ANSWER_SAFETY_CAP) {
|
||||
return { kind: "finish", reason: "safety_cap", plateauCount };
|
||||
}
|
||||
if (plateauCount >= 2) return { kind: "finish", reason: "plateau", plateauCount };
|
||||
if (input.usefulOpportunityCount === 0) return { kind: "finish", reason: "no_information_gain", plateauCount };
|
||||
if (input.repeatedOnly) return { kind: "finish", reason: "repeated_partition", plateauCount };
|
||||
|
||||
@@ -27,6 +27,9 @@ export type DecideNextActionInput = Readonly<{
|
||||
holdoutValidation?: HoldoutValidationStatus;
|
||||
accepted?: boolean;
|
||||
inferenceCredibleRange?: readonly [string, string] | null;
|
||||
inferenceRounds?: number;
|
||||
effectiveAnswerCount?: number;
|
||||
plateauRounds?: number;
|
||||
}>;
|
||||
|
||||
export type RectificationNextAction = Readonly<{
|
||||
@@ -55,6 +58,9 @@ export function decideNextAction(input: DecideNextActionInput): RectificationNex
|
||||
inferenceCredibleRange: input.inferenceCredibleRange,
|
||||
engineAcceptAllowed: input.selectionAllowed,
|
||||
engineProposeAllowed: input.proposeAllowed,
|
||||
inferenceRounds: input.inferenceRounds,
|
||||
effectiveAnswerCount: input.effectiveAnswerCount,
|
||||
plateauRounds: input.plateauRounds,
|
||||
});
|
||||
return { type: decision.nextAction, separation: decision.separation, probe: decision.probe };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
} from "./candidate-separation.ts";
|
||||
import { rangeFromTimes } from "./credible-range.ts";
|
||||
import type { DroppedProbe } from "../v9/probe-question-contract.ts";
|
||||
import type { RectificationPhase, ResultStatus } from "./types.ts";
|
||||
import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../../birth-time-dynamic-stop-policy.ts";
|
||||
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
|
||||
import {
|
||||
DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||||
type RectificationPhase,
|
||||
type ResultStatus,
|
||||
} from "./types.ts";
|
||||
|
||||
export type RectificationNextActionType =
|
||||
| "ask_fact_collection"
|
||||
@@ -81,6 +87,10 @@ export type DecideRectificationInput = Readonly<{
|
||||
engineAcceptAllowed?: boolean;
|
||||
engineProposeAllowed?: boolean;
|
||||
datedMethodCollectOpen?: boolean;
|
||||
/** Server-persisted inference counters; never infer these from chat text. */
|
||||
inferenceRounds?: number;
|
||||
effectiveAnswerCount?: number;
|
||||
plateauRounds?: number;
|
||||
}>;
|
||||
|
||||
export function decideRectification(input: DecideRectificationInput): RectificationDecision {
|
||||
@@ -136,7 +146,13 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
}
|
||||
if (input.snapshotCurrent === false) {
|
||||
if (probe && !userStopped && input.trainingGateOpen !== false) {
|
||||
return discriminate(separation, holdout, range, probe);
|
||||
return discriminateOrExhaust(
|
||||
input,
|
||||
separation,
|
||||
holdout,
|
||||
range,
|
||||
probe,
|
||||
);
|
||||
}
|
||||
if (!(userStopped && input.candidateScores.length > 0)) {
|
||||
return collect(separation, holdout, range, probe);
|
||||
@@ -144,7 +160,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
}
|
||||
if (!separation.sufficient) {
|
||||
if (probe && !userStopped) {
|
||||
return discriminate(separation, holdout, range, probe);
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe);
|
||||
}
|
||||
return completeWithRange(separation, holdout, range, userStopped ? "user_stopped" : "offer");
|
||||
}
|
||||
@@ -153,7 +169,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
}
|
||||
if (holdout === "failed") {
|
||||
if (probe && !userStopped) {
|
||||
return discriminate(separation, holdout, range, probe);
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe);
|
||||
}
|
||||
return completeWithRange(separation, holdout, range, "exhausted");
|
||||
}
|
||||
@@ -183,6 +199,25 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
});
|
||||
}
|
||||
|
||||
function budgetExhausted(input: DecideRectificationInput): boolean {
|
||||
return (input.inferenceRounds ?? 0) >= DEFAULT_MAX_DISCRIMINATION_ROUNDS
|
||||
|| (input.effectiveAnswerCount ?? 0) >= EFFECTIVE_ANSWER_SAFETY_CAP
|
||||
|| (input.plateauRounds ?? 0) >= RECTIFICATION_POLICY.maxPlateauRounds;
|
||||
}
|
||||
|
||||
function discriminateOrExhaust(
|
||||
input: DecideRectificationInput,
|
||||
separation: CandidateSeparation,
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
probe: CandidateDiscriminatorProbe,
|
||||
): RectificationDecision {
|
||||
if (budgetExhausted(input)) {
|
||||
return completeWithRange(separation, holdout, range, "exhausted");
|
||||
}
|
||||
return discriminate(separation, holdout, range, probe);
|
||||
}
|
||||
|
||||
function collect(
|
||||
separation: CandidateSeparation,
|
||||
holdout: HoldoutValidationStatus,
|
||||
|
||||
@@ -47,18 +47,6 @@ import {
|
||||
createStepAnswerState,
|
||||
flushStepAnswerOnStreamFinish,
|
||||
} from "./step-answer";
|
||||
import {
|
||||
bindSpokenToOpenQuestion,
|
||||
CHOICE_CARD_CONTINUATION_ACK,
|
||||
composeRectificationTurnNarration,
|
||||
openQuestionPromptFromToolResult,
|
||||
publicNarrationDtoFromDossier,
|
||||
} from "./turn-narration";
|
||||
import { isSafeCollectSpokenPrompt } from "./spoken-answer";
|
||||
import {
|
||||
collectSpokenPromptForNewFocus,
|
||||
composeCollectSpokenAssistantText,
|
||||
} from "./turn-decision";
|
||||
import {
|
||||
defaultMessageOrigin,
|
||||
isRectificationMessageOrigin,
|
||||
@@ -250,7 +238,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
|
||||
let collectSpokenEmitted = false;
|
||||
const collectSpokenEmitted = false;
|
||||
if (dossier.case.sessionId !== sessionId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
|
||||
}
|
||||
@@ -665,7 +653,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
|
||||
const stepAnswer = createStepAnswerState();
|
||||
|
||||
let persistedPrompt: string | null = null;
|
||||
let spokenRaw = "";
|
||||
let visibleEmitted = "";
|
||||
|
||||
@@ -690,31 +677,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
await emit({ type: "answer.delta", text: visible, replace: true });
|
||||
};
|
||||
|
||||
const emitNewCollectSpokenIfNeeded = async (): Promise<void> => {
|
||||
if (collectSpokenEmitted) return;
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const fromFocus = collectSpokenPromptForNewFocus({
|
||||
previousFocusId,
|
||||
focus: latest.conversationSummary.activeFocus,
|
||||
});
|
||||
const prompt = fromFocus && isSafeCollectSpokenPrompt(fromFocus) ? fromFocus : null;
|
||||
if (!prompt) return;
|
||||
const { composed, delta } = composeCollectSpokenAssistantText(answerText, prompt);
|
||||
if (!delta) return;
|
||||
if (!answerText.trim()) {
|
||||
await emitVisibleSpoken(composed);
|
||||
} else {
|
||||
answerText = composed;
|
||||
answerDeltas.push(delta);
|
||||
await emit({ type: "answer.delta", text: delta });
|
||||
}
|
||||
collectSpokenEmitted = true;
|
||||
} catch {
|
||||
// Visibility fallback must not fail the turn.
|
||||
}
|
||||
};
|
||||
|
||||
const publishSpokenStep = async (pieces: readonly string[], live = false) => {
|
||||
const joined = pieces.join("");
|
||||
if (!joined) return;
|
||||
@@ -722,11 +684,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
const spoken = live ? joined : joined.trim();
|
||||
if (!spoken) return;
|
||||
spokenRaw += spoken;
|
||||
const visible = persistedPrompt
|
||||
? bindSpokenToOpenQuestion(spokenRaw, persistedPrompt)
|
||||
: spokenRaw;
|
||||
if (!visible && persistedPrompt) return;
|
||||
await emitVisibleSpoken(visible);
|
||||
await emitVisibleSpoken(spokenRaw);
|
||||
};
|
||||
|
||||
const retractSpoken = async () => {
|
||||
@@ -764,9 +722,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}
|
||||
}
|
||||
|
||||
const stampedPrompt = openQuestionPromptFromToolResult(chunk);
|
||||
if (stampedPrompt) persistedPrompt = stampedPrompt;
|
||||
|
||||
const stepEffect = applyStepAnswerChunk(
|
||||
stepAnswer,
|
||||
chunk,
|
||||
@@ -848,30 +803,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
|
||||
}
|
||||
|
||||
const flushPersistedPrompt = async (): Promise<boolean> => {
|
||||
if (!persistedPrompt) {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
persistedPrompt = publicNarrationDtoFromDossier(latest).nextQuestion;
|
||||
} catch {
|
||||
// Keep whatever prompt the tool result already stamped.
|
||||
}
|
||||
}
|
||||
if (!persistedPrompt) return false;
|
||||
const bound = bindSpokenToOpenQuestion(spokenRaw || answerText, persistedPrompt);
|
||||
if (bound.trim()) {
|
||||
await emitVisibleSpoken(bound);
|
||||
return true;
|
||||
}
|
||||
if (visibleEmitted.trim() && visibleEmitted !== CHOICE_CARD_CONTINUATION_ACK) {
|
||||
await emitVisibleSpoken(CHOICE_CARD_CONTINUATION_ACK);
|
||||
return true;
|
||||
}
|
||||
if (answerText.trim()) return true;
|
||||
await emitVisibleSpoken(CHOICE_CARD_CONTINUATION_ACK);
|
||||
return true;
|
||||
};
|
||||
|
||||
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
@@ -931,20 +862,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
stepCount: toolsUsed.size,
|
||||
maxSteps,
|
||||
});
|
||||
if (mapped === "run_timeout") {
|
||||
if (await flushPersistedPrompt()) {
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
return completeAttempt();
|
||||
}
|
||||
return failedAttempt(attemptId, "run_timeout");
|
||||
}
|
||||
if (mapped === "run_timeout") return failedAttempt(attemptId, "run_timeout");
|
||||
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, mapped ?? "stream_aborted");
|
||||
if (!finished) return failedAttempt(attemptId, mapped ?? "stream_unfinished");
|
||||
if (mapped === "answer_truncated") {
|
||||
if (await flushPersistedPrompt()) {
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
return completeAttempt();
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: "failed",
|
||||
@@ -963,26 +884,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (mapped === "max_steps" || mapped === "provider_error") {
|
||||
return failedAttempt(attemptId, mapped);
|
||||
}
|
||||
if (await flushPersistedPrompt()) {
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
const invariant = await discriminatorInvariant();
|
||||
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
|
||||
return completeAttempt();
|
||||
}
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
if (!answerText.trim()) {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
|
||||
if (narration.trim()) {
|
||||
answerText = narration;
|
||||
answerDeltas.push(narration);
|
||||
await emit({ type: "answer.delta", text: narration });
|
||||
}
|
||||
} catch {
|
||||
// Fall through to empty_stream.
|
||||
}
|
||||
}
|
||||
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
|
||||
const invariant = await discriminatorInvariant();
|
||||
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
|
||||
|
||||
@@ -48,12 +48,7 @@ import {
|
||||
spokenFollowupForUser,
|
||||
} from "./method-followup";
|
||||
import type { SessionOutcomeKind } from "./confirmation-gate";
|
||||
import { isSafeCollectSpokenPrompt } from "./spoken-answer";
|
||||
import {
|
||||
collectSpokenPromptForNewFocus,
|
||||
composeCollectSpokenAssistantText,
|
||||
projectCurrentQuestion,
|
||||
} from "./turn-decision";
|
||||
import { projectCurrentQuestion } from "./turn-decision";
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
userId: string;
|
||||
@@ -598,55 +593,10 @@ async function persistExhaustionCollect(input: {
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: spoken
|
||||
? composeCollectSpokenAssistantText(range, spoken).composed
|
||||
: range,
|
||||
hostNarration: range,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistCollectSpokenAssistantIfNew(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
requestId: string;
|
||||
answerText: string;
|
||||
previousFocusId?: string | null;
|
||||
alreadyEmitted?: boolean;
|
||||
hostNarration?: string | null;
|
||||
}): Promise<string | null> {
|
||||
if (input.alreadyEmitted) return null;
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
const fromFocus = collectSpokenPromptForNewFocus({
|
||||
previousFocusId: input.previousFocusId ?? null,
|
||||
focus: dossier.conversationSummary.activeFocus,
|
||||
});
|
||||
if (!fromFocus) return null;
|
||||
const host = input.hostNarration?.trim() ?? "";
|
||||
const prompt = host && isSafeCollectSpokenPrompt(host) ? host : fromFocus;
|
||||
if (!isSafeCollectSpokenPrompt(prompt)) return null;
|
||||
const { delta } = composeCollectSpokenAssistantText(input.answerText, prompt);
|
||||
if (!delta) return null;
|
||||
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: null,
|
||||
assistantMessage: prompt,
|
||||
});
|
||||
return delta;
|
||||
}
|
||||
|
||||
export async function persistEmptyCollectSpokenAssistant(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
requestId: string;
|
||||
answerText: string;
|
||||
previousFocusId?: string | null;
|
||||
alreadyEmitted?: boolean;
|
||||
hostNarration?: string | null;
|
||||
}): Promise<string | null> {
|
||||
return persistCollectSpokenAssistantIfNew(input);
|
||||
}
|
||||
|
||||
async function persistApplied(
|
||||
accounting: AccountingClient,
|
||||
command: ApplyChoiceCommand,
|
||||
|
||||
@@ -355,6 +355,22 @@ export type DecideFromDossierOptions = Readonly<{
|
||||
birthDate?: string | null;
|
||||
}>;
|
||||
|
||||
function decisionBudgetFromInference(inference: InferenceState | null | undefined) {
|
||||
const rounds = inference?.rounds ?? [];
|
||||
let plateauRounds = 0;
|
||||
for (let index = rounds.length - 1; index >= 0; index -= 1) {
|
||||
if (rounds[index]?.kind !== "low_information") break;
|
||||
plateauRounds += 1;
|
||||
}
|
||||
return {
|
||||
// The existing convergence evaluator budgets informative rounds. Keep the
|
||||
// authoritative decision aligned with that persisted interpretation.
|
||||
inferenceRounds: rounds.filter((item) => item.kind === "informative").length,
|
||||
effectiveAnswerCount: inference?.answered_probes.length ?? 0,
|
||||
plateauRounds,
|
||||
};
|
||||
}
|
||||
|
||||
function completedProbeForSemanticKey(
|
||||
semanticKey: string,
|
||||
packet: CandidateContrastPacket | null | undefined,
|
||||
@@ -440,6 +456,7 @@ export function decideFromDossier(
|
||||
sessionOutcome: "collect_evidence",
|
||||
});
|
||||
const latest = dossier.latestResult;
|
||||
const decisionBudget = decisionBudgetFromInference(inference);
|
||||
const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, options, inference);
|
||||
const confirmationGate = buildConfirmationGate({
|
||||
engineConfirmationAllowed: latest?.confirmationAllowed === true,
|
||||
@@ -480,6 +497,7 @@ export function decideFromDossier(
|
||||
|| latest?.decisionReceipt?.accept_allowed === true,
|
||||
engineProposeAllowed: latest?.decisionReceipt?.propose_allowed === true,
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods),
|
||||
...decisionBudget,
|
||||
}),
|
||||
droppedProbes: gated.dropped,
|
||||
};
|
||||
@@ -503,6 +521,7 @@ export function decideAfterInferenceChange(input: {
|
||||
trainingGateOpen: trainingScoreableGate(input.dossier.evidence).open,
|
||||
candidateScores: [],
|
||||
userStopped: input.userStopped,
|
||||
...decisionBudgetFromInference(null),
|
||||
});
|
||||
}
|
||||
const training = input.state.events.filter((item) => item.usage === "training");
|
||||
@@ -543,6 +562,7 @@ export function decideAfterInferenceChange(input: {
|
||||
|| input.dossier.latestResult?.decisionReceipt?.accept_allowed === true,
|
||||
engineProposeAllowed: input.dossier.latestResult?.decisionReceipt?.propose_allowed === true,
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods),
|
||||
...decisionBudgetFromInference(input.state),
|
||||
}),
|
||||
droppedProbes: gated.dropped,
|
||||
};
|
||||
|
||||
@@ -1114,6 +1114,9 @@ export function decideConversationalSession(input: {
|
||||
holdoutValidation?: HoldoutValidationStatus;
|
||||
snapshotCurrent?: boolean;
|
||||
accepted?: boolean;
|
||||
inferenceRounds?: number;
|
||||
effectiveAnswerCount?: number;
|
||||
plateauRounds?: number;
|
||||
}): ReturnType<typeof decideRectification> {
|
||||
const coverageOpen = Boolean(
|
||||
input.methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"),
|
||||
@@ -1135,6 +1138,9 @@ export function decideConversationalSession(input: {
|
||||
engineAcceptAllowed: input.selectionAllowed,
|
||||
engineProposeAllowed: input.proposeAllowed,
|
||||
datedMethodCollectOpen: input.methods ? datedMethodCollectOpen(input.methods) : undefined,
|
||||
inferenceRounds: input.inferenceRounds,
|
||||
effectiveAnswerCount: input.effectiveAnswerCount,
|
||||
plateauRounds: input.plateauRounds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Hydrate recovery for old Turns that stored process talk in
|
||||
* `assistant_message`. Live `answer.delta` is the model text as-is; the
|
||||
* runner and chat must not call these helpers on the live stream.
|
||||
*/
|
||||
|
||||
import {
|
||||
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
|
||||
RECTIFICATION_TOOL_DONE_LABELS,
|
||||
} from "../../rectification-activity-labels.ts";
|
||||
|
||||
const CJK_RE = /[\u4e00-\u9fff]/;
|
||||
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|current_question|current_probe|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast(?:_packet)?|choice_frame|deferred_followup|resolve-focus|questionId|active[\s_-]?focus)\b/i;
|
||||
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用|看看|自然)|我(?:决定|倾向|batch|需要用|需要继续|需要考虑|继续收集|继续访谈|继续自然|自然地|用自然语言|应该|认为|可以尝试|不能把|直接自然|确认理解|先向用户)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|方法资料已齐|还不能出牌|不得出牌|不得\s*offer|本轮对照了|这意味着|服务器给了|第.{0,4}条边界|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup|根据规则|规则要求|不调用工具|严格来说|实际上规则|也许我应该|当前探针|这回应的是当前探针|账本|草稿|quote 路径|写入需要日期精度|没有具体日期|先确认草稿|纠缠草稿|自然访谈|待确认状态/;
|
||||
const THIRD_PERSON_USER_RE = /用户/;
|
||||
const AGENT_SELF_RE = /我(?:应该|认为|可以|先|需要|不能|直接)|让我|也许我/;
|
||||
const ADDRESSES_USER_RE = /你|您|记下了|已经记下|已记录了|已收到|不用急|别担心|哪一年|有没有|哪件|大概年份|对吗|是不是|请你/;
|
||||
|
||||
const ACTIVITY_ECHO_LABELS = [
|
||||
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
|
||||
...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS),
|
||||
];
|
||||
|
||||
export type SplitSpokenAndThinking = Readonly<{
|
||||
thinking: string;
|
||||
spoken: string;
|
||||
}>;
|
||||
|
||||
function isActivityEcho(text: string): boolean {
|
||||
const trimmed = text.trim().replace(/[。.…]+$/u, "");
|
||||
if (!trimmed) return false;
|
||||
return ACTIVITY_ECHO_LABELS.some((label) => {
|
||||
const bare = label.replace(/[。.…]+$/u, "");
|
||||
return trimmed === bare || trimmed === `正在${bare}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function isSafeCollectSpokenPrompt(prompt: string): boolean {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) return false;
|
||||
if (INTERNAL_TOKEN_RE.test(trimmed)) return false;
|
||||
if (trimmed.includes("请点选") || trimmed.includes("看下面这一问")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isRectificationProcessNarration(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/[A-Za-z]{4,}/.test(trimmed) && !CJK_RE.test(trimmed)) return true;
|
||||
if (isActivityEcho(trimmed)) return true;
|
||||
if (INTERNAL_TOKEN_RE.test(trimmed)) return true;
|
||||
if (PROCESS_ZH_RE.test(trimmed)) return true;
|
||||
if (THIRD_PERSON_USER_RE.test(trimmed)) return true;
|
||||
if (AGENT_SELF_RE.test(trimmed) && !ADDRESSES_USER_RE.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function nextStableChannelDelta(published: string, next: string): string {
|
||||
if (!next.startsWith(published)) return "";
|
||||
return next.slice(published.length);
|
||||
}
|
||||
|
||||
function splitSentences(text: string): string[] {
|
||||
const parts = text.split(/(?<=[。!?])\s*/u).map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : [text];
|
||||
}
|
||||
|
||||
function splitUnits(text: string): string[] {
|
||||
const lines = text.split(/\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length > 1) return lines.flatMap(splitSentences);
|
||||
return splitSentences(text);
|
||||
}
|
||||
|
||||
function isMixed(units: readonly string[]): boolean {
|
||||
return units.length > 1
|
||||
&& units.some(isRectificationProcessNarration)
|
||||
&& units.some((unit) => !isRectificationProcessNarration(unit));
|
||||
}
|
||||
|
||||
function splitParagraphs(text: string): string[] {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.flatMap((block) => {
|
||||
const trimmed = block.trim();
|
||||
if (!trimmed) return [];
|
||||
const units = splitUnits(trimmed);
|
||||
return isMixed(units) ? units : [trimmed];
|
||||
});
|
||||
}
|
||||
|
||||
export function splitRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
|
||||
const paragraphs = splitParagraphs(text);
|
||||
if (paragraphs.length === 0) return { thinking: "", spoken: text };
|
||||
|
||||
const thinking: string[] = [];
|
||||
const spoken: string[] = [];
|
||||
for (const paragraph of paragraphs) {
|
||||
if (isRectificationProcessNarration(paragraph)) thinking.push(paragraph);
|
||||
else spoken.push(paragraph);
|
||||
}
|
||||
|
||||
if (thinking.length === 0) return { thinking: "", spoken: text };
|
||||
const userFacing: string[] = [];
|
||||
for (const paragraph of spoken) {
|
||||
if (ADDRESSES_USER_RE.test(paragraph) || /[??]/.test(paragraph) || paragraph.length > 60) {
|
||||
userFacing.push(paragraph);
|
||||
} else {
|
||||
thinking.push(paragraph);
|
||||
}
|
||||
}
|
||||
return {
|
||||
thinking: thinking.join("\n\n"),
|
||||
spoken: userFacing.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
|
||||
const split = splitRectificationSpokenAndThinking(text);
|
||||
if (split.spoken.trim()) return split;
|
||||
if (!split.thinking.trim()) return { thinking: "", spoken: text };
|
||||
return { thinking: split.thinking, spoken: "" };
|
||||
}
|
||||
|
||||
export function settleRectificationSpokenAndThinking(
|
||||
answerRaw: string,
|
||||
thinkingRaw = "",
|
||||
): SplitSpokenAndThinking {
|
||||
const leak = splitRectificationSpokenAndThinking(answerRaw);
|
||||
const channelThinking = thinkingRaw.trim();
|
||||
const spoken = leak.spoken.trim();
|
||||
if (channelThinking) return { thinking: channelThinking, spoken };
|
||||
if (!spoken && leak.thinking.trim()) return { thinking: leak.thinking, spoken: "" };
|
||||
return { thinking: leak.thinking, spoken: spoken || (!leak.thinking ? answerRaw.trim() : "") };
|
||||
}
|
||||
@@ -67,46 +67,6 @@ function looksLikeChoiceSchema(schema: Readonly<Record<string, unknown>> | null
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyAnswerCollectSpokenFallback(
|
||||
answerText: string,
|
||||
question: Pick<CurrentQuestionProjection, "kind" | "prompt"> | null | undefined,
|
||||
): string | null {
|
||||
if (answerText.trim()) return null;
|
||||
if (question?.kind !== "collect_spoken") return null;
|
||||
const prompt = typeof question.prompt === "string" ? question.prompt.trim() : "";
|
||||
return prompt || null;
|
||||
}
|
||||
|
||||
export function collectSpokenPromptForNewFocus(input: {
|
||||
previousFocusId: string | null | undefined;
|
||||
focus: Parameters<typeof projectCurrentQuestion>[0];
|
||||
}): string | null {
|
||||
const question = projectCurrentQuestion(input.focus);
|
||||
if (question?.kind !== "collect_spoken") return null;
|
||||
const focusId = typeof input.focus?.id === "string" && input.focus.id.trim()
|
||||
? input.focus.id
|
||||
: question.focus_id;
|
||||
if (!focusId || focusId === (input.previousFocusId ?? null)) return null;
|
||||
const prompt = typeof question.prompt === "string" ? question.prompt.trim() : "";
|
||||
return prompt || null;
|
||||
}
|
||||
|
||||
export function composeCollectSpokenAssistantText(answerText: string, prompt: string): {
|
||||
composed: string;
|
||||
delta: string;
|
||||
} {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!trimmedPrompt) return { composed: answerText, delta: "" };
|
||||
if (!answerText.trim()) {
|
||||
return { composed: trimmedPrompt, delta: trimmedPrompt };
|
||||
}
|
||||
const delta = `\n\n${trimmedPrompt}`;
|
||||
return {
|
||||
composed: `${answerText.replace(/\s+$/u, "")}${delta}`,
|
||||
delta,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectCurrentQuestion(
|
||||
focus: {
|
||||
id?: string;
|
||||
|
||||
Reference in New Issue
Block a user