fix(rectification): drop duplicate collect cards and false run_failed
Spoken collect no longer renders a second visual prompt; choice legends stay screen-reader only and live cards share the assistant inset. Exhaustion collect avoids colliding with the opening question id, and a successful billed turn no longer surfaces run_failed after the exit gate. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -716,12 +716,17 @@ export async function POST(request: Request) {
|
||||
} else {
|
||||
// Shared gate owns persistNextInterviewIfIdle then ensureNonTerminalTurnExit;
|
||||
// The shared gate replaces the old message/opening-only cleanup.
|
||||
await finalizeSuccessfulTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
action,
|
||||
});
|
||||
try {
|
||||
await finalizeSuccessfulTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
action,
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error instanceof RectificationToolServiceError ? error.code : "run_failed";
|
||||
console.warn(`[rectification-v9] turn exit after success case=${caseId} code=${code}`);
|
||||
}
|
||||
send({ type: "done", emitted: true });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -2945,22 +2945,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.rectification-question-slot {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
width: calc(100% - var(--assistant-content-inset));
|
||||
margin-block: var(--space-3);
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
}
|
||||
.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);
|
||||
@@ -2993,6 +2981,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.rectification-choice-card .birth-time-choice-question legend.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.rectification-choice-card .birth-time-choice-question:disabled .birth-time-choice-option {
|
||||
cursor: default;
|
||||
opacity: .48;
|
||||
|
||||
@@ -325,7 +325,6 @@ 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)`);
|
||||
@@ -1230,14 +1229,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
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">
|
||||
当前没有可回答的问题,正在等待服务端更新。
|
||||
@@ -1287,7 +1278,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
<Textarea
|
||||
ref={composer}
|
||||
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||||
aria-describedby={collectSpokenPrompt ? questionHintId : undefined}
|
||||
value={draft}
|
||||
disabled={!canSend}
|
||||
placeholder={readonly
|
||||
|
||||
@@ -48,7 +48,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
disabled={props.pending || props.disabled || answered}
|
||||
data-answered={answered ? "true" : "false"}
|
||||
>
|
||||
<legend>{props.card.prompt}</legend>
|
||||
<legend className="sr-only">{props.card.prompt}</legend>
|
||||
{props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
|
||||
<div className="birth-time-primary-choices">
|
||||
{props.card.options.map((option, index) => (
|
||||
|
||||
@@ -942,6 +942,8 @@ const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
|
||||
education: "有没有记得住年份的升学、转学或考试?",
|
||||
relocation: "有没有记得住时间的搬家或长期住到外地?",
|
||||
finance: "有没有记得住时间的收入变化、大笔支出或欠债?",
|
||||
health_pressure: "有没有记得住时间的生病、受伤或特别大的压力?",
|
||||
other: "可以再说一件记得大概时间的经历。",
|
||||
};
|
||||
|
||||
export const GENERIC_COLLECT_QUESTION = "请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。";
|
||||
@@ -957,35 +959,74 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string |
|
||||
return period ? `${period},${base}` : base;
|
||||
}
|
||||
|
||||
const EXHAUSTION_COLLECT_ORDER = ["family", "education", "finance"] as const;
|
||||
const EXHAUSTION_OOS_COLLECT_ORDER = ["family", "education", "finance"] as const;
|
||||
const EXHAUSTION_REMAINING_COLLECT_ORDER = [
|
||||
"health_pressure",
|
||||
"relocation",
|
||||
"career",
|
||||
"relationship",
|
||||
] as const;
|
||||
|
||||
function datedCollectFollowup(
|
||||
domain: keyof typeof YEARLESS_COLLECT_LEAD,
|
||||
evidence: readonly MethodFollowupEvidence[],
|
||||
): MethodFollowup | null {
|
||||
const lead = YEARLESS_COLLECT_LEAD[domain];
|
||||
if (!lead) return null;
|
||||
return {
|
||||
method_id: PROBE_METHOD_ID[domain],
|
||||
intent: "collect_method_evidence",
|
||||
ask_theme: REVERSE_VERIFY_THEME[domain],
|
||||
domain,
|
||||
kind_hint: REVERSE_VERIFY_KIND[domain],
|
||||
user_prompt_hint: collectHint(lead, REVERSE_VERIFY_VARGA[domain], "", evidence),
|
||||
must_not_label: false,
|
||||
choice_frame: null,
|
||||
source: "oos_blind",
|
||||
};
|
||||
}
|
||||
|
||||
export function exhaustionSpokenCollectFollowup(input: {
|
||||
evidence: readonly MethodFollowupEvidence[];
|
||||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||||
}): MethodFollowup | null {
|
||||
const declined = declinedDomains(input.declinedTopics ?? []);
|
||||
for (const domain of EXHAUSTION_COLLECT_ORDER) {
|
||||
if (declined.has(domain)) continue;
|
||||
if (hasConfirmedDomain(input.evidence, domain)) continue;
|
||||
const lead = YEARLESS_COLLECT_LEAD[domain];
|
||||
if (!lead) continue;
|
||||
for (const domain of EXHAUSTION_OOS_COLLECT_ORDER) {
|
||||
if (declined.has(domain) || hasConfirmedDomain(input.evidence, domain)) continue;
|
||||
const next = datedCollectFollowup(domain, input.evidence);
|
||||
if (next) return next;
|
||||
}
|
||||
if (
|
||||
!declined.has("occupation")
|
||||
&& !hasConfirmedDomain(input.evidence, "occupation")
|
||||
&& !occupationCollectFocusClosed(input.declinedTopics ?? [])
|
||||
) {
|
||||
return {
|
||||
method_id: PROBE_METHOD_ID[domain],
|
||||
method_id: "occupation",
|
||||
intent: "collect_method_evidence",
|
||||
ask_theme: REVERSE_VERIFY_THEME[domain],
|
||||
domain,
|
||||
kind_hint: REVERSE_VERIFY_KIND[domain],
|
||||
user_prompt_hint: collectHint(lead, REVERSE_VERIFY_VARGA[domain], "", input.evidence),
|
||||
ask_theme: "occupation",
|
||||
domain: "occupation",
|
||||
kind_hint: "occupation_note",
|
||||
user_prompt_hint: collectHint("你长期做什么工作?", "D1-H10 + D10", "", input.evidence),
|
||||
must_not_label: false,
|
||||
choice_frame: null,
|
||||
source: "oos_blind",
|
||||
source: "method_coverage",
|
||||
};
|
||||
}
|
||||
for (const domain of EXHAUSTION_REMAINING_COLLECT_ORDER) {
|
||||
if (domain === "health_pressure") {
|
||||
if (declinedHealth(declined) || hasConfirmedHealth(input.evidence)) continue;
|
||||
} else if (declined.has(domain) || hasConfirmedDomain(input.evidence, domain)) {
|
||||
continue;
|
||||
}
|
||||
const next = datedCollectFollowup(domain, input.evidence);
|
||||
if (next) return next;
|
||||
}
|
||||
return {
|
||||
method_id: "dasha_events",
|
||||
intent: "collect_method_evidence",
|
||||
ask_theme: "dated_event",
|
||||
domain: null,
|
||||
domain: "other",
|
||||
kind_hint: null,
|
||||
user_prompt_hint: collectHint(
|
||||
"可以再说一件记得大概时间的经历。",
|
||||
|
||||
@@ -172,6 +172,33 @@ export function isRenderableChoiceOpenQuestion(
|
||||
}
|
||||
|
||||
export const COLLECT_FOCUS_SCHEMA_KEY = "collect";
|
||||
export const COLLECT_FOCUS_RETRY_SUFFIX = "next";
|
||||
|
||||
const PERSISTABLE_FOCUS_DOMAINS = new Set([
|
||||
"education",
|
||||
"career",
|
||||
"relationship",
|
||||
"relocation",
|
||||
"finance",
|
||||
"health",
|
||||
"family",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export function persistableFocusDomain(domain: string | null | undefined): string | null {
|
||||
if (!domain || domain === "unknown" || domain === "active_focus") return null;
|
||||
if (domain === "health_pressure") return "health";
|
||||
if (domain === "occupation") return "other";
|
||||
if (PERSISTABLE_FOCUS_DOMAINS.has(domain)) return domain;
|
||||
return domain;
|
||||
}
|
||||
|
||||
function isFocusIdempotencyConflict(error: unknown): boolean {
|
||||
const code = error instanceof RectificationToolServiceError
|
||||
? error.code
|
||||
: safeToolErrorCode(error);
|
||||
return code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict");
|
||||
}
|
||||
|
||||
function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
|
||||
const prompt = spokenFollowupForUser({ ...followup, choice_frame: null });
|
||||
@@ -215,12 +242,12 @@ async function persistCollectFocus(input: {
|
||||
) {
|
||||
return { status: "already_open", focus: active, questionId: active.questionId, prompt };
|
||||
}
|
||||
try {
|
||||
const insertFocus = async (id: string): Promise<PersistServerFocusResult> => {
|
||||
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
||||
questionId,
|
||||
questionId: id,
|
||||
intent: input.followup.intent,
|
||||
targetEvidenceId: null,
|
||||
targetDomain: input.followup.domain,
|
||||
targetDomain: persistableFocusDomain(input.followup.domain),
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: schema,
|
||||
});
|
||||
@@ -230,11 +257,20 @@ async function persistCollectFocus(input: {
|
||||
questionId: result.focus.questionId,
|
||||
prompt,
|
||||
};
|
||||
};
|
||||
try {
|
||||
return await insertFocus(questionId);
|
||||
} catch (error) {
|
||||
const code = error instanceof RectificationToolServiceError
|
||||
? error.code
|
||||
: safeToolErrorCode(error);
|
||||
if (code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict")) {
|
||||
if (!isFocusIdempotencyConflict(error)) {
|
||||
return {
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
const retryId = `${questionId}:${COLLECT_FOCUS_RETRY_SUFFIX}`.slice(0, 160);
|
||||
if (retryId === questionId) {
|
||||
return {
|
||||
status: "duplicate_focus",
|
||||
focus: input.activeFocus,
|
||||
@@ -242,12 +278,24 @@ async function persistCollectFocus(input: {
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
try {
|
||||
return await insertFocus(retryId);
|
||||
} catch (retryError) {
|
||||
if (isFocusIdempotencyConflict(retryError)) {
|
||||
return {
|
||||
status: "duplicate_focus",
|
||||
focus: input.activeFocus,
|
||||
questionId: retryId,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user