fix(rectification): ask spoken collect questions in the agent body
Independent Staging Quality Gate / validate (push) Successful in 9m32s
Independent Staging Quality Gate / publish (push) Successful in 6m53s

The standalone collect prompt bar reused the choice-card chrome and
duplicated the question. Visibility now comes from the spoken reply,
with an empty-body fallback that posts the persisted focus prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-30 09:39:47 +08:00
parent 02d4c91626
commit ea0fbd4406
10 changed files with 226 additions and 108 deletions
@@ -10,7 +10,12 @@ import {
transitionV9CaseStatus,
} from "@/lib/rectification-agentic/v9/tool-service";
import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import { applyRectificationChoice, applyCollectFocusDenial, persistNextInterviewIfIdle } from "@/lib/rectification-agentic/v9/answer-choice";
import {
applyRectificationChoice,
applyCollectFocusDenial,
persistNextInterviewIfIdle,
persistEmptyCollectSpokenAssistant,
} from "@/lib/rectification-agentic/v9/answer-choice";
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run";
@@ -674,6 +679,22 @@ export async function POST(request: Request) {
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
if (!result.answerText.trim()) {
try {
const fallback = await persistEmptyCollectSpokenAssistant({
accounting: accounting as never,
userId,
caseId,
requestId: crypto.randomUUID(),
answerText: result.answerText,
});
if (fallback) send({ type: "answer.delta", text: fallback });
} catch (error) {
console.warn(
`[rectification-v9] empty collect spoken fallback failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
}
}
send({ type: "done", emitted: true });
}
-7
View File
@@ -2902,13 +2902,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-spoken-collect-prompt {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-sm);
font-weight: 600;
line-height: 1.55;
}
.rectification-candidates-heading { display: grid; gap: 5px; }
.rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
.rectification-candidates-heading span,
@@ -59,10 +59,8 @@ import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agen
import {
isPersistedFocusId,
parseRectificationChoiceCard,
parseRectificationSpokenCollect,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
type RectificationSpokenCollect,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
@@ -288,7 +286,6 @@ 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 [spokenCollect, setSpokenCollect] = useState<RectificationSpokenCollect | null>(null);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -391,18 +388,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const applyCaseSnapshot = useCallback((payload: {
latest_result?: unknown;
choice_card?: unknown;
current_question?: unknown;
case?: { accepted_time?: unknown; confirmed_time?: unknown };
} | null) => {
if (!payload) return;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
const nextSpoken = parseRectificationSpokenCollect(payload.current_question);
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);
setChoiceCard(nextChoice);
setSpokenCollect(nextSpoken);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1053,14 +1047,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
choiceCardsOpen.current = showLiveChoiceCard;
updateFollowState();
}, [showLiveChoiceCard, updateFollowState]);
const showLiveSpokenCollect = Boolean(
!choiceCard
&& spokenCollect
&& liveChoiceHost
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& offeredSelectionOnce
@@ -1075,9 +1061,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const liveSpokenMessageKey = showLiveSpokenCollect && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
@@ -1145,9 +1128,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
? choiceCard
: null;
const liveSpoken = showLiveSpokenCollect && message.renderKey === liveSpokenMessageKey
? spokenCollect
: null;
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
@@ -1200,11 +1180,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{liveSpoken && (
<section className="rectification-choice-card" aria-label="口述采集题">
<p className="rectification-spoken-collect-prompt">{liveSpoken.prompt}</p>
</section>
)}
</div>
);
})}
@@ -54,6 +54,7 @@ import {
openQuestionPromptFromToolResult,
publicNarrationDtoFromDossier,
} from "./turn-narration";
import { emptyAnswerCollectSpokenFallback, projectCurrentQuestion } from "./turn-decision";
import {
defaultMessageOrigin,
isRectificationMessageOrigin,
@@ -916,7 +917,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (!answerText.trim()) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
const collect = emptyAnswerCollectSpokenFallback(
"",
projectCurrentQuestion(latest.conversationSummary.activeFocus),
);
const narration = collect
?? composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
if (narration.trim()) {
answerText = narration;
answerDeltas.push(narration);
@@ -47,6 +47,7 @@ import {
spokenFollowupForUser,
} from "./method-followup";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { emptyAnswerCollectSpokenFallback, projectCurrentQuestion } from "./turn-decision";
export type ApplyChoiceCommand = Readonly<{
userId: string;
@@ -499,6 +500,28 @@ export async function persistNextInterviewIfIdle(input: {
};
}
export async function persistEmptyCollectSpokenAssistant(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
answerText: string;
}): Promise<string | null> {
if (input.answerText.trim()) return null;
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const prompt = emptyAnswerCollectSpokenFallback(
input.answerText,
projectCurrentQuestion(dossier.conversationSummary.activeFocus),
);
if (!prompt) return null;
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: input.requestId,
userMessage: null,
assistantMessage: prompt,
});
return prompt;
}
async function persistApplied(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -516,30 +516,6 @@ export function choiceCardUserMessage(
return card.scoring ? line : `${HOLDOUT_MESSAGE_PREFIX}${line}`;
}
export type RectificationSpokenCollect = Readonly<{
kind: "collect_spoken";
question_id: string | null;
prompt: string;
domain: string | null;
}>;
export function parseRectificationSpokenCollect(value: unknown): RectificationSpokenCollect | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (row.kind !== "collect_spoken") return null;
if (row.choice_mode === CHOICE_MODE || Array.isArray(row.options) || row.choice) return null;
const prompt = clippedProbeLabel(row.prompt, 2, 400);
if (!prompt) return null;
return {
kind: "collect_spoken",
question_id: typeof row.question_id === "string" && row.question_id.trim()
? row.question_id.trim()
: null,
prompt,
domain: typeof row.domain === "string" && row.domain.trim() ? row.domain.trim() : null,
};
}
export function parseRectificationChoiceCard(value: unknown): RectificationChoiceCard | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
@@ -67,6 +67,16 @@ 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 projectCurrentQuestion(
focus: {
id?: string;
+1 -1
View File
@@ -63,7 +63,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
1. rectification-read-case
2. display_date_label
3. rectification-record-evidence-batch
4. current_question.kind=choice / 使kind=collect_spoken
4. current_question.kind=choice / 使current_question.kind=collect_spoken prompt
5. confirmation_allowed false 5 skill_verification_report80%/60%
6. Skill `;