fix(rectification): pin the adopt card and drop casual adopt copy
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user