fix(rectification): separate question slots from model prose

This commit is contained in:
Jesse_Chen
2026-08-31 05:50:28 +08:00
parent 86ba17ee18
commit 6cbf1f22e2
14 changed files with 268 additions and 984 deletions
+24
View File
@@ -2942,6 +2942,30 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
}
.rectification-question-slot {
display: grid;
gap: var(--space-2);
margin-block: var(--space-3);
}
.rectification-question-slot__spoken {
display: grid;
gap: var(--space-1);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
}
.rectification-question-slot__prompt {
margin: 0;
color: var(--color-ink);
font-weight: 600;
}
.rectification-question-slot__hint,
.rectification-question-slot__status {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-snapshot {
display: grid;
gap: var(--space-3);
@@ -55,7 +55,7 @@ import {
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import {
isPersistedFocusId,
parseRectificationChoiceCard,
@@ -95,6 +95,23 @@ type PersistedTurn = Readonly<{
}>;
type CandidateResult = RectificationCandidateResult | null;
type CurrentQuestionModel = Readonly<{
kind: "choice" | "collect_spoken";
prompt: string | null;
}>;
function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const question = value as { kind?: unknown; prompt?: unknown };
if (question.kind !== "choice" && question.kind !== "collect_spoken") return null;
return {
kind: question.kind,
prompt: typeof question.prompt === "string" && question.prompt.trim()
? question.prompt.trim()
: null,
};
}
function RectificationCandidateCards({
result,
@@ -177,10 +194,6 @@ type RenderMessage = ChatMessageView & {
choiceAttachment?: SettledChoiceAttachment;
};
function turnOfferedSelection(message: RenderMessage): boolean {
return Boolean(message.completedReceipt?.steps.includes("rectification-offer-candidates"));
}
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
@@ -232,11 +245,10 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
const raw = failed ? "" : turn.text ?? "";
if (failed && !raw) return [];
if (isIncompleteRunBanner(raw)) return [];
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
const completedReceipt = completedReceiptFromPersisted(turn.receipt);
return [{
role: "assistant",
text: split.spoken,
text: raw,
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt,
@@ -286,6 +298,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -309,6 +324,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
const questionHintId = useId();
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
@@ -387,16 +403,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
case?: { accepted_time?: unknown; confirmed_time?: unknown };
case?: {
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
};
} | null) => {
if (!payload) return;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
const nextCaseStatus = isRectificationCaseStatus(payload.case?.status)
? payload.case.status
: null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
setCaseSnapshotLoaded(true);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1025,19 +1053,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? [message.choiceAttachment.card.question_id]
: []),
);
const liveChoiceHost = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.choiceAttachment
));
const showLiveChoiceCard = Boolean(
choiceCard
&& liveChoiceHost
currentQuestion?.kind === "choice"
&& choiceCard
&& !answeredQuestionIds.has(choiceCard.question_id)
&& !turnOfferedSelection(liveChoiceHost)
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
@@ -1058,9 +1077,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const showMissingQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion === null,
);
const showUnavailableQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion !== null
&& ((currentQuestion.kind === "choice" && !choiceCard)
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
);
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
@@ -1125,10 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
: message;
const settledChoice = message.choiceAttachment;
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
? choiceCard
: null;
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
const visibleChoiceCard = settledChoice?.card;
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
@@ -1173,7 +1206,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}`
: `${visibleChoiceCard.question_id}:${choiceNonce}`}
card={visibleChoiceCard}
pending={Boolean(liveChoice) && busy}
pending={false}
disabled={readonly || Boolean(settledChoice)}
selectedKey={settledChoice?.selectedKey ?? ""}
onSelect={submitChoice}
@@ -1183,6 +1216,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{collectSpokenPrompt && (
<div className="rectification-question-slot__spoken">
<p className="rectification-question-slot__prompt">{collectSpokenPrompt}</p>
<p id={questionHintId} className="rectification-question-slot__hint" role="note">
</p>
</div>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
{showUnavailableQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
</section>
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -1221,13 +1285,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
aria-describedby={collectSpokenPrompt ? questionHintId : undefined}
value={draft}
disabled={!canSend}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: "继续说你记得的人生经历,或回答刚才的问题…"}
: collectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -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;
@@ -83,10 +83,9 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => {
test("persisted rectification turns hydrate after the async Case refresh", () => {
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
assert.match(chat, /useState<RenderMessage\[\]>\(\(\) => messagesFromTurns\(initialTurns\)\)/);
assert.match(chat, /finalizeRectificationSpokenAndThinking/);
assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/);
assert.match(chat, /text: split\.spoken,/);
assert.doesNotMatch(chat, /split\.spoken \|\| raw/);
assert.doesNotMatch(chat, /spoken-answer/);
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
assert.match(chat, /text: raw,/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
@@ -248,10 +247,8 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /answerTokens: 8_192/);
assert.match(run, /thinkingTokens: 8_192/);
assert.match(run, /activity.changed/);
assert.match(run, /if \(!answerText\.trim\(\)\) \{[\s\S]*composeRectificationTurnNarration/);
assert.match(run, /bindSpokenToOpenQuestion/);
assert.match(run, /CHOICE_CARD_CONTINUATION_ACK/);
assert.match(run, /openQuestionPromptFromToolResult/);
assert.match(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/);
assert.doesNotMatch(run, /bindSpokenToOpenQuestion|CHOICE_CARD_CONTINUATION_ACK|openQuestionPromptFromToolResult/);
assert.doesNotMatch(run, /heldSpoken/);
assert.match(run, /replace: true/);
const narration = readFileSync(
@@ -540,9 +537,7 @@ test("time-selection cards use server adoption state and stay mutually exclusive
const cardsIndex = messageLoop.indexOf("<RectificationCandidateCards");
assert.ok(actionsIndex >= 0 && cardsIndex > actionsIndex);
assert.match(chat, /candidateResult\?\.selectionAllowed/);
assert.match(chat, /turnOfferedSelection/);
assert.match(chat, /rectification-offer-candidates/);
assert.match(chat, /showLiveChoiceCard = Boolean\(\s*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*currentQuestion\?\.kind === "choice"[\s\S]*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*candidateResult\?\.canAdopt[\s\S]*!showLiveChoiceCard[\s\S]*!busy[\s\S]*!readonly/);
assert.doesNotMatch(
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const selectionCardMessageKey")),
@@ -637,7 +632,8 @@ test("rectification Agent output stays natural and keeps tool execution silent",
assert.match(strategy, /没有更多事件/);
assert.match(strategy, /(?:无需|不要求)结束、暂停或保存进度/);
assert.match(strategy, /不要一进场就出 A\/B\/C\/D/);
assert.match(agent, /有持久化当前问题时,用自然语言问一件带大概年份的经历/);
assert.match(agent, /有持久化 current_question 时,题干与选项完全由结构化槽位和 UI 承担/);
assert.match(agent, /正文不得提问、复述、改写或拼接题干/);
});
test("clear current-turn events go through the batch evidence service", () => {
@@ -632,7 +632,7 @@ test("clicking A applies the choice without invoking a language model", async ()
});
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /当前可信区间/);
assert.match(applied.narration, /家里有没有结婚、添丁或住院/);
assert.doesNotMatch(applied.narration, /家里有没有结婚、添丁或住院/);
const fns = accounting.calls.map((call) => call.fn);
assert.ok(fns.includes("apply_agentic_rectification_choice_action"));
assert.equal(fns.includes("append_agentic_rectification_turn"), true);
@@ -565,7 +565,7 @@ test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric
assert.match(idle.hostNarration ?? "", /04:4704:53/);
assert.match(idle.hostNarration ?? "", /04:51/);
assert.match(idle.hostNarration ?? "", new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.match(idle.hostNarration ?? "", /升学|转学|考试/);
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT));
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_intent, "collect_method_evidence");
@@ -1,210 +1,37 @@
/**
* These helpers recover old leaked Turns on hydrate. They are not the live
* spoken-answer classifier; `runV9AgentTurn` must not call them.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
finalizeRectificationSpokenAndThinking,
isRectificationProcessNarration,
nextStableChannelDelta,
settleRectificationSpokenAndThinking,
splitRectificationSpokenAndThinking,
} from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const agentRun = readFileSync(
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
"utf8",
);
test("a plain spoken follow-up stays on the answer channel", () => {
const spoken = [
"记下了,大约六岁入学小学。",
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
].join("\n\n");
assert.deepEqual(splitRectificationSpokenAndThinking(spoken), {
thinking: "",
spoken,
});
test("live and persisted assistant text stay verbatim after spoken-answer removal", () => {
assert.match(chat, /const raw = failed \? "" : turn\.text \?\? ""/);
assert.match(chat, /text: raw,/);
assert.doesNotMatch(chat, /spoken-answer/);
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
assert.match(agentRun, /answerText = visible;/);
assert.doesNotMatch(agentRun, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
});
test("process self-talk leaves the spoken conclusion on the answer channel", () => {
const processTalk = [
"用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。",
"但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 yearoccurredFrom 只能按用户原话来。",
"我决定先按六岁入学记下来,再在正文里确认那句晚年份是不是口误。",
"让我调用 batch 写入 education_start。",
].join("\n\n");
const spoken = [
"入学小学这条先按大约六岁记下。你第一句提到的那个很晚的年份,我理解是口误对吗?",
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
].join("\n\n");
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`), {
thinking: processTalk,
spoken,
});
test("structured current_question, not model prose, owns the visible question slot", () => {
assert.match(chat, /current_question\?: unknown/);
assert.match(chat, /setCurrentQuestion\(nextQuestion\)/);
assert.match(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "choice"/);
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
});
test("English process talk is classified as thinking, not the spoken answer", () => {
const split = splitRectificationSpokenAndThinking([
"The proposedKind value was rejected. Retrying with education_start.",
"记下了,那年九月上大学。",
].join("\n\n"));
assert.match(split.thinking, /proposedKind/);
assert.equal(split.spoken, "记下了,那年九月上大学。");
});
test("stable channel deltas only emit the newly classified suffix", () => {
assert.equal(nextStableChannelDelta("", "先核对升学年份。"), "先核对升学年份。");
assert.equal(
nextStableChannelDelta("先核对升学年份。", "先核对升学年份。\n\n再问初中。"),
"\n\n再问初中。",
);
assert.equal(nextStableChannelDelta("先核对升学年份。", "另一段"), "");
});
test("a lone process paragraph stays in thinking until a spoken conclusion arrives", () => {
const processTalk = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n`), {
thinking: processTalk,
spoken: "",
});
});
test("truncated third-person batch-tool narration stays in thinking", () => {
const truncated = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
assert.equal(isRectificationProcessNarration(truncated), true);
assert.deepEqual(splitRectificationSpokenAndThinking(truncated), {
thinking: truncated,
spoken: "",
});
assert.deepEqual(finalizeRectificationSpokenAndThinking(truncated), {
thinking: truncated,
spoken: "",
});
});
test("finalizing does not promote process-only self-talk into the spoken answer", () => {
const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
const last = "但按 skill 规则,日期精度真实保留,不得猜补。";
const processTalk = `${first}\n\n${last}`;
assert.deepEqual(finalizeRectificationSpokenAndThinking(processTalk), {
thinking: processTalk,
spoken: "",
});
});
test("Chinese interview planning after tools stays out of the spoken answer", () => {
const processTalk = [
"读取校正记录",
"整理多条事件证据",
"比较候选时间",
"职业类型已经记录,并重新计算了候选。虽然职业领域现在有信息了,但账户里仍然只有 3 件带评分日期的事件,还不足以拉开候选范围区间(当前不可分宽度 8 分钟)。",
"方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分),但还需要更多带日期的经历来区分候选。当前还应继续收集事件。",
"我用自然语言再问一件带大概年份的经历。根据 method_followup_plan,职业已经覆盖。当前 open_question 为空,next_action 为空。",
"候选区分尚未充分(not_separated),应继续收集。",
"让我继续访谈,问一件能帮助区分候选的职业前事。",
"本轮对照了Gochara、D1 本命盘、D10 事业分盘。",
].join("\n\n");
const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**又是什么时候离开的?只要个大概年份就行。";
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
assert.equal(split.spoken, spoken);
assert.match(split.thinking, /occupation_note/);
assert.match(split.thinking, /读取校正记录/);
assert.match(split.thinking, /本轮对照了/);
assert.doesNotMatch(split.thinking, /你大概是在哪一年入职/);
assert.equal(isRectificationProcessNarration("读取校正记录"), true);
assert.equal(isRectificationProcessNarration(spoken), false);
});
test("planning about candidate_contrast_packet stays out of the spoken answer", () => {
const processTalk = [
"这意味着:方法资料已齐,还不能出牌。",
"服务器给了 candidate_contrast_packetchoice_frame 已写好。",
"第 7 条边界:id=ask_candidate_discriminator 时不得 offer。",
].join("\n\n");
const spoken = "那次高考或重要考试,发挥有没有明显失常、压力很大?说有或没有就行。";
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
assert.equal(split.spoken, spoken);
assert.match(split.thinking, /这意味着/);
assert.match(split.thinking, /candidate_contrast_packet/);
assert.match(split.thinking, /不得 offer/);
assert.match(split.thinking, /第 7 条边界/);
assert.doesNotMatch(split.thinking, /发挥有没有明显失常/);
assert.equal(isRectificationProcessNarration("这意味着:方法资料已齐"), true);
assert.equal(isRectificationProcessNarration("服务器给了 candidate_contrast_packet"), true);
assert.equal(isRectificationProcessNarration(spoken), false);
const settled = settleRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`, "");
assert.equal(settled.spoken, spoken);
assert.doesNotMatch(settled.spoken, /candidate_contrast_packet/);
});
test("draft-ledger planning stays out of the spoken answer", () => {
const processTalk = [
"第二件离职事件已记录为草稿。现在账本有两件已确认加一件草稿。",
"既然实习开始和实习离职都属于同一段职业经历并且相关联,用户后面的消息会继续补充,我先向用户确认这件离职信息,同时继续收集。",
"由于离职是事务性的独立事件且 quote 路径有障碍,我继续自然访谈。",
"先确认草稿再继续收集下一件。",
"这里草稿是服务器接受但待确认状态。",
"我应该继续访谈而不是纠缠草稿确认。",
].join("\n\n");
const spoken = [
"好的,已经记下你这段职业经历:那年春天开始实习、秋天离职。加上之前上学,目前有了比较清晰的两大块时间线。",
"不用急着回忆全部。毕业后正式入职的第一份工作,大概哪一年?有没有恋爱、结婚这样的关系节点?哪一件印象深就先说哪件,大概年份就好。",
].join("\n\n");
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
assert.equal(split.spoken, spoken);
assert.match(split.thinking, /账本/);
assert.match(split.thinking, /我应该继续访谈/);
assert.match(split.thinking, /quote 路径/);
assert.doesNotMatch(split.spoken, /账本/);
assert.doesNotMatch(split.spoken, /我应该/);
assert.doesNotMatch(split.spoken, /草稿是服务器/);
assert.equal(isRectificationProcessNarration("我应该继续访谈而不是纠缠草稿确认。"), true);
assert.equal(isRectificationProcessNarration(spoken.split("\n\n")[0]!), false);
});
test("probe-deliberation with no user-facing close stays off the spoken answer", () => {
const processTalk = [
"这回应的是当前探针(感情前事,用于对照 D9 差异)。",
"当前探针是请核对一段还没用进评分的感情前事。用户给出的是一个主观感受,不是具体带日期的事件。",
"让我看看是否应该把它作为一条证据记录。",
"严格来说,用户没有给出有确定日期的新事件。根据规则,没有具体带日期事件就按 focus 处理。",
"current_question 为 null,但 current_probe 有一条。",
"也许我应该直接自然回应,不调用工具。规则要求承接。",
"我认为这里不需要写证据。写入需要日期精度。",
"我直接自然问。",
].join("\n\n");
const split = splitRectificationSpokenAndThinking(processTalk);
assert.equal(split.spoken, "");
assert.match(split.thinking, /当前探针/);
assert.match(split.thinking, /current_question/);
assert.match(split.thinking, /我直接自然问/);
assert.equal(finalizeRectificationSpokenAndThinking(processTalk).spoken, "");
const settled = settleRectificationSpokenAndThinking(processTalk, "");
assert.equal(settled.spoken, "");
assert.doesNotMatch(settled.spoken, /探针/);
assert.doesNotMatch(settled.spoken, /根据规则/);
});
test("leaked process text on the answer channel is not mixed into native thinking", () => {
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?";
assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, ""), {
thinking: processTalk,
spoken: "",
});
assert.deepEqual(settleRectificationSpokenAndThinking(spoken, processTalk), {
thinking: processTalk,
spoken,
});
assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, processTalk), {
thinking: processTalk,
spoken: "",
});
test("answer deltas expose the model text without a parser or regex cleanup", () => {
assert.match(chat, /raw = event\.replace === true \? event\.text : raw \+ event\.text/);
assert.match(chat, /text: raw,/);
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
});
@@ -9,14 +9,10 @@ import {
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { emptyAnswerCollectSpokenFallback, projectTurnDecision, collectSpokenPromptForNewFocus, composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { persistCollectSpokenAssistantIfNew, persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { spokenCollectFallbackFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { isSafeCollectSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
import { openQuestionPromptFromToolResult } from "../src/lib/rectification-agentic/v9/turn-narration.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import {
CASE_ID,
EVIDENCE_ID,
@@ -28,7 +24,6 @@ import {
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import { parseV9CaseDossier as parseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
@@ -92,26 +87,44 @@ test("skill version stays 10.0.13 for the spoken-collect visibility fix", () =>
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
});
test("GET collect_spoken is not rendered as a standalone chat block", () => {
test("cases current_question drives the unified question slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const choiceCard = readFileSync(new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url), "utf8");
assert.doesNotMatch(chat, /parseRectificationSpokenCollect/);
assert.doesNotMatch(chat, /showLiveSpokenCollect/);
assert.doesNotMatch(chat, /spokenCollect/);
assert.doesNotMatch(chat, /liveSpoken/);
assert.doesNotMatch(chat, /rectification-spoken-collect-prompt/);
assert.doesNotMatch(chat, /aria-label="口述采集题"/);
assert.doesNotMatch(styles, /rectification-spoken-collect-prompt/);
assert.doesNotMatch(choiceCard, /parseRectificationSpokenCollect|RectificationSpokenCollect/);
assert.match(chat, /current_question\?: unknown/);
assert.match(chat, /setCurrentQuestion\(nextQuestion\)/);
assert.match(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "choice"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /onSelect=\{submitChoice\}/);
assert.match(chat, /onStop=\{submitStop\}/);
assert.doesNotMatch(chat, /finalizeRectificationSpokenAndThinking/);
assert.doesNotMatch(chat, /rectification-agentic\/v9\/spoken-answer/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
test("collect_spoken current_question shows the server prompt and input hint", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /rectification-question-slot__prompt/);
assert.match(chat, /请在下方输入框回答/);
assert.match(chat, /aria-describedby=\{collectSpokenPrompt \? questionHintId : undefined\}/);
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
});
test("missing current_question is explicit only for resumable cases", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
assert.match(chat, /readonly && \(/);
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
});
test("choice_card still renders through the existing choice-card branch", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const render = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{messages.map((message) => {") ,
chat.indexOf("{savedTime &&"),
);
assert.match(render, /showLiveChoiceCard/);
@@ -122,25 +135,6 @@ test("choice_card still renders through the existing choice-card branch", () =>
assert.doesNotMatch(render, /showLiveSpokenCollect/);
});
test("empty answerText with a collect_spoken focus falls back to the focus prompt; non-empty does not", () => {
const collect = {
kind: "collect_spoken" as const,
prompt: RELATIONSHIP_PROMPT,
};
assert.equal(emptyAnswerCollectSpokenFallback("", collect), RELATIONSHIP_PROMPT);
assert.equal(emptyAnswerCollectSpokenFallback(" ", collect), RELATIONSHIP_PROMPT);
assert.equal(emptyAnswerCollectSpokenFallback("记下了。", collect), null);
assert.equal(emptyAnswerCollectSpokenFallback("", {
kind: "choice",
prompt: RELATIONSHIP_PROMPT,
}), null);
assert.equal(emptyAnswerCollectSpokenFallback("", null), null);
assert.equal(emptyAnswerCollectSpokenFallback("", {
kind: "collect_spoken",
prompt: " ",
}), null);
});
test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => {
const collect = openQuestionFromPersistedFocus(collectPersistResult());
assert.equal(collect?.kind, "collect_spoken");
@@ -306,18 +300,15 @@ test("agent prompt does not let the model write a persisted question stem", () =
agent.indexOf("const agenticRectificationInstructions"),
agent.indexOf("export function getRectificationV9Agent"),
);
assert.match(prompt, /有持久化当前问题时/);
assert.match(prompt, /题干一律不由你写/);
assert.match(prompt, /collect_spoken[\s\S]*服务器接在正文之后/);
assert.match(prompt, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/);
assert.match(prompt, /请点选/);
assert.match(prompt, /有持久化 current_question 时/);
assert.match(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
assert.match(prompt, /collect_spoken[\s\S]*不输出输入提示/);
assert.match(prompt, /没有 current_question 时也不要自拟区分题/);
assert.match(prompt, /选择卡/);
assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/);
assert.doesNotMatch(prompt, /界面提示条/);
});
const FALLBACK_REQUEST_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const RECORDED_BODY = "记下了:2022年搬家。";
const COLLECT_QUESTION_ID = "collect:relationship:collect_method_evidence";
function collectFocus() {
@@ -332,367 +323,12 @@ function collectFocus() {
});
}
function collectFocusDossier() {
return dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: collectFocus(),
}),
});
}
function relocation2022Dossier(activeFocus: ReturnType<typeof collectFocus> | null) {
return dossierFixture({
evidence: [
{
id: EVIDENCE_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relocation",
domain: "relocation",
occurred_from: "2022-01-01",
occurred_to: null,
date_precision: "year",
summary: "2022年搬家",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-30T00:00:00.000Z",
},
],
conversationSummary: conversationSummaryFixture({
activeFocus,
}),
});
}
function choiceFocusDossier() {
return dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
targetDomain: "education",
expectedAnswerSchema: {
choice: {
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: [
{ key: "A", label: "明确发生且时间吻合", answer_class: "yes" },
{ key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" },
{ key: "C", label: "明确没有发生", answer_class: "no" },
{ key: "D", label: "这段记不清楚", answer_class: "unsure" },
],
},
},
}),
}),
});
}
test("new collect_spoken focus is visible from the focus lifecycle, not from body text", () => {
const focus = {
id: FOCUS_ID,
questionId: COLLECT_QUESTION_ID,
intent: "collect_method_evidence",
targetDomain: "relationship",
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
};
assert.equal(
collectSpokenPromptForNewFocus({ previousFocusId: null, focus }),
RELATIONSHIP_PROMPT,
);
assert.equal(
collectSpokenPromptForNewFocus({ previousFocusId: FOCUS_ID, focus }),
null,
);
assert.equal(
collectSpokenPromptForNewFocus({
previousFocusId: null,
focus: {
id: FOCUS_ID,
intent: "distinguish_candidates",
expectedAnswerSchema: {
choice: { prompt: RELATIONSHIP_PROMPT, option_a: "A", option_b: "B", option_c: "C", option_d: "D" },
},
},
}),
null,
);
assert.equal(composeCollectSpokenAssistantText(" ", RELATIONSHIP_PROMPT).composed, RELATIONSHIP_PROMPT);
assert.equal(
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).composed,
`${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`,
);
assert.equal(
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).delta,
`\n\n${RELATIONSHIP_PROMPT}`,
);
assert.equal(spokenFollowupForUser(collectFollowup()), RELATIONSHIP_PROMPT);
assert.equal(isSafeCollectSpokenPrompt(RELATIONSHIP_PROMPT), true);
assert.equal(isSafeCollectSpokenPrompt("接下来请点选下面这一问。"), false);
assert.equal(isSafeCollectSpokenPrompt("看下面这一问"), false);
assert.equal(isSafeCollectSpokenPrompt("请继续 focusId 提问"), false);
assert.equal(openQuestionPromptFromToolResult({
type: "tool-result",
payload: {
result: { open_question: { kind: "collect_spoken", prompt: RELATIONSHIP_PROMPT } },
},
}), null);
});
test("empty agent body persists the collect_spoken prompt as a plain assistant message", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
append_agentic_rectification_turn: (_fn, args) => ({
turn_id: "99999999-9999-4999-8999-999999999999",
idempotent: false,
assistant_message: args.p_assistant_message,
}),
});
const filled = await persistEmptyCollectSpokenAssistant({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: " ",
previousFocusId: null,
});
assert.equal(filled, RELATIONSHIP_PROMPT);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_assistant_message, RELATIONSHIP_PROMPT);
assert.equal(turn?.args.p_user_message, null);
assert.equal(turn?.args.p_status, "completed");
const appended = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
answerText: RECORDED_BODY,
previousFocusId: null,
});
assert.equal(appended, `\n\n${RELATIONSHIP_PROMPT}`);
const turns = accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turns.length, 2);
assert.equal(turns[1]?.args.p_assistant_message, RELATIONSHIP_PROMPT);
assert.match(String(turns[1]?.args.p_assistant_message), /还记得别的带年份的感情变化吗/);
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /请点选/);
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /focusId|current_question|choice_frame/);
});
test("the same open collect focus is not appended again on the next turn", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
});
const skipped = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: RECORDED_BODY,
previousFocusId: FOCUS_ID,
});
assert.equal(skipped, null);
assert.equal(
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
0,
);
});
test("choice focus never copies the stem into the assistant body", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => choiceFocusDossier(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
});
const skipped = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: "记下了。",
previousFocusId: null,
});
assert.equal(skipped, null);
assert.equal(
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
0,
);
});
test("in-turn persistPlanFocus and idle persist share one collect stem and emit once", async () => {
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = answerChoice.slice(
answerChoice.indexOf("export async function persistNextInterviewIfIdle"),
answerChoice.indexOf("export async function persistCollectSpokenAssistantIfNew"),
);
assert.match(idle, /if \(dossier\.conversationSummary\.activeFocus\)/);
assert.match(idle, /hostNarration:\s*nextInterview\.hostNarration/);
assert.match(answerChoice, /spokenFollowupForUser\(followup\)/);
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
append_agentic_rectification_turn: (_fn, args) => ({
turn_id: TURN_ID,
assistant_message: args.p_assistant_message,
}),
});
const first = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: RECORDED_BODY,
previousFocusId: null,
hostNarration: spokenFollowupForUser(collectFollowup()),
});
assert.equal(first, `\n\n${RELATIONSHIP_PROMPT}`);
const second = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
answerText: RECORDED_BODY,
previousFocusId: null,
alreadyEmitted: true,
hostNarration: spokenFollowupForUser(collectFollowup()),
});
assert.equal(second, null);
assert.equal(
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
1,
);
});
test("agent route appends a new collect_spoken stem from focus lifecycle, not body text", () => {
test("agent route keeps question ownership in the server Case projection", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
assert.match(afterRun, /persistNextInterviewIfIdle/);
assert.match(afterRun, /persistCollectSpokenAssistantIfNew/);
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
assert.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistCollectSpokenAssistantIfNew"));
assert.match(afterRun, /previousFocusId:\s*result\.previousFocusId/);
assert.match(afterRun, /alreadyEmitted:\s*result\.collectSpokenEmitted/);
assert.match(afterRun, /hostNarration:\s*idleHostNarration/);
assert.match(afterRun, /send\(\{ type: "answer\.delta", text: fallback \}\)/);
assert.doesNotMatch(afterRun, /if \(!result\.answerText\.trim\(\)\)/);
assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /collectSpokenPromptForNewFocus/);
assert.match(agentRun, /composeCollectSpokenAssistantText/);
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
});
async function runCollectTurn(input: {
firstDossier: unknown;
laterDossier: unknown;
spoken?: string;
}) {
const emitted: Array<{ type: string; text?: string }> = [];
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
return loads === 1 ? input.firstDossier : input.laterDossier;
},
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const result = await runV9AgentTurn({
userId: USER_ID,
caseId: CASE_ID,
sessionId: "22222222-2222-4222-8222-222222222222",
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2022年搬家",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
reserve: async () => ({ success: true, status: 200 }),
complete: async () => true,
release: async () => true,
},
emit: (event) => { emitted.push(event); },
buildAgent: async () => ({
stream: async () => ({
fullStream: (async function* () {
yield { type: "start" };
yield { type: "tool-call", payload: { toolName: "skill", args: { name: "jyotish-birth-time-rectification" } } };
yield { type: "tool-result", payload: { toolName: "skill" } };
yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } };
yield { type: "tool-result", payload: { toolName: "rectification-read-case" } };
if (input.spoken) {
yield { type: "text-delta", payload: { text: input.spoken } };
}
yield { type: "finish" };
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 4 }),
}),
getSkill: async () => ({ name: "jyotish-birth-time-rectification", instructions: "skill" }),
}) as never,
});
return { result, emitted };
}
test("free-text 2022 relocation creates a new collect_spoken stem in the assistant body", async () => {
const { result, emitted } = await runCollectTurn({
firstDossier: relocation2022Dossier(null),
laterDossier: relocation2022Dossier(collectFocus()),
spoken: RECORDED_BODY,
});
assert.equal(result.ok, true);
assert.equal(result.previousFocusId, null);
assert.equal(result.collectSpokenEmitted, true);
assert.equal(result.answerText, `${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`);
assert.match(result.answerText, /还记得别的带年份的感情变化吗/);
const deltas = emitted.filter((event) => event.type === "answer.delta").map((event) => event.text);
assert.ok(deltas.includes(RECORDED_BODY));
assert.ok(deltas.includes(`\n\n${RELATIONSHIP_PROMPT}`));
assert.doesNotMatch(result.answerText, /请点选/);
assert.doesNotMatch(result.answerText, /focusId|current_question|choice_frame/);
});
test("empty stream with a newly created collect_spoken focus uses the focus prompt as the assistant message", async () => {
const { result, emitted } = await runCollectTurn({
firstDossier: relocation2022Dossier(null),
laterDossier: relocation2022Dossier(collectFocus()),
});
assert.equal(result.ok, true);
assert.equal(result.answerText, RELATIONSHIP_PROMPT);
assert.equal(result.collectSpokenEmitted, true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: RELATIONSHIP_PROMPT }],
);
});
test("an already open collect_spoken focus is not appended again after a later turn", async () => {
const { result } = await runCollectTurn({
firstDossier: collectFocusDossier(),
laterDossier: collectFocusDossier(),
spoken: RECORDED_BODY,
});
assert.equal(result.ok, true);
assert.equal(result.previousFocusId, FOCUS_ID);
assert.equal(result.collectSpokenEmitted, false);
assert.equal(result.answerText, RECORDED_BODY);
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
});
test("choice focus leaves the stem on the card and out of the body", async () => {
const { result } = await runCollectTurn({
firstDossier: choiceFocusDossier(),
laterDossier: choiceFocusDossier(),
spoken: RECORDED_BODY,
});
assert.equal(result.ok, true);
assert.equal(result.collectSpokenEmitted, false);
assert.doesNotMatch(result.answerText, /升学结果或学习环境/);
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
assert.doesNotMatch(result.answerText, /请点选/);
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus|composeCollectSpokenAssistantText/);
});
@@ -63,8 +63,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /工具执行保持静默/);
assert.match(prompt, /思考用简体中文写在思维链/);
assert.match(prompt, /对用户说的话必须自己写在正文里/);
assert.match(prompt, /题干选项只由选择卡展示/);
assert.match(prompt, /正文只自然承接/);
assert.match(prompt, /题干选项完全由结构化槽位和 UI 承担/);
assert.match(prompt, /每轮正文只输出一句确认或承接/);
assert.match(prompt, /「先这样」由服务器/);
assert.doesNotMatch(prompt, /不得询问外貌、体质、胎记或疤痕/);
assert.doesNotMatch(prompt, /财务与健康只有用户主动说才问/);
@@ -407,6 +407,8 @@ test("a repeated identical tool call is treated as idempotent and does not abort
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "已经记下。" }),
chunk("finish"),
]) as never,
});
+59 -109
View File
@@ -23,15 +23,6 @@ import {
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { messageContentHash } from "../src/lib/rectification-agentic/v9/message-origin.ts";
import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
bindSpokenToOpenQuestion,
openQuestionPromptFromToolResult,
} from "../src/lib/rectification-agentic/v9/turn-narration.ts";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
} from "../src/lib/rectification-activity-receipt.ts";
type StreamChunk = {
type: string;
@@ -606,7 +597,7 @@ test("Chinese process self-talk after tools is thinking, not the spoken answer",
assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?");
});
test("process-only self-talk after tools is replaced by server narration, not retried", async () => {
test("process-only self-talk after tools fails closed instead of becoming the spoken answer", async () => {
let buildCount = 0;
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
const accounting = fakeAccounting({
@@ -639,13 +630,16 @@ test("process-only self-talk after tools is replaced by server narration, not re
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
assert.equal(result.ok, true);
assert.match(result.answerText, /已经记下|请继续说下一件/);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "empty_stream");
assert.equal(result.answerText, "");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.doesNotMatch(JSON.stringify(emitted), /我需要用批量工具/);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(finalizedTurn?.args.p_status, "completed");
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
assert.equal(finalizedTurn?.args.p_status, "failed");
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
@@ -683,7 +677,7 @@ test("a length-limited spoken answer is not billed or persisted as a completed t
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
});
test("length after a stamped open_question still completes without failing the choice card", async () => {
test("length after a stamped open_question fails closed without rewriting the answer", async () => {
const prompt = "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?";
const accounting = fakeAccounting({
...receiptHandlers,
@@ -709,12 +703,16 @@ test("length after a stamped open_question still completes without failing the c
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "接下来看下面这一问。");
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
assert.equal(result.ok, false);
assert.equal(result.errorCode, "answer_truncated");
assert.equal(result.answerText, "");
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "实习和离职的时间点都记下了,谢谢。" }],
);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("answer deltas and tool activity are published before billing settles", async () => {
@@ -818,7 +816,7 @@ test("browser disconnect aborts the run, finalizes retryable and releases usage"
assert.equal(billing.released, 1);
});
test("empty stream uses server narration instead of retrying the attempt", async () => {
test("empty stream fails closed instead of synthesizing an answer", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
@@ -830,11 +828,13 @@ test("empty stream uses server narration instead of retrying the attempt", async
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(result.answerText, /已经记下|请继续说下一件/);
assert.equal(billing.completed, 1);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "empty_stream");
assert.equal(result.answerText, "");
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
});
test("legacy Skill identity fails before billing reservation", async () => {
@@ -1154,7 +1154,7 @@ test("does not retry set-focus with identical arguments", async () => {
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("duplicate compare after diagnostics still completes with server narration", async () => {
test("duplicate compare after diagnostics fails closed without resetting the attempt", async () => {
const executedMethods = [
"ashtakavarga",
"d1-rashi",
@@ -1193,19 +1193,19 @@ test("duplicate compare after diagnostics still completes with server narration"
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.match(result.answerText, /已经记下|请继续说下一件/);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "empty_stream");
assert.equal(result.answerText, "");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.equal(
emitted.filter((event) => event.type === "tool.activity"
&& (event as { tool?: string; status?: string }).tool === "rectification-compare-candidates"
&& (event as { tool?: string; status?: string }).status === "started").length,
1,
);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("an unclaimed V10 attempt never starts the model", async () => {
@@ -1408,9 +1408,9 @@ test("Chinese interview planning stays on reasoning-delta; the spoken answer is
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
});
test("timeout after a stamped open_question still completes without pasting the lock", async () => {
test("timeout after a stamped open_question fails closed without pasting the lock", async () => {
const prompt = "2016 年前后 · 高考或重要考试发挥明显失常、压力很大";
const { options, emitted } = runOptions({
const { options, emitted, billing } = runOptions({
attemptTimeoutMs: 40,
buildAgent: async () => ({
stream: async (
@@ -1443,19 +1443,16 @@ test("timeout after a stamped open_question still completes without pasting the
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "接下来看下面这一问。");
assert.doesNotMatch(result.answerText, /有没有/);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "接下来看下面这一问。" }],
);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "run_timeout");
assert.equal(result.answerText, "");
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.deepEqual(emitted.filter((event) => event.type === "answer.delta"), []);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("open_question acknowledgements stream live instead of waiting for flush", async () => {
test("open_question text deltas remain the model answer instead of being rewritten", async () => {
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
@@ -1479,28 +1476,21 @@ test("open_question acknowledgements stream live instead of waiting for flush",
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "好的,毕业这条也记下了。");
assert.equal(result.answerText, "好的,毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?");
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "毕业这条也记下了。" },
{ type: "answer.delta", text: "\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?" },
],
);
assert.doesNotMatch(JSON.stringify(emitted.filter((event) => event.type === "answer.delta")), /入学考试/);
assert.match(JSON.stringify(emitted.filter((event) => event.type === "answer.delta")), /入学考试/);
});
test("persisted choice prompt replaces a competing model follow-up without a topic denylist", async () => {
test("persisted choice prompt does not rewrite a competing model follow-up", async () => {
const spoken = "好的,2020 年 6 月毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试,你当时发挥明显失常、或者压力特别大,有没有发生过?";
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
assert.equal(
bindSpokenToOpenQuestion(spoken, prompt),
"好的,2020 年 6 月毕业这条也记下了。",
);
assert.equal(openQuestionPromptFromToolResult({
type: "tool-result",
payload: { toolName: "rectification-record-evidence-batch", result: { open_question: { prompt } } },
}), prompt);
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
@@ -1520,19 +1510,17 @@ test("persisted choice prompt replaces a competing model follow-up without a top
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "好的,2020 年 6 月毕业这条也记下了。");
assert.doesNotMatch(result.answerText, /入学考试/);
assert.equal(result.answerText, spoken);
assert.match(result.answerText, /入学考试/);
assert.doesNotMatch(result.answerText, /有没有明显入职/);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: result.answerText }],
[{ type: "answer.delta", text: spoken }],
);
});
test("persisted choice card owns a matching year-locked follow-up", async () => {
test("persisted choice card does not rewrite a matching year-locked follow-up", async () => {
const spoken = "好,实习和离职都记下了。\n\n2023 年前后,你有没有入职或者职责明显加重过?";
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
assert.equal(bindSpokenToOpenQuestion(spoken, prompt), "好,实习和离职都记下了。");
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
@@ -1544,7 +1532,10 @@ test("persisted choice card owns a matching year-locked follow-up", async () =>
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
result: {
accepted_count: 1,
open_question: { prompt: "2023 年前后 · 入职、升职或职责明显加重" },
},
}),
chunk("text-delta", { text: spoken }),
chunk("finish"),
@@ -1552,57 +1543,16 @@ test("persisted choice card owns a matching year-locked follow-up", async () =>
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "好,实习和离职都记下了。");
assert.doesNotMatch(result.answerText, /你有没有入职或者职责明显加重过/);
assert.equal(result.answerText, spoken);
assert.match(result.answerText, /你有没有入职或者职责明显加重过/);
assert.doesNotMatch(result.answerText, /有没有明显入职、升职或职责明显加重/);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: result.answerText }],
[{ type: "answer.delta", text: spoken }],
);
});
test("structured-choice acknowledgements are not kept as a second assistant reply", () => {
const prompt = "2012 年 11 月前后,有没有入职、升职或职责明显加重?";
assert.equal(
bindSpokenToOpenQuestion("已记下你刚才的选择,候选比较也随之更新了。", prompt),
"",
);
assert.equal(
bindSpokenToOpenQuestion("已记录你的选择,并更新了候选比较。接下来这一问和家里有关,请看下方选项。", prompt),
"",
);
assert.equal(bindSpokenToOpenQuestion("记下了。", prompt), "记下了。");
});
test("choice-card acknowledgements do not repeat a different-domain event", () => {
const intern = "实习和离职的时间点都记下了,谢谢。";
assert.equal(
bindSpokenToOpenQuestion(intern, "2005 年 5 月前后,有没有搬家、离乡或长期异地?"),
"",
);
assert.equal(
bindSpokenToOpenQuestion(intern, "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?"),
"",
);
assert.equal(
bindSpokenToOpenQuestion("好,实习和离职都记下了。", "2023 年前后 · 入职、升职或职责明显加重"),
"好,实习和离职都记下了。",
);
});
test("lock-only prompt is not spliced into speech", () => {
const lock = "2023 年前后 · 入职、升职或职责明显加重";
assert.equal(bindSpokenToOpenQuestion("", lock), "");
assert.equal(bindSpokenToOpenQuestion("记下了。", lock), "记下了。");
});
test("full-sentence lock is rendered only by the persisted choice card", () => {
const spoken = "好的。\n\n2016 年高考发挥失常过吗?";
const prompt = "2023 年前后,有没有明显入职、升职或职责明显加重?";
assert.equal(bindSpokenToOpenQuestion(spoken, prompt), "好的。");
});
test("model terminal text-delta is the reply even when Case narration could be composed", async () => {
test("model terminal text-delta is the reply even when Case evidence is available", async () => {
const spoken = "职业已经记下。你入职大概是哪一年?说个年份就行。";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([