fix(rectification): pin the adopt card and drop casual adopt copy
Independent Staging Quality Gate / validate (push) Successful in 11m34s
Independent Staging Quality Gate / publish (push) Successful in 1m56s

The time card stayed under an earlier collect question, and the close said to just use a time for now. Keep the card on the delivery turn and present the analysis as this round's result.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-05 02:03:19 +08:00
co-authored by Cursor
parent 7c776bdd3a
commit 329f8a2109
22 changed files with 402 additions and 52 deletions
@@ -95,7 +95,15 @@ import {
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question";
import {
applyLiveCandidateOffer,
copyTextForMessage,
interviewQuestionBlocksAdoptOffer,
parseTurnQuestion,
persistedOfferFromTurn,
questionIsAnswered,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
type PersistedTurn = Readonly<{
@@ -264,9 +272,11 @@ function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[])
return {
...message,
question: question ?? undefined,
candidateOffer: next?.offerResultId
? { resultId: next.offerResultId }
: message.candidateOffer,
candidateOffer: persistedOfferFromTurn(
next?.offerResultId,
message.candidateOffer,
true,
),
};
});
}
@@ -609,25 +619,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!canOffer && !adopted) return;
const resultId = candidateResult.resultId;
queueMicrotask(() => {
setMessages((current) => {
if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current;
if (adopted && current.some((message) => message.candidateOffer)) {
return current.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message
));
}
if (!canOffer || adopted) return current;
const owner = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.failed
));
if (!owner) return current;
return current.map((message) => (
message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message
));
});
setMessages((current) => applyLiveCandidateOffer(current, {
resultId,
canOffer,
adopted,
}));
});
}, [candidateResult]);
@@ -1375,13 +1371,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& regeneratingMessageKey === null,
);
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey;
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const selectionCardMessageKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
const showSelectionCards = Boolean(
candidateResult
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime)),
&& (canOfferCards || Boolean(candidateResult.selectedTime))
&& !messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime))
)),
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
@@ -71,7 +71,7 @@ export const RECTIFICATION_USER_COPY = {
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "可以从下面选一个先用着。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
@@ -170,6 +170,12 @@ export function nonConvergingRangeNarration(
}
if (rangeText && representative) {
const collapsed = Boolean(range && range[0] === range[1]);
if (collapsed) {
const head = progress ?? `眼下更站得住的是 ${representative}`;
const body = `${head}${boundaryCopy}`;
return prefix ? `${prefix}${body}` : body;
}
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
const representativeClause = progress ? `。代表分钟是 ${representative}` : ``;
const body = `${head}${representativeClause}${boundaryCopy}`;
@@ -189,7 +195,9 @@ export function nonConvergingRangeNarration(
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`;
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
}
const INSTRUCTION_TONE_SENTENCE = /不得|请写成/;
@@ -23,7 +23,7 @@ export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
不要出现「确认」「精确」这两个词,不得再提问,不要写「我按你说的经历认真分析过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`;
export type AdoptNarrationOutcome =
| "agent"
@@ -113,13 +113,13 @@ function stopFactsFromDropped(
if (representative && runnerUp && representative !== runnerUp) {
facts.push({
kind: "indistinguishable",
label: `剩下的题分不开 ${representative}${runnerUp}`,
label: `再问下去也分不开 ${representative}${runnerUp}`,
count: Math.max(1, noSplit),
});
} else if (noSplit > 0) {
facts.push({
kind: "indistinguishable",
label: "剩下的题分不开当前候选",
label: "再问下去也分不出更准的时间了",
count: noSplit,
});
}
@@ -224,10 +224,11 @@ export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
if (split && facts.representative_minute && facts.runner_up_minute) {
return `剩下的问题分不开 ${facts.representative_minute}${facts.runner_up_minute}`;
if (split && facts.representative_minute && facts.runner_up_minute
&& facts.representative_minute !== facts.runner_up_minute) {
return `再问下去也分不开 ${facts.representative_minute}${facts.runner_up_minute}`;
}
if (split) return `${split.label}`;
if (split) return `${split.label.replace(/。?$/, "")}`;
return null;
}
@@ -11,6 +11,9 @@ export const MACHINE_VOICE_LEXICON = [
"当前区分题",
"接下来请点选下面这一问",
"可以从下面的时间里选一个采用",
"可以从下面选一个先用着",
"下面的时间可以先用着",
"剩下的题分不开当前候选",
"我没能确定这句话是否在回答当前问题",
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
"有没有记得住时间的收入变化、大笔支出或欠债",
@@ -120,6 +120,89 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
});
}
export function interviewQuestionBlocksAdoptOffer(
question: TurnQuestion | null | undefined,
adopted: boolean,
): boolean {
if (adopted) return false;
if (!question || questionIsAnswered(question)) return false;
return question.kind === "collect_spoken" || question.kind === "choice";
}
export type CandidateOfferAnchor = {
renderKey: string;
role: string;
state?: string;
text?: string | null;
failed?: boolean;
question?: TurnQuestion | null;
candidateOffer?: Readonly<{ resultId: string }>;
};
function isSettledAssistant(message: CandidateOfferAnchor): boolean {
if (message.role !== "assistant" || message.failed) return false;
if (message.state && message.state !== "settled") return false;
return Boolean(message.text);
}
export function applyLiveCandidateOffer<T extends CandidateOfferAnchor>(
messages: readonly T[],
input: { resultId: string; canOffer: boolean; adopted: boolean },
): T[] {
if (input.adopted) {
if (!messages.some((message) => message.candidateOffer)) return [...messages];
return messages.map((message) => (
message.candidateOffer
? { ...message, candidateOffer: { resultId: input.resultId } }
: message
));
}
if (!input.canOffer) return [...messages];
const liveInterview = messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, false)
));
if (liveInterview) {
return messages.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
));
}
const owner = [...messages].reverse().find((message) => (
isSettledAssistant(message)
&& !interviewQuestionBlocksAdoptOffer(message.question, false)
));
if (!owner) {
return messages.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
));
}
return messages.map((message) => ({
...message,
candidateOffer: message.renderKey === owner.renderKey
? { resultId: input.resultId }
: undefined,
}));
}
export function persistedOfferFromTurn(
offerResultId: string | null | undefined,
previous: Readonly<{ resultId: string }> | undefined,
turnHydrated: boolean,
): Readonly<{ resultId: string }> | undefined {
if (typeof offerResultId === "string" && offerResultId.length > 0) {
return { resultId: offerResultId };
}
if (turnHydrated) return undefined;
return previous;
}
function assistantCanOwnAdoptOffer(
turn: { role?: string; question?: TurnQuestion | null },
adopted: boolean,
): boolean {
return turn.role === "assistant"
&& !interviewQuestionBlocksAdoptOffer(turn.question, adopted);
}
export function attachOfferResultToTurns<T extends {
id: string;
role: string;
@@ -137,20 +220,21 @@ export function attachOfferResultToTurns<T extends {
if (!offered || !resultId) {
return turns.map((turn) => ({ ...turn, offer_result_id: null }));
}
const adopted = Boolean(input.acceptedTime);
const firstVerify = turns.findIndex((turn) => (
turn.role === "assistant" && turn.question?.kind === "reverse_verify"
));
const searchUntil = firstVerify >= 0 ? firstVerify : turns.length;
let ownerIndex = -1;
for (let index = searchUntil - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) {
ownerIndex = index;
break;
}
}
if (ownerIndex < 0) {
for (let index = turns.length - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) {
ownerIndex = index;
break;
}