/** * 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() : "") }; }