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:
+33
-1
@@ -7061,7 +7061,7 @@
|
||||
- 首次发现:2026-09-01
|
||||
- 最近更新:2026-09-01
|
||||
- 影响面:POST `/api/rectification/agent` 的 `opening`、`message`、`answer_choice`、`stop_and_review` 推进轮,`decideRectification` 的 probe 耗尽转向,以及服务端问题在 turn 历史中的可见性
|
||||
- 用户现象:真实 Case `645ba774-67fb-4d6f-b842-f59e4d97adc0` 已有 7 条证据、3 个领域并答完 6 道区分题后,仍保持正确的 `can_adopt=false`,但 `current_question=null`,界面永久显示等待服务端更新。
|
||||
- 用户现象:真实校正会话已有多条证据、多个领域并答完多道区分题后,仍保持正确的 `can_adopt=false`,但 `current_question=null`,界面永久显示等待服务端更新。
|
||||
- 触发条件:最后一步走 `answer_choice`,或 `message` 命中任一 HTTP 200 确定性早退;同时普通 discriminator 已耗尽、候选分离不足,原决策链直接返回区间而未继续尝试 holdout、带日期收集或 Nakshatra 边界题。
|
||||
- 根因:BUG-456 只在当时命中的 opening/message 分支附近补了兜底调用,没有建立所有推进轮共享的后置条件;后来新增或既有的 structured choice 与多个消息早退仍可绕过。选择题历史另以「接下来请点选下面这一问。」代替服务端 focus prompt,导致回看时看不到真实问题。
|
||||
- 修复:所有成功的推进轮统一经过 awaited 公共出口闸;immediate 路径必须等 focus 持久化完成后才返回 HTTP 200,stream 路径必须在 `done` 前完成。`read_only` 显式无副作用。闸门最终重读 Case 并验证 `current_question || canAdopt || 已终态`,无法建立后置条件时 fail-closed。probe 耗尽按普通 discriminator → holdout → dated collect → Nakshatra 边界题 → 明确区间出口转向,所有路径继续使用既有 delivery capability,事故 Case 的 `canAdopt=false` 不变。服务端 focus prompt 直接写入 turn 历史,不让模型复述。
|
||||
@@ -7070,3 +7070,35 @@
|
||||
- 相关记录:BUG-459
|
||||
- 复发自:BUG-456
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-461 | 口述采集卡重复助手正文,选择题卡题干与气泡不对齐
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-01
|
||||
- 最近更新:2026-09-01
|
||||
- 影响面:`rectification-agentic-chat` 当前问题槽、`RectificationChoiceCard` legend、`.rectification-question-slot` 对齐
|
||||
- 用户现象:口述采集时卡片把助手已经说过的题干再显示一遍,并多出灰色提示「请在下方输入框回答…」。选择题卡在未点选时与助手气泡左缘错位,点选后回看才对齐。
|
||||
- 触发条件:当前问题是 `collect_spoken`;或当前问题是选择题且卡片仍在问题槽、尚未变成消息附件。
|
||||
- 根因:BUG-449/460 已把服务端题干写入助手正文,BUG-441/452 也禁止为无选项问题新造视觉容器,但后续测试把口述采集卡和可见 legend 锁成了必有。问题槽是消息列表的兄弟节点,没有 `--assistant-content-inset`;已回答卡片在 `.rectification-message-wrap` 内,所以只有回看才对齐。
|
||||
- 修复:口述采集不再渲染第二块视觉卡,题干只留在正文,`collectSpokenPrompt` 只驱动输入框占位。选择题 legend 改为 `sr-only`。问题槽与助手内容使用同一 inset,且不给槽内选择题再套一层 inset。
|
||||
- 验证:`rectification-spoken-collect`、`rectification-agentic-entry`、`rectification-varga-style-copy` 改为锁新行为;口述采集不得再出现 `__spoken` / `__prompt` / `aria-describedby`。未做登录后的真实页面点选。
|
||||
- 防复发:不得为 `collect_spoken` 新造视觉容器或复用选择卡样式;选择题可见题干不得与助手正文重复;问题槽与已回答卡片必须共用 `--assistant-content-inset`。不得用正文文本判断助手有没有问出来。
|
||||
- 相关记录:BUG-441、BUG-449、BUG-452、BUG-460
|
||||
- 复发自:BUG-441
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-462 | 区间出口采集撞上开场题号后,成功计费轮被误报 run_failed
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-01
|
||||
- 最近更新:2026-09-01
|
||||
- 影响面:`exhaustionSpokenCollectFollowup`、`persistCollectFocus`、POST `/api/rectification/agent` 流式成功出口
|
||||
- 用户现象:助手已经记下带年份事件并完成计费后,界面同时出现「回答未完成,已保留现有内容;本次不会扣点。」和「生时校正暂时不可用,请稍后再试。」
|
||||
- 触发条件:非收敛区间出口后需要再问口述采集,且开场通用采集题号 `collect:unknown:collect_method_evidence` 已作为历史行存在;或职业/健康压力等域不在 SQL `target_domain` 白名单里。
|
||||
- 根因:耗尽采集只轮转家庭/升学/财务后就退回 `domain: null`,题号与开场题相同。`unique (case_id, question_id)` 含已结束行,落库报 `focus_idempotency_conflict`。出口闸因此认为没有下一步并抛 `agentic_rectification_nonterminal_exit_missing`。该异常发生在 `billing.settled` 与 `run.completed` 之后,被映射成 `run_failed`;前端只要看到 `error` 就把本轮标成失败并提示不扣点。
|
||||
- 修复:耗尽采集在家庭/升学/财务之后问职业,再问健康压力/搬家/事业/感情,最后用 `domain: "other"`。职业落库映射为 `other`,健康压力映射为 `health`。同一题号冲突时改写 `questionId:next`。流式 `result.ok` 后出口闸失败只记日志,仍发送 `done`,不再发 `run_failed`。
|
||||
- 验证:`rectification-range-offer-deadend` 锁职业与 `other` 回退;`rectification-server-focus` 锁域映射与 `:next` 重试;`rectification-spoken-collect` 锁成功出口不 `send error`;`rectification-collect-stall` 冲突路径改为两次落库。未做登录后的真实页面复现。
|
||||
- 防复发:耗尽回退不得再用 `collect:unknown:*`。SQL 不允许的域必须在落库前映射。流式成功轮在计费完成后不得把出口闸失败发成用户可见的 `run_failed`。出口闸仍须在 `send done` 之前 awaited。不得放宽 `canAdopt`。
|
||||
- 相关记录:BUG-450、BUG-452、BUG-460
|
||||
- 复发自:BUG-460
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -661,9 +661,9 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
|
||||
assert.doesNotMatch(tools, /offer_selection/);
|
||||
});
|
||||
|
||||
test("choice cards render the persisted prompt as the visible question stem", () => {
|
||||
assert.match(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.doesNotMatch(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
|
||||
test("choice cards expose the persisted prompt only to assistive tech", () => {
|
||||
assert.match(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.doesNotMatch(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
|
||||
});
|
||||
|
||||
test("choice card answers and stop share one stacked primary list", () => {
|
||||
|
||||
@@ -864,7 +864,7 @@ test("duplicate collect focus reloads the active question instead of returning n
|
||||
expectedAnswerSchema: { collect: true, prompt: "你长期做什么工作?" },
|
||||
});
|
||||
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
|
||||
assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 1);
|
||||
assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 2);
|
||||
});
|
||||
|
||||
test("skipped collect focus reloads once and retries persistence", async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
@@ -619,3 +620,48 @@ test("idle persist still decides from the dossier once and does not invent colle
|
||||
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
|
||||
assert.match(idle, /persistExhaustionCollect/);
|
||||
});
|
||||
|
||||
function datedCollectEvidence(domain: string, year: string, extra: { eventKind?: string } = {}) {
|
||||
return {
|
||||
status: "confirmed" as const,
|
||||
domain,
|
||||
datePrecision: extra.eventKind ? "unknown" as const : "year" as const,
|
||||
occurredFrom: extra.eventKind ? null : `${year}-01-01`,
|
||||
occurredTo: null,
|
||||
...(extra.eventKind ? { eventKind: extra.eventKind } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
test("exhaustion after family declined and dated domains confirmed asks occupation", () => {
|
||||
const next = exhaustionSpokenCollectFollowup({
|
||||
evidence: [
|
||||
datedCollectEvidence("education", "2016"),
|
||||
datedCollectEvidence("career", "2020"),
|
||||
datedCollectEvidence("relationship", "2018"),
|
||||
datedCollectEvidence("finance", "2024"),
|
||||
],
|
||||
declinedTopics: [{ target_domain: "family", status: "declined" }],
|
||||
});
|
||||
assert.equal(next?.domain, "occupation");
|
||||
assert.equal(next?.choice_frame, null);
|
||||
assert.equal(spokenFollowupForUser(next), "你长期做什么工作?");
|
||||
});
|
||||
|
||||
test("exhaustion generic fallback uses domain other, not unknown", () => {
|
||||
const next = exhaustionSpokenCollectFollowup({
|
||||
evidence: [
|
||||
datedCollectEvidence("education", "2016"),
|
||||
datedCollectEvidence("career", "2020"),
|
||||
datedCollectEvidence("relationship", "2018"),
|
||||
datedCollectEvidence("finance", "2024"),
|
||||
datedCollectEvidence("relocation", "2022"),
|
||||
datedCollectEvidence("health_pressure", "2021"),
|
||||
datedCollectEvidence("occupation", "2020", { eventKind: "occupation_note" }),
|
||||
],
|
||||
declinedTopics: [{ target_domain: "family", status: "declined" }],
|
||||
});
|
||||
assert.equal(next?.domain, "other");
|
||||
assert.equal(next?.intent, "collect_method_evidence");
|
||||
assert.equal(stableFollowupQuestionId(next), "collect:other:collect_method_evidence");
|
||||
assert.equal(spokenFollowupForUser(next), "可以再说一件记得大概时间的经历。");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import test from "node:test";
|
||||
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import {
|
||||
COLLECT_FOCUS_RETRY_SUFFIX,
|
||||
openQuestionFromPersistedFocus,
|
||||
persistableFocusDomain,
|
||||
persistServerOwnedFocus,
|
||||
shouldSkipDiscriminatorFollowup,
|
||||
stableFollowupQuestionId,
|
||||
@@ -599,3 +601,123 @@ test("degraded spoken collect does not keep discriminator identity or block a la
|
||||
assert.ok(open);
|
||||
assert.notEqual(open?.unrenderable, true);
|
||||
});
|
||||
|
||||
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
|
||||
return {
|
||||
method_id: "dasha_events",
|
||||
intent: "collect_method_evidence",
|
||||
ask_theme: "dated_event",
|
||||
domain: "relationship",
|
||||
kind_hint: null,
|
||||
user_prompt_hint: "collect",
|
||||
must_not_label: false,
|
||||
choice_frame: null,
|
||||
source: "method_coverage",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function focusRowFromArgs(args: Record<string, unknown>) {
|
||||
return {
|
||||
id: FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-08-31T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
idempotent: false,
|
||||
};
|
||||
}
|
||||
|
||||
test("collect persist maps occupation to other and health_pressure to health", async () => {
|
||||
assert.equal(persistableFocusDomain("occupation"), "other");
|
||||
assert.equal(persistableFocusDomain("health_pressure"), "health");
|
||||
assert.equal(persistableFocusDomain("education"), "education");
|
||||
assert.equal(persistableFocusDomain("horary"), "horary");
|
||||
assert.equal(persistableFocusDomain(null), null);
|
||||
assert.equal(persistableFocusDomain("unknown"), null);
|
||||
|
||||
const occupation = collectFollowup({
|
||||
method_id: "occupation",
|
||||
ask_theme: "occupation",
|
||||
domain: "occupation",
|
||||
kind_hint: "occupation_note",
|
||||
});
|
||||
assert.equal(stableFollowupQuestionId(occupation), "collect:occupation:collect_method_evidence");
|
||||
const health = collectFollowup({
|
||||
method_id: "d30_health",
|
||||
ask_theme: "health_pressure",
|
||||
domain: "health_pressure",
|
||||
});
|
||||
const generic = collectFollowup({ domain: "other" });
|
||||
assert.equal(stableFollowupQuestionId(generic), "collect:other:collect_method_evidence");
|
||||
assert.doesNotMatch(stableFollowupQuestionId(generic), /unknown/);
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args),
|
||||
});
|
||||
const occupationPersisted = await persistServerOwnedFocus({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
activeFocus: null,
|
||||
decisionReceipt: null,
|
||||
followup: occupation,
|
||||
});
|
||||
const healthPersisted = await persistServerOwnedFocus({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
activeFocus: null,
|
||||
decisionReceipt: null,
|
||||
followup: health,
|
||||
});
|
||||
const occupationWrite = accounting.calls.find((item) => (
|
||||
item.fn === "set_agentic_rectification_conversation_focus"
|
||||
&& String(item.args.p_question_id).startsWith("collect:occupation:")
|
||||
));
|
||||
const healthWrite = accounting.calls.find((item) => (
|
||||
item.fn === "set_agentic_rectification_conversation_focus"
|
||||
&& String(item.args.p_question_id).startsWith("collect:health_pressure:")
|
||||
));
|
||||
assert.equal(occupationPersisted.status, "created");
|
||||
assert.equal(healthPersisted.status, "created");
|
||||
assert.equal(occupationWrite?.args.p_target_domain, "other");
|
||||
assert.equal(healthWrite?.args.p_target_domain, "health");
|
||||
});
|
||||
|
||||
test("collect focus unique conflict retries with a :next question id", async () => {
|
||||
const followup = collectFollowup();
|
||||
let writes = 0;
|
||||
const accounting = fakeAccounting({
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
writes += 1;
|
||||
if (writes === 1) throw new Error("agentic_rectification_focus_idempotency_conflict");
|
||||
return focusRowFromArgs(args);
|
||||
},
|
||||
});
|
||||
const persisted = await persistServerOwnedFocus({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
activeFocus: null,
|
||||
decisionReceipt: null,
|
||||
followup,
|
||||
});
|
||||
assert.equal(persisted.status, "created");
|
||||
assert.equal(writes, 2);
|
||||
assert.equal(
|
||||
persisted.questionId,
|
||||
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
|
||||
);
|
||||
const retryWrite = accounting.calls.at(-1);
|
||||
assert.equal(
|
||||
retryWrite?.args.p_question_id,
|
||||
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,14 +102,21 @@ test("cases current_question drives the unified question slot", () => {
|
||||
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
|
||||
});
|
||||
|
||||
test("collect_spoken current_question shows the server prompt and input hint", () => {
|
||||
test("collect_spoken current_question drives the composer, not a second visual card", () => {
|
||||
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");
|
||||
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+\? "请回答上面的问题…"/);
|
||||
assert.match(chat, /<RectificationChoiceCard/);
|
||||
assert.doesNotMatch(chat, /rectification-question-slot__spoken/);
|
||||
assert.doesNotMatch(chat, /rectification-question-slot__prompt/);
|
||||
assert.doesNotMatch(chat, /请在下方输入框回答/);
|
||||
assert.doesNotMatch(chat, /questionHintId/);
|
||||
assert.doesNotMatch(chat, /aria-describedby/);
|
||||
assert.match(styles, /\.rectification-question-slot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
|
||||
assert.match(styles, /\.rectification-question-slot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
|
||||
assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/);
|
||||
});
|
||||
|
||||
test("missing current_question is explicit only for resumable cases", () => {
|
||||
@@ -330,6 +337,9 @@ test("agent route keeps question ownership in the server Case projection", () =>
|
||||
assert.match(turnExit, /persistNextInterviewIfIdle/);
|
||||
assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
|
||||
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
|
||||
const successExit = afterRun.slice(afterRun.indexOf("} else {"), afterRun.indexOf("send({ type: \"done\""));
|
||||
assert.match(successExit, /await finalizeSuccessfulTurnExit/);
|
||||
assert.doesNotMatch(successExit, /send\(\{\s*type:\s*"error"/);
|
||||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus|composeCollectSpokenAssistantText/);
|
||||
});
|
||||
|
||||
@@ -335,16 +335,16 @@ test("remaining D9/D10 packets use userChoice labels and long-term questions", (
|
||||
assert.notEqual(d10!.styleOptions?.find((item) => item.answerClass === "yes")?.label, D10_TYPE_TABLE.巨蟹座.style);
|
||||
});
|
||||
|
||||
test("choice-card legend spacing does not depend on fieldset grid gap", () => {
|
||||
const choiceCss = readFileSync(new URL("../src/app/birth-time-choice.css", import.meta.url), "utf8");
|
||||
test("choice-card legend is screen-reader only so the stem is not duplicated", () => {
|
||||
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const card = readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8");
|
||||
const legendRule = choiceCss.match(/\.birth-time-choice-question legend \{[^}]+\}/)?.[0] ?? "";
|
||||
const whyRule = globals.match(/\.rectification-choice-why \{[^}]+\}/)?.[0] ?? "";
|
||||
assert.match(legendRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
|
||||
const srOnlyLegend = globals.match(
|
||||
/\.rectification-choice-card \.birth-time-choice-question legend\.sr-only \{[^}]+\}/,
|
||||
)?.[0] ?? "";
|
||||
assert.match(whyRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
|
||||
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.match(srOnlyLegend, /margin:\s*0/);
|
||||
assert.match(card, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.match(card, /className="rectification-choice-why"/);
|
||||
assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/);
|
||||
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>\s*\{props\.card\.why \? <p className="rectification-choice-why">/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user