fix(rectification): separate question slots from model prose
This commit is contained in:
@@ -233,7 +233,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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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