fix(rectification): show question stems and unblock adopt after occupation
Choice legends and spoken collect prompts were hidden after BUG-461, and occupation still blocked canAdopt once dated coverage was done. Restore visible stems and stop polling extra collects when adopt is allowed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6204,6 +6204,7 @@
|
||||
- 相关记录:BUG-400、BUG-401、BUG-405
|
||||
- 复发自:BUG-401(卡片隐藏题干)叠加 BUG-405(正文不再复述题干)
|
||||
- 修复版本:待发布
|
||||
- 后续复发:BUG-471
|
||||
|
||||
## BUG-407 | 区分题目录被快照投影饿死,低分职业题压过已评分的高分 D24
|
||||
|
||||
@@ -7086,6 +7087,7 @@
|
||||
- 相关记录:BUG-441、BUG-449、BUG-452、BUG-460
|
||||
- 复发自:BUG-441
|
||||
- 修复版本:待发布
|
||||
- 后续复发:BUG-471
|
||||
|
||||
## BUG-462 | 区间出口采集撞上开场题号后,成功计费轮被误报 run_failed
|
||||
|
||||
@@ -7118,6 +7120,7 @@
|
||||
- 相关记录:BUG-456、BUG-460、BUG-462
|
||||
- 复发自:无(授权策略变更,不是同一实现回归)
|
||||
- 修复版本:待发布
|
||||
- 后续复发:BUG-472
|
||||
|
||||
## BUG-464 | 客户端全量覆盖 chat_sessions.messages 导致列表膨胀、写满失败与双标签页丢消息
|
||||
|
||||
@@ -7231,3 +7234,35 @@
|
||||
- 相关记录:BUG-326、BUG-339、BUG-343、BUG-383
|
||||
- 复发自:BUG-326(render 里写 ref);BUG-339(effect 同步 setState)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-471 | 选择题干与口述采集题在直播界面消失
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`rectification-choice-card` legend、`.rectification-question-slot` 口述题干、`shouldPersistFocusPromptTurn`、生时纠正直播对话
|
||||
- 用户现象:助手说「界面上继续有下一问 / 有下一步」,下方没有可见问句;D9/D10 风格卡只有 A/B/C/D,看不到题干。用户无法判断要回答什么。
|
||||
- 触发条件:Agent 正文只写承接句;`current_question` 已是 `choice` 或 `collect_spoken`;直播 `send()` 不重拉 turns。
|
||||
- 根因:BUG-461 为去掉重复采集卡,把选择题 legend 重新设为 `sr-only`,并拆掉口述题槽。题干只指望助手正文或第二条 persist turn。Agent 被禁止复述题干,直播又不合并额外 turn,于是两边都空。
|
||||
- 修复:选择题 legend 恢复可见 prompt。`collect_spoken` 在问题槽渲染 prompt 段落(不加灰色「请在下方输入框回答…」、不套选择卡样式)。collect 且本轮已有非空助手正文时,不再追加第二条题干消息。不改 Skill 版本,不打开确认门。
|
||||
- 验证:`rectification-agentic-entry`、`rectification-varga-style-copy`、`rectification-spoken-collect`、`agent-voice-copy-contract`。未做登录后的真实页面点选。
|
||||
- 防复发:有点选卡时题干必须在卡片上可见,不得再把 `choice_card.prompt` 藏进 `sr-only`。口述采集的真源是 GET `current_question.prompt`,必须在问题槽可见。不得用助手正文字符串判断「有没有问过」。不得恢复灰色 hint。
|
||||
- 相关记录:BUG-406、BUG-441、BUG-461、BUG-469
|
||||
- 复发自:BUG-406(可见 legend)被 BUG-461 回退;BUG-461 拆掉口述槽后直播无题干
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-472 | 职业与财务采集挡住已放行的临时采用卡
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`BLOCKING_COVERAGE_IDS`、`persistNextInterviewIfIdle`、`persistNextInterviewAfterChoice`、GET overlay `can_adopt`
|
||||
- 用户现象:区分题已答完、引擎 `accept_allowed=true`、区间已收到约 8 分钟,界面没有时间卡。用户说家里「没有」后只看到一句范围旁白,流程像停住。
|
||||
- 触发条件:带日期的 dasha/D9/D10/relatives 已覆盖或拒答,`occupation` 仍 uncovered;耗尽采集按 family→education→finance 继续追问。拒答家人走 `applyCollectFocusDenial` → `persistNextInterviewAfterChoice`,不走 idle 早退。
|
||||
- 根因:BUG-463 已把分离/holdout 移出采用门,但仍把 `occupation` 留在 blocking 集合。idle 覆盖完成后不再追问,拒答采集路径仍会落财务题。公开层 `can_adopt=false`,前端不渲染时间卡;口述槽当时也是空的。
|
||||
- 修复:blocking 采用门只保留 `dasha_events`、`d9_relationship`、`d10_career`、`relatives`。`occupation` 仍可采集,不再挡住 `canAdopt`。`persistNextInterviewAfterChoice` 与 idle 共用同一早退:已可采用且下一步不是采集/区分/holdout 时,不再落下一问。确认门、3/2 证据下限、引擎 ceiling 不变。不 bump Skill,不改 Python 引擎。
|
||||
- 验证:`rectification-provisional-adopt` 事故形状 1c 与 after-choice 早退;`rectification-eight-method`、`rectification-coverage-collect`、`rectification-occupation-coverage-exit`、`rectification-range-offer-deadend`、`rectification-collect-stall`。`./node_modules/.bin/tsc --noEmit` 通过。未做登录后的真实页面点选。
|
||||
- 防复发:不得把 `occupation`、财务或健康重新写进 `BLOCKING_COVERAGE_IDS`。覆盖完成后 `persistNextInterviewAfterChoice` 与 idle 都不得再追问 OOS 采集。不得放宽确认门或 3/2 下限。
|
||||
- 相关记录:BUG-453、BUG-463
|
||||
- 复发自:BUG-463(idle 不再追问,拒答路径与 occupation blocking 仍挡住采用卡)
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -2956,6 +2956,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
margin-block: var(--space-3);
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
}
|
||||
.rectification-question-slot__prompt {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rectification-question-slot__status {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
@@ -2988,16 +2995,14 @@ 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;
|
||||
.rectification-choice-card .birth-time-choice-question legend {
|
||||
display: block;
|
||||
margin: 0 0 var(--space-3);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rectification-choice-card .birth-time-choice-question:disabled .birth-time-choice-option {
|
||||
cursor: default;
|
||||
|
||||
@@ -325,6 +325,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
|
||||
const boardId = useId();
|
||||
const boardTitleId = useId();
|
||||
const collectSpokenPromptId = useId();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
|
||||
@@ -1229,6 +1230,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
|
||||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||||
{collectSpokenPrompt}
|
||||
</p>
|
||||
)}
|
||||
{showMissingQuestion && (
|
||||
<p className="rectification-question-slot__status" role="status">
|
||||
当前没有可回答的问题,正在等待服务端更新。
|
||||
@@ -1278,6 +1284,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
<Textarea
|
||||
ref={composer}
|
||||
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||||
aria-describedby={collectSpokenPrompt && !showLiveChoiceCard && !readonly
|
||||
? collectSpokenPromptId
|
||||
: 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 className="sr-only">{props.card.prompt}</legend>
|
||||
<legend>{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) => (
|
||||
|
||||
@@ -78,6 +78,29 @@ function openingRangeFromDossier(dossier: {
|
||||
return openingRangeFromCandidateRange(dossier.case?.candidateRange ?? null);
|
||||
}
|
||||
|
||||
function shouldSkipFollowupPersist(input: {
|
||||
canAdopt: boolean;
|
||||
nextAction: string;
|
||||
}): boolean {
|
||||
return input.canAdopt
|
||||
&& input.nextAction !== "ask_fact_collection"
|
||||
&& input.nextAction !== "ask_candidate_discriminator"
|
||||
&& input.nextAction !== "ask_holdout_validation";
|
||||
}
|
||||
|
||||
function adoptHostNarration(input: {
|
||||
credibleRange?: readonly [string, string] | null;
|
||||
representativeTime?: string | null;
|
||||
openingRange: readonly [string, string] | null;
|
||||
receipt: Readonly<Record<string, unknown>> | null | undefined;
|
||||
}): string {
|
||||
return withProspectiveWindows(deliveryAdoptNarration({
|
||||
credibleRange: input.credibleRange,
|
||||
representativeTime: input.representativeTime,
|
||||
openingRange: input.openingRange,
|
||||
}), input.receipt);
|
||||
}
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
@@ -316,6 +339,21 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
nextAction: ReturnType<typeof publicNextAction>;
|
||||
birthDate?: string | null;
|
||||
}): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> {
|
||||
if (shouldSkipFollowupPersist({
|
||||
canAdopt: input.nextAction.can_adopt,
|
||||
nextAction: input.nextAction.type,
|
||||
})) {
|
||||
return {
|
||||
hostNarration: adoptHostNarration({
|
||||
credibleRange: input.nextAction.credible_range,
|
||||
representativeTime: input.nextAction.representative_time,
|
||||
openingRange: openingRangeFromDossier(input.dossier),
|
||||
receipt: input.dossier.latestResult?.decisionReceipt,
|
||||
}),
|
||||
choiceReady: false,
|
||||
persisted: false,
|
||||
};
|
||||
}
|
||||
const latest = input.dossier.latestResult
|
||||
? {
|
||||
...input.dossier.latestResult,
|
||||
@@ -553,20 +591,16 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
birthDate = null;
|
||||
}
|
||||
const decision = decideFromDossier(dossier, { birthDate });
|
||||
if (
|
||||
decision.canAdopt
|
||||
&& decision.nextAction !== "ask_fact_collection"
|
||||
&& decision.nextAction !== "ask_candidate_discriminator"
|
||||
&& decision.nextAction !== "ask_holdout_validation"
|
||||
) {
|
||||
if (shouldSkipFollowupPersist(decision)) {
|
||||
return {
|
||||
persisted: false,
|
||||
choiceReady: false,
|
||||
hostNarration: withProspectiveWindows(deliveryAdoptNarration({
|
||||
hostNarration: adoptHostNarration({
|
||||
credibleRange: decision.credibleRange,
|
||||
representativeTime: decision.representativeTime,
|
||||
openingRange: openingRangeFromDossier(dossier),
|
||||
}), dossier.latestResult?.decisionReceipt),
|
||||
receipt: dossier.latestResult?.decisionReceipt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (isNonConvergingRangeOffer(decision)) {
|
||||
|
||||
@@ -22,10 +22,13 @@
|
||||
* Finance/health score if volunteered; they are not method-layer rotation.
|
||||
* Method coverage finishes before repeating a precision-stage ask.
|
||||
* Appearance and marks are skipped_by_policy. Horary does not block offering
|
||||
* time cards. Occupation still blocks adopt and confirmation until a note
|
||||
* exists, but once the training gate is open it must not also block yearless
|
||||
* varga cards. Career evidence plus a closed occupation collect focus covers
|
||||
* occupation without depending on the model picking domain=occupation.
|
||||
* time cards. Occupation is a method-layer note, not an adopt gate: dated
|
||||
* dasha/D9/D10/relatives coverage plus the engine ceiling can offer
|
||||
* representative cards while occupation remains uncovered. Yearless-to-collect
|
||||
* conversion still waits until occupation is covered, so that note can be
|
||||
* asked later without blocking adopt. Career evidence
|
||||
* plus a closed occupation collect focus still covers the occupation method
|
||||
* without depending on the model picking domain=occupation.
|
||||
* Method coverage asks for dated events in natural language.
|
||||
* Known-event quality probes (exam went badly for a year already
|
||||
* in the ledger) are not reverse-inference cards. Dasha existence
|
||||
@@ -205,7 +208,6 @@ const BLOCKING_COVERAGE_IDS = new Set<MethodFollowupId>([
|
||||
"d9_relationship",
|
||||
"d10_career",
|
||||
"relatives",
|
||||
"occupation",
|
||||
]);
|
||||
|
||||
function isConfirmedDated(item: MethodFollowupEvidence): boolean {
|
||||
@@ -1685,7 +1687,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
source: "method_coverage",
|
||||
});
|
||||
}
|
||||
if (!next && dashaCovered && coverageComplete) {
|
||||
if (!next && dashaCovered && coverageComplete && occupationCovered) {
|
||||
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
|
||||
const yearless = yearlessDiscriminators[0];
|
||||
if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) {
|
||||
|
||||
@@ -33,9 +33,10 @@ export function dossierHasNonemptyAssistantBody(
|
||||
/**
|
||||
* Whether to append the server-owned focus prompt as its own assistant turn.
|
||||
*
|
||||
* Structured only: action, focus kind/intent, and whether this turn already
|
||||
* has a nonempty assistant body. Never inspect the body text to decide
|
||||
* “whether it already asked”.
|
||||
* Structured only: focus kind/intent, and whether this turn already has a
|
||||
* nonempty assistant body. Never inspect the body text to decide
|
||||
* “whether it already asked”. Collect prompts live in the question slot,
|
||||
* so a second assistant turn is only written when this turn has no body.
|
||||
*/
|
||||
export function shouldPersistFocusPromptTurn(input: {
|
||||
action: RectificationRouteAction;
|
||||
@@ -45,7 +46,7 @@ export function shouldPersistFocusPromptTurn(input: {
|
||||
}): boolean {
|
||||
const collect = input.questionKind === "collect_spoken"
|
||||
|| input.questionIntent === "collect_method_evidence";
|
||||
return !(input.action === "opening" && collect && input.assistantBodyPresent);
|
||||
return !(collect && input.assistantBodyPresent);
|
||||
}
|
||||
|
||||
export async function finalizeSuccessfulTurnExit(input: {
|
||||
|
||||
@@ -102,7 +102,7 @@ test("question-slot ownership stays in the rectification agent prompt", () => {
|
||||
assert.doesNotMatch(route, /接下来请点选下面这一问/);
|
||||
assert.doesNotMatch(exit, /接下来请点选下面这一问/);
|
||||
assert.match(exit, /shouldPersistFocusPromptTurn/);
|
||||
assert.match(exit, /action === ["']opening["']/);
|
||||
assert.doesNotMatch(exit, /action === ["']opening["']/);
|
||||
assert.match(route, /assistantBodyPresent: Boolean\(result\.answerText\?\.trim\(\)\)/);
|
||||
assert.doesNotMatch(exit, /assistantMessage\.(?:includes|match|search)/);
|
||||
assert.doesNotMatch(exit, /question\.prompt\.(?:includes|match|search)/);
|
||||
@@ -116,7 +116,7 @@ test("question-slot ownership stays in the rectification agent prompt", () => {
|
||||
assert.doesNotMatch(skipFn, /includes\(/);
|
||||
});
|
||||
|
||||
test("E-1 opening collect skip is structural, not a body-text regex", () => {
|
||||
test("collect prompt skip is structural, not a body-text regex", () => {
|
||||
assert.equal(shouldPersistFocusPromptTurn({
|
||||
action: "opening",
|
||||
questionKind: "collect_spoken",
|
||||
@@ -140,7 +140,7 @@ test("E-1 opening collect skip is structural, not a body-text regex", () => {
|
||||
questionKind: "collect_spoken",
|
||||
questionIntent: "collect_method_evidence",
|
||||
assistantBodyPresent: true,
|
||||
}), true);
|
||||
}), false);
|
||||
assert.equal(shouldPersistFocusPromptTurn({
|
||||
action: "answer_choice",
|
||||
questionKind: "choice",
|
||||
|
||||
@@ -662,9 +662,9 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
|
||||
assert.doesNotMatch(tools, /offer_selection/);
|
||||
});
|
||||
|
||||
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 cards expose the persisted prompt as a visible legend", () => {
|
||||
assert.match(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.doesNotMatch(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
|
||||
});
|
||||
|
||||
test("choice card answers and stop share one stacked primary list", () => {
|
||||
|
||||
@@ -3,15 +3,17 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyAnswerToState, buildInferenceState, candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import {
|
||||
isNonConvergingRangeOffer,
|
||||
publicNextAction,
|
||||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
decideFromDossier,
|
||||
rectificationFollowupCatalog,
|
||||
type DecisionDossier,
|
||||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { buildMethodFollowupPlan, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
||||
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
@@ -856,16 +858,11 @@ test("duplicate collect focus reloads the active question instead of returning n
|
||||
},
|
||||
});
|
||||
const persisted = await persistOccupationAfterChoice(accounting.client);
|
||||
assert.equal(persisted.hostNarration, "你平时主要做什么工作?");
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: "collect:occupation:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "occupation",
|
||||
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, 2);
|
||||
assert.ok(persisted.hostNarration);
|
||||
assert.notEqual(persisted.hostNarration, "");
|
||||
assert.ok(
|
||||
accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length >= 2,
|
||||
);
|
||||
});
|
||||
|
||||
test("skipped collect focus reloads once and retries persistence", async () => {
|
||||
@@ -880,7 +877,7 @@ test("skipped collect focus reloads once and retries persistence", async () => {
|
||||
},
|
||||
});
|
||||
const persisted = await persistOccupationAfterChoice(accounting.client);
|
||||
assert.equal(persisted.hostNarration, "你平时主要做什么工作?");
|
||||
assert.ok(persisted.hostNarration);
|
||||
assert.equal(writes, 2);
|
||||
const write = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").at(-1);
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
@@ -890,10 +887,11 @@ test("skipped collect focus reloads once and retries persistence", async () => {
|
||||
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
|
||||
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
|
||||
});
|
||||
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
|
||||
assert.notEqual(currentQuestion, null);
|
||||
assert.ok(currentQuestion?.prompt || currentQuestion?.kind);
|
||||
});
|
||||
|
||||
test("nonterminal turn exit deterministically restores a spoken question", async () => {
|
||||
test("nonterminal turn exit is already satisfied once dated coverage can adopt", async () => {
|
||||
const answerChoiceModule = await import("../src/lib/rectification-agentic/v9/answer-choice.ts") as Record<string, unknown>;
|
||||
const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: {
|
||||
accounting: ReturnType<typeof fakeAccounting>["client"];
|
||||
@@ -901,13 +899,11 @@ test("nonterminal turn exit deterministically restores a spoken question", async
|
||||
caseId: string;
|
||||
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
|
||||
assert.equal(typeof ensureExit, "function");
|
||||
let activeFocus: Record<string, unknown> | null = null;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), activeFocus),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
activeFocus = createdFocusFromArgs(args);
|
||||
return { focus: activeFocus, idempotent: false };
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
|
||||
set_agentic_rectification_conversation_focus: () => {
|
||||
throw new Error("adoptable offer must not persist another question");
|
||||
},
|
||||
});
|
||||
const repaired = await ensureExit!({
|
||||
@@ -915,22 +911,15 @@ test("nonterminal turn exit deterministically restores a spoken question", async
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.equal(repaired.persisted, true);
|
||||
assert.match(repaired.hostNarration ?? "", /05:07/);
|
||||
assert.match(repaired.hostNarration ?? "", /代表分钟|眼下更像/);
|
||||
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: String(write?.args.p_question_id ?? ""),
|
||||
intent: String(write?.args.p_intent ?? ""),
|
||||
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
|
||||
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
|
||||
});
|
||||
assert.equal(currentQuestion?.kind, "collect_spoken");
|
||||
assert.ok(currentQuestion?.prompt);
|
||||
assert.equal(repaired.persisted, false);
|
||||
assert.equal(repaired.hostNarration, null);
|
||||
assert.equal(
|
||||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("live five-evidence case ends on the occupation spoken collect", async () => {
|
||||
test("live five-evidence case opens adopt instead of occupation collect", async () => {
|
||||
const dossier = liveCaseDossier();
|
||||
const state = liveCaseState();
|
||||
assert.equal(dossier.evidence.length, 5);
|
||||
@@ -941,25 +930,24 @@ test("live five-evidence case ends on the occupation spoken collect", async () =
|
||||
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(decision.probe, null);
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.notEqual(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(isNonConvergingRangeOffer(decision), false);
|
||||
assert.ok(decision.droppedProbes.some((probe) => (
|
||||
probe.semantic_key === CAREER_2023_ACTIVATION.semantic_key
|
||||
&& probe.reason === "no_split_among_active"
|
||||
)), JSON.stringify(decision.droppedProbes));
|
||||
|
||||
const plan = planFrom(dossier);
|
||||
assert.equal(plan.next_followup?.method_id, "occupation");
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.occupation);
|
||||
const plan = planFrom(dossier, { sessionOutcome: decision.sessionOutcome });
|
||||
assert.equal(plan.next_followup, null);
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: createdFocusFromArgs(args),
|
||||
idempotent: false,
|
||||
}),
|
||||
set_agentic_rectification_conversation_focus: () => {
|
||||
throw new Error("adoptable offer must not persist another question");
|
||||
},
|
||||
});
|
||||
const persisted = await persistNextInterviewAfterChoice({
|
||||
accounting: accounting.client,
|
||||
@@ -970,16 +958,12 @@ test("live five-evidence case ends on the occupation spoken collect", async () =
|
||||
nextAction: publicNextAction(decision),
|
||||
birthDate: "1997-08-08",
|
||||
});
|
||||
assert.match(persisted.hostNarration, /你平时主要做什么工作?/);
|
||||
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: String(write?.args.p_question_id ?? ""),
|
||||
intent: String(write?.args.p_intent ?? ""),
|
||||
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
|
||||
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
|
||||
});
|
||||
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
|
||||
assert.equal(persisted.persisted, false);
|
||||
assert.match(persisted.hostNarration, /可以从下面选一个先用着/);
|
||||
assert.equal(
|
||||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evide
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import type { InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
|
||||
const UNCOVERED_BLOCKING = new Set(["relatives", "occupation"]);
|
||||
const UNCOVERED_BLOCKING = new Set(["relatives"]);
|
||||
|
||||
const TRAINING_EVIDENCE = [
|
||||
{
|
||||
@@ -107,7 +107,14 @@ function producedDossier(
|
||||
})),
|
||||
representativeTime: state.representative_time,
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never),
|
||||
decisionReceipt: { inference_state: state },
|
||||
decisionReceipt: {
|
||||
inference_state: state,
|
||||
accept_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
},
|
||||
};
|
||||
return {
|
||||
state,
|
||||
@@ -154,13 +161,21 @@ test("separated scores with uncovered relatives stay in collection and still ask
|
||||
assert.ok(plan.next_followup, "collection must still have a question");
|
||||
assert.ok(
|
||||
UNCOVERED_BLOCKING.has(plan.next_followup.method_id),
|
||||
`expected relatives or occupation, got ${plan.next_followup.method_id}`,
|
||||
`expected relatives, got ${plan.next_followup.method_id}`,
|
||||
);
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered");
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
|
||||
});
|
||||
|
||||
test("covering family and occupation leaves collection after scores already separated", () => {
|
||||
test("covering family without occupation still opens adopt after scores already separated", () => {
|
||||
const familyOnly = producedDossier([...TRAINING_EVIDENCE, FAMILY_EVIDENCE]);
|
||||
const familyDecision = decideFromDossier(familyOnly.dossier);
|
||||
assert.notEqual(familyDecision.nextAction, "ask_fact_collection");
|
||||
assert.equal(familyDecision.canAdopt, true);
|
||||
assert.equal(familyDecision.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
test("covering family and occupation still opens adopt after scores already separated", () => {
|
||||
const { state, dossier: collecting } = producedDossier(TRAINING_EVIDENCE);
|
||||
assert.equal(decideFromDossier(collecting).nextAction, "ask_fact_collection");
|
||||
|
||||
@@ -168,15 +183,18 @@ test("covering family and occupation leaves collection after scores already sepa
|
||||
const { dossier } = producedDossier(covered, { previous: state });
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.notEqual(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.ok(
|
||||
decision.nextAction === "ask_holdout_validation" || decision.nextAction === "ready_to_adopt",
|
||||
decision.nextAction === "ask_holdout_validation"
|
||||
|| decision.nextAction === "ready_to_adopt"
|
||||
|| decision.nextAction === "offer_provisional_range",
|
||||
decision.nextAction,
|
||||
);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.equal(decision.separation.sufficient, true);
|
||||
});
|
||||
|
||||
test("declining family and occupation also leaves collection after scores already separated", () => {
|
||||
test("declining family and occupation also opens adopt after scores already separated", () => {
|
||||
const declinedTopics = [
|
||||
{ targetDomain: "family" },
|
||||
{ target_domain: "occupation" },
|
||||
@@ -189,8 +207,11 @@ test("declining family and occupation also leaves collection after scores alread
|
||||
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.notEqual(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.ok(
|
||||
decision.nextAction === "ask_holdout_validation" || decision.nextAction === "ready_to_adopt",
|
||||
decision.nextAction === "ask_holdout_validation"
|
||||
|| decision.nextAction === "ready_to_adopt"
|
||||
|| decision.nextAction === "offer_provisional_range",
|
||||
decision.nextAction,
|
||||
);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, spokenFollowupForUser, blockingMethodsCovered } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||||
import {
|
||||
mentionedVargaKeysFromLedgerEvidence,
|
||||
@@ -1830,7 +1830,7 @@ test("d9_refine after relationship still asks uncovered career first", () => {
|
||||
}), "collect_evidence");
|
||||
});
|
||||
|
||||
test("career evidence does not cover occupation; occupation still blocks until asked", () => {
|
||||
test("career evidence does not cover occupation method; occupation no longer blocks adopt", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: [
|
||||
{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null },
|
||||
@@ -1841,13 +1841,19 @@ test("career evidence does not cover occupation; occupation still blocks until a
|
||||
});
|
||||
assert.equal(plan.next_followup?.method_id, "occupation");
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
|
||||
assert.equal(blockingMethodsCovered(plan.methods), true);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
}), "collect_evidence");
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 34 },
|
||||
{ time: "05:01", score: 33 },
|
||||
{ time: "05:02", score: 33 },
|
||||
],
|
||||
}), "adopt_representative");
|
||||
});
|
||||
|
||||
test("draft occupation_note without a date covers occupation and does not adopt a tie", () => {
|
||||
|
||||
@@ -334,7 +334,7 @@ test("training complete with no renderable distinguish card allows offering a ra
|
||||
assert.equal(stillClosed.nextAction, "ask_fact_collection");
|
||||
});
|
||||
|
||||
test("decideFromDossier offers a range when training is complete and the ask layer has no card", () => {
|
||||
test("decideFromDossier offers a range with adopt when training is complete and occupation is still uncovered", () => {
|
||||
const decision = decideFromDossier({
|
||||
evidence: CASE_EVIDENCE,
|
||||
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
|
||||
@@ -361,7 +361,7 @@ test("decideFromDossier offers a range when training is complete and the ask lay
|
||||
case: { acceptedTime: null },
|
||||
});
|
||||
assert.equal(decision.canOfferRange, true);
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
decideRectification,
|
||||
engineCapabilityCeilingFromReceipt,
|
||||
publicDecisionFields,
|
||||
publicNextAction,
|
||||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
@@ -14,14 +15,13 @@ import {
|
||||
overlayPublicDecision,
|
||||
type DecisionDossier,
|
||||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { persistNextInterviewAfterChoice, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { containsBoundarySemantics } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
buildMethodFollowupPlan,
|
||||
buildNextUserAction,
|
||||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
canRenderRectificationSelectionCards,
|
||||
parseRectificationCandidateResult,
|
||||
@@ -381,10 +381,11 @@ test("incident shape 1b: exact-minute confirm stays closed on the accident case"
|
||||
}
|
||||
});
|
||||
|
||||
test("incident shape 1c: uncovered occupation keeps adopt closed and still has a next question", async () => {
|
||||
test("incident shape 1c: uncovered occupation still opens provisional adopt", async () => {
|
||||
const dossier = incidentDossier({ occupationCovered: false });
|
||||
const { decision, overlaid } = publicFields(dossier);
|
||||
assert.equal(overlaid.can_adopt, false);
|
||||
assert.equal(overlaid.can_adopt, true);
|
||||
assert.equal(overlaid.selection_allowed, true);
|
||||
assert.equal(overlaid.can_confirm_exact_minute, false);
|
||||
|
||||
let activeFocus: Record<string, unknown> | null = null;
|
||||
@@ -422,18 +423,47 @@ test("incident shape 1c: uncovered occupation keeps adopt closed and still has a
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.equal(persisted.persisted, true);
|
||||
const parsed = parseV9CaseDossier({
|
||||
...raw,
|
||||
conversation_summary: {
|
||||
...(raw.conversation_summary as Record<string, unknown>),
|
||||
active_focus: activeFocus,
|
||||
assert.equal(persisted.persisted, false);
|
||||
assert.equal(activeFocus, null);
|
||||
assert.match(persisted.hostNarration ?? "", /可以从下面选一个先用着/);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.equal(canRenderRectificationSelectionCards(parseRectificationCandidateResult({
|
||||
resultId: dossier.latestResult?.resultId,
|
||||
candidates: overlaid.candidates,
|
||||
selectionAllowed: overlaid.selection_allowed,
|
||||
selection_allowed: overlaid.selection_allowed,
|
||||
canAdopt: overlaid.can_adopt,
|
||||
can_adopt: overlaid.can_adopt,
|
||||
representativeTime: overlaid.representative_time,
|
||||
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
|
||||
})), true);
|
||||
});
|
||||
|
||||
test("after-choice persist does not open another collect once dated coverage can adopt", async () => {
|
||||
const dossier = incidentDossier({ occupationCovered: false });
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
let writes = 0;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
set_agentic_rectification_conversation_focus: () => {
|
||||
writes += 1;
|
||||
throw new Error("adoptable offer must not persist another question");
|
||||
},
|
||||
});
|
||||
assert.ok(parsed);
|
||||
const currentQuestion = projectCurrentQuestion(parsed.conversationSummary.activeFocus);
|
||||
assert.ok(currentQuestion?.prompt, JSON.stringify({ decision: decision.nextAction, currentQuestion }));
|
||||
assert.match(currentQuestion?.question_id ?? "", /^collect:/);
|
||||
const next = await persistNextInterviewAfterChoice({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
dossier,
|
||||
decisionState: null,
|
||||
nextAction: publicNextAction(decision),
|
||||
});
|
||||
assert.equal(next.persisted, false);
|
||||
assert.equal(writes, 0);
|
||||
assert.match(next.hostNarration, /可以从下面选一个先用着/);
|
||||
});
|
||||
|
||||
test("incident shape 2: evidence floor still blocks adopt", () => {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
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";
|
||||
@@ -501,15 +500,16 @@ test("P1: dated career probes live only in inference_state still ask when they s
|
||||
assert.equal(after.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
|
||||
});
|
||||
|
||||
test("live remaining probes with zero active split enter dropped_probes and the range-offer branch", () => {
|
||||
test("live remaining probes with zero active split drop and open provisional adopt", () => {
|
||||
const remaining = [CAREER_2024_04, CAREER_2023_ACTIVATION];
|
||||
const dossier = liveDossier(remaining);
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(decision.probe, null);
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
assert.equal(isNonConvergingRangeOffer(decision), true);
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.equal(decision.selectionAllowed, false);
|
||||
assert.equal(isNonConvergingRangeOffer(decision), false);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.equal(decision.selectionAllowed, true);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]);
|
||||
assert.equal(decision.representativeTime, "04:51");
|
||||
assert.equal(decision.droppedProbes.some((item) => (
|
||||
@@ -520,10 +520,11 @@ test("live remaining probes with zero active split enter dropped_probes and the
|
||||
)), true);
|
||||
});
|
||||
|
||||
test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric range", async () => {
|
||||
test("adoptable range offer does not persist another spoken collect", async () => {
|
||||
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]);
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(isNonConvergingRangeOffer(decision), true);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.equal(isNonConvergingRangeOffer(decision), false);
|
||||
const card = projectRectificationChoiceCard({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
@@ -545,48 +546,24 @@ test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
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-30T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
set_agentic_rectification_conversation_focus: () => {
|
||||
throw new Error("adoptable offer must not persist another question");
|
||||
},
|
||||
});
|
||||
const idle = await persistNextInterviewIfIdle({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.equal(idle.persisted, true);
|
||||
assert.equal(idle.persisted, false);
|
||||
assert.equal(idle.choiceReady, false);
|
||||
assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/);
|
||||
assert.match(idle.hostNarration ?? "", /04:47–04:53/);
|
||||
assert.match(idle.hostNarration ?? "", /04:51/);
|
||||
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
|
||||
assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT));
|
||||
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.equal(setFocus?.args.p_intent, "collect_method_evidence");
|
||||
assert.equal(setFocus?.args.p_target_domain, "education");
|
||||
const schema = setFocus?.args.p_expected_answer_schema as Record<string, unknown> | undefined;
|
||||
const question = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: String(setFocus?.args.p_question_id ?? ""),
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "education",
|
||||
expectedAnswerSchema: schema ?? null,
|
||||
});
|
||||
assert.equal(question?.kind, "collect_spoken");
|
||||
assert.ok(question?.prompt);
|
||||
assert.equal(Boolean(idle.hostNarration) && Boolean(question), true);
|
||||
assert.equal(setFocus, undefined);
|
||||
});
|
||||
|
||||
test("userStopped completes with a review-only provisional range", () => {
|
||||
@@ -604,9 +581,11 @@ test("userStopped completes with a review-only provisional range", () => {
|
||||
assert.equal(isNonConvergingRangeOffer(stopped), false);
|
||||
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" });
|
||||
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped");
|
||||
// 原值是 true;该 dossier 只有两个同域日期事件,停止不等于证据达标。
|
||||
assert.equal(fromDossier.canAdopt, false);
|
||||
// 家人已拒答、带日期层已覆盖、引擎已放行时,停止追问仍应交付代表性采用卡。
|
||||
assert.equal(fromDossier.sessionOutcome, "adopt_representative");
|
||||
assert.equal(fromDossier.canAdopt, true);
|
||||
assert.equal(fromDossier.canConfirmExactMinute, false);
|
||||
assert.equal(fromDossier.completionStatus, "provisional_range_user_stopped");
|
||||
});
|
||||
|
||||
test("idle persist still decides from the dossier once and does not invent collect_evidence", () => {
|
||||
|
||||
@@ -104,20 +104,21 @@ test("cases current_question drives the unified question slot", () => {
|
||||
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
|
||||
});
|
||||
|
||||
test("collect_spoken current_question drives the composer, not a second visual card", () => {
|
||||
test("collect_spoken current_question renders the prompt in the question slot", () => {
|
||||
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, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
|
||||
assert.match(chat, /rectification-question-slot__prompt/);
|
||||
assert.match(chat, /aria-describedby=\{collectSpokenPrompt/);
|
||||
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.match(styles, /\.rectification-question-slot__prompt \{/);
|
||||
assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/);
|
||||
});
|
||||
|
||||
|
||||
@@ -335,16 +335,17 @@ 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 is screen-reader only so the stem is not duplicated", () => {
|
||||
test("choice-card legend is visible so the stem is not screen-reader only", () => {
|
||||
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 whyRule = globals.match(/\.rectification-choice-why \{[^}]+\}/)?.[0] ?? "";
|
||||
const srOnlyLegend = globals.match(
|
||||
/\.rectification-choice-card \.birth-time-choice-question legend\.sr-only \{[^}]+\}/,
|
||||
const legendRule = globals.match(
|
||||
/\.rectification-choice-card \.birth-time-choice-question legend \{[^}]+\}/,
|
||||
)?.[0] ?? "";
|
||||
assert.match(whyRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
|
||||
assert.match(srOnlyLegend, /margin:\s*0/);
|
||||
assert.match(card, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.match(legendRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
|
||||
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.doesNotMatch(card, /className="sr-only"/);
|
||||
assert.match(card, /className="rectification-choice-why"/);
|
||||
assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user