5e34dd69ce
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>
127 lines
5.9 KiB
TypeScript
127 lines
5.9 KiB
TypeScript
/**
|
||
* 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() : "") };
|
||
}
|