Merge remote-tracking branch 'origin/staging' into codex/billing-pricing-20260830
Independent Staging Quality Gate / validate (push) Failing after 8m27s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-31 10:00:36 +08:00
28 changed files with 1013 additions and 1289 deletions
@@ -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;