Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts
T
Jesse_Chen 5e34dd69ce fix(web): keep rectification replies as model text, hide thinking
Give the interview a hidden reasoning channel so planning leaves the spoken reply, publish terminal text-delta as-is, and stop regex or Case templates from replacing the model.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:34:25 +08:00

127 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 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() : "") };
}