fix(rectification): dedupe post-adopt verify and skip this probe only
Independent Staging Quality Gate / validate (push) Successful in 14m31s
Independent Staging Quality Gate / publish (push) Successful in 1m59s

Adopted reverse-verify repeated already-asked collect stems, treated 「这题跳过」 as stopping the case, and promised holdout/OOS checks that never ran. Collect spoken stems no longer prefix a year.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 21:51:59 +08:00
co-authored by Cursor
parent 6b038e6d0d
commit d4627a5019
36 changed files with 1065 additions and 91 deletions
+2 -1
View File
@@ -228,6 +228,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card |
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` after adopted reverse-verify is exhausted; no spinner, no reload | enabled |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | disabled, stop visible |
| `candidates` | the offer cards under the message that offered them | enabled |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | disabled during the chain |
@@ -472,7 +473,7 @@ or user IDs.
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
+1
View File
@@ -31,6 +31,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。 | 我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。 | 承认不确定,不训人。 |
| 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 |
| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 |
| 采用后会拿盘外核对来验证。 | 前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 已采用且没有下一道核对题时用 `postAdoptVerifyDone`,不再念 `adoptCue`。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
@@ -18,7 +18,7 @@ import {
} from "@/lib/rectification-agentic/v9/answer-choice";
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { CHOICE_ACTION, SKIP_PROBE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run";
import { safePublicEvent } from "@/lib/rectification-agentic/v9/stream-mapping";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "@/lib/rectification-agentic/v9/case-status";
@@ -82,14 +82,14 @@ const agentRequestSchema = z.object({
caseId: z.string().uuid(),
sessionId: z.string().uuid(),
requestId: z.string().uuid(),
action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review"]),
action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"]),
message: z.string().trim().min(1).max(4000).optional(),
modelId: z.string().trim().min(1).max(64).optional(),
actionId: z.string().uuid().optional(),
focusId: z.string().uuid().optional(),
questionId: z.string().trim().min(1).max(200).optional(),
probeId: z.string().trim().min(1).max(200).nullable().optional(),
optionId: z.enum(["A", "B", "C", "D"]).optional(),
optionId: z.enum(["A", "B", "C", "D", "skip_probe"]).optional(),
expectedRevision: z.number().int().min(0).max(10_000).optional(),
origin: z.enum([
"typed",
@@ -300,11 +300,19 @@ export async function POST(request: Request) {
caseId,
sessionId,
actionId,
action: action === "stop_and_review" ? STOP_ACTION : CHOICE_ACTION,
action: action === "stop_and_review"
? STOP_ACTION
: action === "skip_probe"
? SKIP_PROBE_ACTION
: CHOICE_ACTION,
focusId,
questionId: parsed.data.questionId,
probeId: parsed.data.probeId ?? null,
optionId: action === "stop_and_review" ? "stop" : parsed.data.optionId!,
optionId: action === "stop_and_review"
? "stop"
: action === "skip_probe"
? "skip_probe"
: parsed.data.optionId!,
expectedRevision,
narrateAdopt,
});
@@ -327,6 +335,7 @@ export async function POST(request: Request) {
sourceQuote: applied.sourceQuote,
derivedContext: applied.derivedContext,
nextAction: applied.nextAction,
next_user_action: applied.nextUserAction,
nextInterviewPersisted: applied.nextInterviewPersisted,
nextChoiceReady: applied.nextChoiceReady,
});
@@ -600,7 +609,7 @@ export async function POST(request: Request) {
);
return response;
}
if (action === "answer_choice" || action === "stop_and_review") {
if (action === "answer_choice" || action === "stop_and_review" || action === "skip_probe") {
return NextResponse.json(
{ error: "选择题处理失败", message: "请稍后重试。" },
{ status: 500 },
@@ -14,7 +14,7 @@ import {
type ConversationFocusList,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision, nextUserActionFromDossier } from "@/lib/rectification-agentic/v9/interview-state";
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
import { attachQuestionsToTurns, attachOfferResultToTurns } from "@/lib/rectification-agentic/v9/turn-question";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
@@ -147,6 +147,7 @@ export function dossierResponse(
},
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: choiceCardFromCaseDossier(dossier),
next_user_action: nextUserActionFromDossier(dossier),
question_source: questionSourceFromFocusList(listed),
};
}
@@ -70,12 +70,14 @@ import {
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
isStructuredChoiceUserText,
shouldContinueAfterStructuredChoice,
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
@@ -272,7 +274,7 @@ function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[])
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "typed",
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
): RenderMessage[] {
return current.map((message) => {
const question = message.question;
@@ -329,6 +331,7 @@ function choiceCardFromQuestion(
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
@@ -418,8 +421,14 @@ type CaseSnapshotState = Readonly<{
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
nextUserActionId: string | null;
}>;
function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
const id = payload?.next_user_action?.id;
return typeof id === "string" && id.trim() ? id.trim() : null;
}
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
if (!payload) return null;
@@ -433,6 +442,7 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
nextUserActionId: nextUserActionIdFromSnapshot(payload),
};
}
@@ -470,6 +480,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [openingRequested, setOpeningRequested] = useState(false);
@@ -628,6 +639,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
choice_card?: unknown;
turns?: unknown;
question_source?: unknown;
next_user_action?: { id?: unknown };
case?: {
status?: unknown;
accepted_time?: unknown;
@@ -643,11 +655,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: null;
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;
const nextActionId = typeof payload.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
if (confirmedTime) {
setSavedTime(confirmedTime);
@@ -1051,8 +1067,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, []);
const submitStructuredChoice = useCallback(async (
action: typeof CHOICE_ACTION | typeof STOP_ACTION,
optionId: ChoiceKey | "stop",
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION,
optionId: ChoiceKey | "stop" | "skip_probe",
override?: Readonly<{
focusId: string;
questionId: string | null;
@@ -1124,6 +1140,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
const payloadNextActionId = typeof payload?.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
onCompleted?.();
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
@@ -1392,6 +1412,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable",
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
@@ -1447,6 +1468,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
function submitStop() {
if (!choiceCard) return;
if (choiceCard.skip_this_probe) {
void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe");
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
}
@@ -1622,6 +1647,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</Button>
</div>
)}
{questionGap === "verified_idle" && (
<p className="rectification-pending-note" role="status">
{RECTIFICATION_USER_COPY.postAdoptVerifyDone}
</p>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -12,7 +12,7 @@ type RectificationChoiceCardProps = Readonly<{
card: ChoiceCard;
pending: boolean;
disabled: boolean;
selectedKey?: ChoiceKey | "stop" | "";
selectedKey?: ChoiceKey | "stop" | "skip_probe" | "";
onSelect: (key: ChoiceKey) => void;
onStop: () => void;
variant?: "card" | "embedded";
@@ -20,7 +20,7 @@ type RectificationChoiceCardProps = Readonly<{
}>;
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "">("");
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
const selectedKey = props.selectedKey || localSelected;
const answered = Boolean(selectedKey);
@@ -77,7 +77,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
<button
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === "stop" ? "true" : "false"}
data-selected={selectedKey === "stop" || selectedKey === "skip_probe" ? "true" : "false"}
onClick={stop}
>
{props.card.stop_label}
@@ -77,6 +77,7 @@ export const RECTIFICATION_USER_COPY = {
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
} as const;
export type RangeNarrationVariant = "delivery" | "intermediate";
@@ -232,6 +233,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
];
@@ -20,7 +20,8 @@ import {
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
@@ -120,7 +121,7 @@ export async function generateAdoptNarrationText(
post_adopt_verification: facts.post_adopt_verification.map((item) => ({
kind: item.kind,
domain: item.domain,
year: item.year,
year_label: item.year_label,
})),
}),
}], {
@@ -10,7 +10,8 @@ import type { RectificationDecision } from "../core/rectification-decision.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import type { DecisionDossier } from "./decision-from-dossier.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
import { reverseVerifyChecksFromProbes, reverseVerifyRemainingForAdopt } from "./method-followup.ts";
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
import type { DroppedProbe } from "./probe-question-contract.ts";
@@ -33,9 +34,9 @@ export type AdoptCandidateSupport = Readonly<{
}>;
export type AdoptPostAdoptCheck = Readonly<{
kind: "holdout" | "oos";
kind: "reverse_verify";
domain: string;
year: number | null;
year_label: string;
}>;
export type AdoptDeliveryFacts = Readonly<{
@@ -158,21 +159,23 @@ export function adoptDeliveryFacts(
] as const
: null;
const opening = openingRangeFromCandidateRange(dossier.case.candidateRange ?? null);
const holdout = (inference?.events ?? [])
.filter((item) => item.usage === "holdout" && item.year !== null)
.map((item) => ({
kind: "holdout" as const,
domain: item.domain,
year: item.year,
}));
const oos = refinement.oos_blind_prompts.map((item) => ({
kind: "oos" as const,
domain: item.domain,
year: null,
}));
const verification = [...holdout, ...oos];
const askedKeys = askedDiscriminatorKeys(receipt, dossier.evidence);
const remaining = reverseVerifyRemainingForAdopt({
eventProbes: refinement.discriminating_event_probes,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
askedProbeKeys: askedKeys,
});
const verification = reverseVerifyChecksFromProbes(remaining);
const years = uniqueYears([
...holdout.map((item) => item.year),
...remaining.map((item) => item.year),
...verification.flatMap((item) => {
const found: number[] = [];
for (const match of item.year_label.matchAll(FOUR_DIGIT_YEAR)) {
found.push(Number(match[1]));
}
return found;
}),
...(inference?.events ?? []).map((item) => item.year),
]);
const minutes = uniqueClocks([
@@ -209,6 +212,16 @@ export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean {
return facts.precision_stage === "ready_to_adopt" && !facts.already_accepted;
}
export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
if (facts.post_adopt_verification.length === 0) {
return "采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。";
}
const labels = facts.post_adopt_verification.map((item) => (
item.year_label ? `${item.year_label}${item.domain}` : item.domain
));
return `采用后会拿${labels.join("、")}来核对。`;
}
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
if (split && facts.representative_minute && facts.runner_up_minute) {
@@ -243,6 +256,12 @@ export function validateAdoptNarration(
return { ok: false, reason: "question" };
}
if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" };
if (
facts.post_adopt_verification.length === 0
&& /会拿[\s\S]{0,40}核对/.test(trimmed)
) {
return { ok: false, reason: "promise" };
}
const minutes = allowedMinuteSet(facts);
for (const token of trimmed.match(CLOCK_TOKEN) ?? []) {
const padded = padClock(token);
@@ -23,6 +23,7 @@ import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCat
import type { AnswerClass, InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
composeChoiceNarration,
focusStatusForAnswer,
@@ -46,9 +47,10 @@ import {
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import { isPersistedFocusId, type ChoiceKey } from "./choice-card";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, type ChoiceKey } from "./choice-card";
import {
adoptDeliveryFacts,
templatePostAdoptExplain,
templateStopExplain,
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
@@ -57,6 +59,7 @@ import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
buildNextUserAction,
exhaustionSpokenCollectFollowup,
GENERIC_COLLECT_QUESTION,
spokenCollectFallbackFollowup,
@@ -105,7 +108,9 @@ function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): bool
|| followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.source === "nakshatra_boundary"
|| followup.intent === "distinguish_candidates";
|| followup.source === "reverse_verify"
|| followup.intent === "distinguish_candidates"
|| followup.intent === "reverse_verify";
}
function shouldSkipFollowupPersist(input: {
@@ -113,7 +118,9 @@ function shouldSkipFollowupPersist(input: {
nextAction: string;
followup: MethodFollowup | null;
methods?: readonly MethodCoverage[];
accepted?: boolean;
}): boolean {
if (input.accepted) return false;
if (!input.canAdopt) return false;
if (
input.nextAction === "ask_fact_collection"
@@ -133,13 +140,19 @@ function adoptHostNarration(input: {
receipt: Readonly<Record<string, unknown>> | null | undefined;
}): string {
const facts = adoptDeliveryFacts(input.decision, input.dossier);
return withProspectiveWindows(deliveryAdoptNarration({
const base = withProspectiveWindows(deliveryAdoptNarration({
credibleRange: input.decision.credibleRange,
representativeTime: input.decision.representativeTime,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: input.decision.stopReason ?? null,
stopExplain: templateStopExplain(facts),
}), input.receipt);
if (facts.post_adopt_verification.length > 0) return base;
const explain = templatePostAdoptExplain(facts);
if (base.includes("没有还能核对的前事")) return base;
return `${base.replace(RECTIFICATION_USER_COPY.adoptCue, "").trim()} ${explain} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
}
function dossierWithCurrentInference(
@@ -164,7 +177,7 @@ export type ApplyChoiceCommand = Readonly<{
caseId: string;
sessionId: string;
actionId: string;
action: typeof CHOICE_ACTION | typeof STOP_ACTION;
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
focusId: string;
questionId?: string;
probeId?: string | null;
@@ -191,6 +204,7 @@ export type AppliedChoiceReceipt = Readonly<{
narration: string;
userDisplay: string | null;
nextAction: ReturnType<typeof publicNextAction>;
nextUserAction: ReturnType<typeof buildNextUserAction>;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
turnId: string | null;
@@ -200,6 +214,38 @@ function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function markVerifyProbeSkipped(
state: InferenceState,
schema: Readonly<Record<string, unknown>>,
fallbackProbeId: string | null,
): InferenceState {
const probeId = asText(schema.probe_id) ?? fallbackProbeId ?? asText(schema.semantic_key);
const semantic = asText(schema.semantic_key) ?? probeId;
const split = asText(schema.candidate_split_hash) ?? semantic;
if (!probeId || !semantic || !split) return state;
if (state.answered_probes.some((item) => (
item.probe_id === probeId
|| item.semantic_key === semantic
|| item.candidate_split_hash === split
))) {
return state;
}
return {
...state,
revision: state.revision + 1,
answered_probes: [
...state.answered_probes,
{
probe_id: probeId,
semantic_key: semantic,
candidate_split_hash: split,
answer_class: "unsure",
classified_from: "declined",
},
],
};
}
function dossierWithClosedFocus<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null; intent?: string | null } | null;
@@ -257,11 +303,71 @@ export async function applyRectificationChoice(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
const answerClass = optionId === "stop" ? null : outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && !answerClass) {
const answerClass = optionId === "stop" || optionId === "skip_probe"
? null
: outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && optionId !== "skip_probe" && !answerClass) {
throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema");
}
if (optionId === "skip_probe" || command.action === SKIP_PROBE_ACTION) {
const skippedState = previous ? markVerifyProbeSkipped(previous, schema, schemaProbeId ?? command.probeId ?? null) : null;
const probeId = schemaProbeId ?? command.probeId ?? asText(schema.semantic_key) ?? "skip_probe";
const semanticKey = asText(schema.semantic_key) ?? probeId;
const splitHash = asText(schema.candidate_split_hash) ?? semanticKey;
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
const inference = skippedState && previous
? {
expectedRevision: previous.revision,
probeId,
openProbeId: probeId,
semanticKey,
candidateSplitHash: splitHash,
answerClass: "unsure" as const,
rawAnswer: "skip_probe",
inferenceState: skippedState as unknown as Record<string, unknown>,
posteriorBefore: posteriorMap(previous.candidates),
posteriorAfter: posteriorMap(skippedState.candidates),
scoreDeltas: scoreDeltas(
posteriorMap(previous.candidates),
posteriorMap(skippedState.candidates),
),
decisionStateFingerprint: inferenceFingerprintForState(
command.caseId,
evidenceFp,
skippedState,
),
reason: "choice" as const,
idempotencyKey: `skip_probe:${probeId}:unsure`,
candidateSetId: skippedState.candidate_set_id,
}
: null;
return persistApplied(accounting, command, {
focusId: focus.id,
questionId,
focusStatus: "skipped",
probeId,
optionId: "skip_probe",
scoring: false,
appliedInference: Boolean(inference),
answerClass: null,
sourceQuote: null,
year: typeof schema.probe_year === "number" ? schema.probe_year : null,
expectedRevision: previous?.revision ?? command.expectedRevision,
inference,
narration: composeChoiceNarration({
optionId: "skip_probe",
scoring: false,
appliedInference: false,
}),
userDisplay: CHOICE_SKIP_QUESTION_LABEL,
decisionState: skippedState,
userStopped: false,
dossier,
});
}
if (optionId === "stop" || command.action === STOP_ACTION) {
const narration = composeChoiceNarration({
optionId: "stop",
@@ -419,6 +525,7 @@ export async function persistNextInterviewAfterChoice(input: {
persisted?: boolean;
focusId?: string | null;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
}> {
const latest = input.dossier.latestResult
? {
@@ -461,6 +568,7 @@ export async function persistNextInterviewAfterChoice(input: {
nextAction: input.nextAction.type,
followup,
methods: plan.methods,
accepted: Boolean(input.dossier.case.acceptedTime),
})) {
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
@@ -474,6 +582,15 @@ export async function persistNextInterviewAfterChoice(input: {
: fallback,
choiceReady: false,
persisted: false,
followup: null,
};
}
if (input.dossier.case.acceptedTime && !followup) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
choiceReady: false,
persisted: false,
followup: null,
};
}
const persistedFocus = await persistFocusAfterChoice({
@@ -491,6 +608,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup,
};
}
if (open?.kind === "collect_spoken" && open.prompt) {
@@ -500,6 +618,7 @@ export async function persistNextInterviewAfterChoice(input: {
persisted: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup,
};
}
if (followup?.choice_frame) {
@@ -521,6 +640,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: false,
focusId: fallback.focus?.id ?? null,
focus: fallback.focus,
followup,
};
}
if (followup?.intent === "collect_method_evidence") {
@@ -529,7 +649,7 @@ export async function persistNextInterviewAfterChoice(input: {
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus };
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup };
}
return persistExhaustionCollect({
accounting: input.accounting,
@@ -545,6 +665,13 @@ export async function persistNextInterviewAfterChoice(input: {
});
}
if (!followup) {
if (input.dossier.case.acceptedTime) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
choiceReady: false,
followup: null,
};
}
if (isNonConvergingRangeOffer({
canOfferRange: input.nextAction.can_offer_range,
canAdopt: input.nextAction.can_adopt,
@@ -578,6 +705,7 @@ export async function persistNextInterviewAfterChoice(input: {
return {
hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback,
choiceReady: false,
followup,
};
}
@@ -838,6 +966,7 @@ export async function persistNextInterviewIfIdle(input: {
nextAction: decision.nextAction,
followup,
methods: plan.methods,
accepted: Boolean(dossier.case.acceptedTime),
})) {
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = adoptHostNarration({
@@ -865,6 +994,13 @@ export async function persistNextInterviewIfIdle(input: {
});
}
if (!followup) {
if (dossier.case.acceptedTime) {
return {
persisted: false,
choiceReady: false,
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
};
}
return { persisted: false, choiceReady: false, hostNarration: null };
}
const nextAction = publicNextAction(decision);
@@ -995,6 +1131,8 @@ async function persistApplied(
birthDate,
});
const nextAction = publicNextAction(nextDecision);
const accepted = Boolean(input.dossier.case.acceptedTime);
const skipThisProbe = input.optionId === "skip_probe";
let narrationPersisted = false;
let nextInterviewPersisted = false;
@@ -1002,11 +1140,16 @@ async function persistApplied(
let hostNarration = input.narration;
let skippedNextInterview = false;
let nextFocus: ConversationFocus | null = null;
let nextFollowup: MethodFollowup | null = null;
let turnId: string | null = null;
if (
command.deferFollowup !== true
&& input.userStopped !== true
&& shouldContinueAfterStructuredChoice(nextAction)
&& (
shouldContinueAfterStructuredChoice(nextAction)
|| skipThisProbe
|| accepted
)
) {
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
@@ -1022,8 +1165,11 @@ async function persistApplied(
nextChoiceReady = nextInterview.choiceReady;
skippedNextInterview = nextInterview.persisted === false;
nextFocus = nextInterview.focus ?? null;
nextFollowup = nextInterview.followup ?? null;
if (nextInterview.hostNarration) {
if (skippedNextInterview) {
if (accepted || skipThisProbe) {
hostNarration = nextInterview.hostNarration;
} else if (skippedNextInterview) {
hostNarration = `${input.narration}
${nextInterview.hostNarration}`;
@@ -1032,10 +1178,10 @@ ${nextInterview.hostNarration}`;
}
}
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
const adoptionFacts = nextAction.can_adopt
const adoptionFacts = !accepted && nextAction.can_adopt
? adoptDeliveryFacts(nextDecision, liveDossier)
: null;
const adoptionNarration = nextAction.can_adopt
const adoptionNarration = !accepted && nextAction.can_adopt
? withProspectiveWindows(deliveryAdoptNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
@@ -1045,7 +1191,7 @@ ${nextInterview.hostNarration}`;
}), liveDossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
const completedRangeNarration = nextAction.type === "complete_with_range"
const completedRangeNarration = !accepted && nextAction.type === "complete_with_range"
? withProspectiveWindows(`${completedRangePrefix}
${nonConvergingRangeNarration({
@@ -1057,7 +1203,10 @@ ${nonConvergingRangeNarration({
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
: null;
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
if (accepted && !keptNextQuestion) {
hostNarration = RECTIFICATION_USER_COPY.postAdoptVerifyDone;
nextFollowup = null;
} else if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
}
@@ -1075,8 +1224,10 @@ ${nonConvergingRangeNarration({
if (turn.turnId) {
turnId = turn.turnId;
try {
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus;
const focus = nextFocus ?? (keptNextQuestion
? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus
: null);
if (focus) {
await linkFocusAskedTurn({
accounting,
@@ -1127,6 +1278,18 @@ ${nonConvergingRangeNarration({
narration,
userDisplay: input.userDisplay,
nextAction,
nextUserAction: buildNextUserAction({
scorableCount: input.dossier.evidence.length,
evidenceCount: input.dossier.evidence.length,
hasLatestResult: Boolean(input.dossier.latestResult),
selectionAllowed: nextAction.can_adopt,
sessionOutcome: typeof nextAction.session_outcome === "string"
? nextAction.session_outcome as SessionOutcomeKind
: "collect_evidence",
nextFollowup: keptNextQuestion ? nextFollowup : null,
workingTime: input.dossier.case.acceptedTime ?? nextAction.representative_time ?? null,
accepted,
}),
nextInterviewPersisted,
nextChoiceReady,
turnId,
@@ -20,10 +20,11 @@ import {
export const CHOICE_ACTION = "answer_choice" as const;
export const STOP_ACTION = "stop_and_review" as const;
export const SKIP_PROBE_ACTION = "skip_probe" as const;
export const DETERMINISTIC_CHOICE_MODEL = "deterministic:choice";
export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION;
export type ChoiceOptionId = ChoiceKey | "stop";
export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
export type ChoiceOptionId = ChoiceKey | "stop" | "skip_probe";
export type ChoiceActionStatus = "received" | "applied" | "narrated";
export type StructuredProbeDerivedContext = Readonly<{
@@ -38,7 +39,7 @@ export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): Answe
}
export function focusStatusForAnswer(answerClass: AnswerClass | null, optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" {
if (optionId === "stop" || answerClass === "unsure") return "skipped";
if (optionId === "stop" || optionId === "skip_probe" || answerClass === "unsure") return "skipped";
if (answerClass === "no") return "declined";
return "resolved";
}
@@ -99,6 +100,9 @@ export function composeChoiceNarration(input: {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
}
if (input.optionId === "skip_probe") {
return "已记录。这题先不计分,换一件事问。";
}
if (input.answerClass === "unsure") {
return "已记录。这题先不计分,换一件事问。";
}
@@ -56,6 +56,7 @@ export type RectificationChoiceFrame = Readonly<{
stop_message: string;
scoring: boolean;
choice_kind?: EventProbeChoiceKind;
skip_this_probe?: boolean;
}>;
export type AgentChoiceCopy = Readonly<{
@@ -86,6 +87,7 @@ export type RectificationChoiceCard = Readonly<{
case_revision: number | null;
focus_id: string | null;
choice_kind?: EventProbeChoiceKind;
skip_this_probe?: boolean;
}>;
export type ChoiceCardFollowup = Readonly<{
@@ -379,9 +381,10 @@ export function buildChoiceFrame(
);
if (!hypothesis) return null;
const domain = followupDomain(followup);
const skipQuestion = followup.intent === "reverse_verify"
const skipThisProbe = followup.intent === "reverse_verify"
|| followup.source === "reverse_verify";
const skipQuestion = skipThisProbe
|| followup.intent === "out_of_sample_check"
|| followup.source === "reverse_verify"
|| followup.source === "oos_blind";
return {
question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`,
@@ -403,6 +406,7 @@ export function buildChoiceFrame(
stop_message: skipQuestion ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring,
choice_kind: hypothesisKind(followup, input.probes),
...(skipThisProbe ? { skip_this_probe: true } : {}),
};
}
@@ -521,6 +525,7 @@ export function mergeChoiceCard(
case_revision: meta.case_revision ?? null,
focus_id: meta.focus_id ?? null,
choice_kind: frame.choice_kind,
...(frame.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
@@ -553,6 +558,7 @@ export function choiceCardFromPersistedVerifyCopy(input: {
case_revision: input.caseRevision,
focus_id: input.focusId,
choice_kind: "existence",
skip_this_probe: true,
};
}
@@ -614,5 +620,6 @@ export function parseRectificationChoiceCard(value: unknown): RectificationChoic
...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality"
? { choice_kind: row.choice_kind }
: {}),
...(row.skip_this_probe === true ? { skip_this_probe: true } : {}),
};
}
@@ -13,7 +13,7 @@ import {
rectificationFollowupCatalog,
} from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import { projectRectificationChoiceCard } from "./method-followup";
import { projectRectificationChoiceCard, buildMethodFollowupPlan, buildNextUserAction } from "./method-followup";
import {
internalObservationsFromWindowScan,
windowScanFromDecisionReceipt,
@@ -102,4 +102,37 @@ export function choiceCardFromCaseDossier(dossier: {
});
}
export function nextUserActionFromDossier(dossier: Parameters<typeof choiceCardFromCaseDossier>[0]) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
const accepted = Boolean(dossier.case.acceptedTime);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: dossier.conversationSummary.activeFocus,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
observations,
sessionOutcome: decision.sessionOutcome,
...catalog,
accepted,
candidatesSeparated: decision.separation.sufficient,
holdoutValidation: decision.holdoutValidation,
});
return buildNextUserAction({
scorableCount: dossier.evidence.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: Boolean(dossier.latestResult),
selectionAllowed: decision.selectionAllowed,
sessionOutcome: decision.sessionOutcome,
nextFollowup: plan.next_followup,
workingTime: dossier.case.acceptedTime ?? decision.representativeTime ?? null,
accepted,
});
}
export { decideFromDossier, overlayPublicDecision } from "./decision-from-dossier";
@@ -17,7 +17,7 @@ export function isRectificationMessageOrigin(value: unknown): value is Rectifica
}
export function defaultMessageOrigin(action: string): RectificationMessageOrigin {
if (action === "answer_choice" || action === "stop_and_review") return "choice_click";
if (action === "answer_choice" || action === "stop_and_review" || action === "skip_probe") return "choice_click";
if (action === "opening") return "system_recovery";
return "typed";
}
@@ -545,10 +545,22 @@ const CONFLICT_PROBE_SOURCES = new Set<string>([
"dasha_activation",
]);
function remainingReverseVerifyProbes(
function reverseVerifyProbeAsked(
probe: DiscriminatingEventProbe,
askedKeys: ReadonlySet<string>,
): boolean {
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
const split = probe.candidate_split_hash ?? "";
return askedKeys.has(semantic)
|| (split !== "" && askedKeys.has(split))
|| askedKeys.has(`${probe.domain}.${probe.year}`);
}
export function remainingReverseVerifyProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
askedKeys: ReadonlySet<string> = new Set(),
birthDate?: string | null,
): DiscriminatingEventProbe[] {
const dasha: DiscriminatingEventProbe[] = [];
@@ -562,6 +574,7 @@ function remainingReverseVerifyProbes(
if (declined.has(probe.domain)) continue;
if (!anchoredQuality && probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
if (probeBelowAdultFloor(probe, birthDate)) continue;
if (reverseVerifyProbeAsked(probe, askedKeys)) continue;
if (CONFLICT_PROBE_SOURCES.has(probe.source)) {
dasha.push(probe);
} else {
@@ -571,6 +584,32 @@ function remainingReverseVerifyProbes(
return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY);
}
export function reverseVerifyChecksFromProbes(
probes: readonly DiscriminatingEventProbe[],
): ReadonlyArray<{ kind: "reverse_verify"; domain: string; year_label: string }> {
return probes.map((probe) => ({
kind: "reverse_verify" as const,
domain: probe.domain,
year_label: probe.year_label,
}));
}
export function reverseVerifyRemainingForAdopt(input: {
eventProbes?: readonly DiscriminatingEventProbe[];
evidence: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
askedProbeKeys?: readonly string[];
birthDate?: string | null;
}): DiscriminatingEventProbe[] {
return remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declinedDomains(input.declinedTopics ?? []),
new Set(input.askedProbeKeys ?? []),
input.birthDate,
);
}
function datedCollectionProbe(
probes: readonly DiscriminatingEventProbe[] | undefined,
domain: string,
@@ -581,12 +620,11 @@ function datedCollectionProbe(
function collectionYearFields(probe: DiscriminatingEventProbe | null): Pick<
MethodFollowup,
"probe_year" | "year_label" | "probe_month" | "semantic_key"
"probe_year" | "probe_month" | "semantic_key"
> {
if (!probe) return {};
return {
probe_year: probe.year,
year_label: probe.year_label,
...(probe.month ? { probe_month: probe.month } : {}),
semantic_key: probe.semantic_key,
};
@@ -996,9 +1034,7 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string |
: openingOther
? GENERIC_COLLECT_QUESTION
: (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION);
const period = followup.year_label
?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null);
return period ? `${period}${base}` : base;
return base;
}
export const DATED_COLLECT_ORDER = [
@@ -1720,7 +1756,13 @@ export function buildMethodFollowupPlan(input: {
}
if (input.accepted) {
const probe = remainingReverseVerifyProbes(input.eventProbes, input.evidence, declined, input.birthDate)[0] ?? null;
const probe = remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declined,
askedKeys,
input.birthDate,
)[0] ?? null;
const theme = probe ? REVERSE_VERIFY_THEME[probe.domain] : null;
const next = probe && theme
? makeFollowup({
@@ -1734,6 +1776,17 @@ export function buildMethodFollowupPlan(input: {
REVERSE_VERIFY_VARGA[probe.domain],
),
source: "reverse_verify",
semantic_key: probe.semantic_key ?? `${probe.domain}.${probe.year}`,
candidate_split_hash: probe.candidate_split_hash,
probe_year: probe.year,
year_label: probe.year_label,
probe_month: probe.month,
choice_kind: probe.choice_kind,
candidate_ids: probe.candidate_ids ?? candidateIdsFromProbe(probe),
expected_outcomes: probe.expected_outcomes,
style_options: probe.style_options,
information_gain: probe.information_gain ?? 0,
probe_id: probe.semantic_key,
}, true, true)
: null;
return {
@@ -9,7 +9,8 @@ export type RectificationRouteAction =
| "message"
| "read_only"
| "answer_choice"
| "stop_and_review";
| "stop_and_review"
| "skip_probe";
export const RECTIFICATION_ACTION_EXECUTION = {
opening: "stream",
@@ -17,6 +18,7 @@ export const RECTIFICATION_ACTION_EXECUTION = {
read_only: "read_only",
answer_choice: "immediate",
stop_and_review: "immediate",
skip_probe: "immediate",
} as const satisfies Record<RectificationRouteAction, "stream" | "hybrid" | "read_only" | "immediate">;
export function dossierHasNonemptyAssistantBody(
@@ -16,11 +16,11 @@ export type TurnQuestion = Readonly<{
prompt: string;
options: readonly TurnQuestionOption[] | null;
status: string;
answer_option: ChoiceKey | "stop" | null;
answer_option: ChoiceKey | "stop" | "skip_probe" | null;
probe_id: string | null;
}>;
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop"]);
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop", "skip_probe"]);
export function focusSpokenPrompt(schema: Readonly<Record<string, unknown>> | null | undefined): string | null {
if (!schema) return null;
@@ -91,7 +91,8 @@ export function parseTurnQuestion(value: unknown): TurnQuestion | null {
prompt,
options: options.length === 4 ? options : (row.kind === "choice" || row.kind === "reverse_verify" ? options : null),
status: typeof row.status === "string" ? row.status : "active",
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D" || answer === "stop"
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D"
|| answer === "stop" || answer === "skip_probe"
? answer
: null,
probe_id: typeof row.probe_id === "string" ? row.probe_id : null,
@@ -55,6 +55,7 @@ export type RectificationCaseSnapshotPayload = Readonly<{
current_question?: unknown;
choice_card?: unknown;
question_source?: unknown;
next_user_action?: Readonly<{ id?: unknown }>;
case?: Readonly<{
status?: unknown;
accepted_time?: unknown;
@@ -219,7 +220,7 @@ export function rectificationConversationState(input: Readonly<{
return "empty";
}
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable";
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable" | "verified_idle";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
@@ -230,6 +231,8 @@ export type RectificationQuestionGapInput = Readonly<{
questionLoadFailed: boolean;
/** Candidate cards are offered and not yet adopted: the reader, not the server, holds the next move. */
offerAwaitingReader?: boolean;
/** GET / structured-choice `next_user_action.id`; `start_consultation` means post-adopt verify is done. */
nextUserActionId?: string | null;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -254,6 +257,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.nextUserActionId === "start_consultation") return "verified_idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
// message carries live yet: both are a gap the next read may close.
@@ -52,9 +52,12 @@ test("free Agentic rectification turns bypass reservation, completion, and cance
const completeBilling = sourceBetween(rectificationRoute, "async complete(usage) {", "async release() {");
const releaseBilling = sourceBetween(rectificationRoute, "async release() {", " };\n\n try {");
// 旧:enum 到 stop_and_review
// 新:增加 skip_probe;非 message 仍不计费
// 原因:决策 4,核对跳过走即时结构化动作
assert.match(
rectificationRoute,
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review"\]\)/,
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/,
);
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 3);
assert.match(reserveBilling, /if \(action !== "message"\) \{[\s\S]*return \{ success: true, status: 200 \};/);
@@ -7,6 +7,7 @@ import { publicNextAction } from "../src/lib/rectification-agentic/core/rectific
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
adoptDeliveryFacts,
templatePostAdoptExplain,
validateAdoptNarration,
type AdoptDeliveryFacts,
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
@@ -681,14 +682,17 @@ test("adoptDeliveryFacts names the representative, range, stop class, and post-a
assert.ok(split);
assert.match(split!.label, /分不开/);
assert.ok((split!.count ?? 0) >= 1);
assert.ok(facts.post_adopt_verification.some((item) => (
item.kind === "holdout" && item.domain === "education" && item.year === 2016
)));
assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "family"));
assert.ok(facts.post_adopt_verification.some((item) => item.kind === "oos" && item.domain === "finance"));
assert.ok(facts.post_adopt_verification.some((item) => (
item.kind === "oos" && (item.domain === "health" || item.domain === "health_pressure")
)));
// 旧:holdout/oos 出现在 post_adopt_verification
// 新:仅出现采用后真会问的 reverse_verify 项,且与 buildMethodFollowupPlan({accepted:true}) 首题一致
// 原因:决策 3 推翻原任务书 §4
assert.equal(facts.post_adopt_verification.every((item) => item.kind === "reverse_verify"), true);
const acceptedPlan = planFrom(dossier, { accepted: true });
if (acceptedPlan.next_followup) {
assert.equal(facts.post_adopt_verification[0]?.domain, acceptedPlan.next_followup.domain);
} else {
assert.deepEqual(facts.post_adopt_verification, []);
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
}
});
test("adopt narration agent keeps in-fact copy and fail-closes the rest", async () => {
@@ -107,7 +107,10 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
assert.match(chat, /onOpeningConsumed\?\.\(\)/);
assert.match(chat, /void send\("opening", ""\)/);
assert.match(route, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review"\]\)/);
// 旧:enum 到 stop_and_review
// 新:增加 skip_probe(核对卡「这题跳过」)
// 原因:决策 4,跳过只关本题
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/);
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
@@ -6,6 +6,7 @@ import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/an
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
composeChoiceNarration,
isStructuredChoiceUserText,
@@ -28,7 +29,7 @@ import {
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics } from "../src/lib/rectification-agentic/user-copy.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
@@ -500,7 +501,7 @@ function adoptionDossier() {
}
function persistChoiceAccounting(
dossier: ReturnType<typeof twoProbeDossier>,
dossier: ReturnType<typeof dossierFixture>,
extra: Parameters<typeof fakeAccounting>[0] = {},
) {
return choiceAccounting({
@@ -884,7 +885,8 @@ test("answering the last discriminator persists a year-locked family collect foc
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
assert.equal(schema.choice, undefined);
assert.equal(schema.collect, true);
assert.match(schema.prompt ?? "", /2021/);
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family → 决策 7 采集题不带年份前缀
assert.equal(schema.prompt, RECTIFICATION_USER_COPY.collectQuestionByDomain.family);
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, false);
@@ -1198,6 +1200,9 @@ test("the public agent route treats structured choice as a non-model command", (
assert.doesNotMatch(block, /authorizeUsage/);
assert.match(route, /"answer_choice"/);
assert.match(route, /"stop_and_review"/);
assert.match(route, /"skip_probe"/);
assert.match(route, /SKIP_PROBE_ACTION/);
assert.doesNotMatch(route, /action === "skip_probe"[\s\S]{0,80}paused/);
assert.match(route, /export const maxDuration = 240/);
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /isPersistedFocusId\(focusId\)/);
@@ -1358,3 +1363,156 @@ test("degraded spoken collect strips discriminator identity", () => {
assert.doesNotMatch(spoken ?? "", /2016/);
assert.equal(spoken, spokenFollowupForUser(spokenFollowup));
});
function withAcceptedTime<T extends { case: { accepted_time: string | null } }>(dossier: T): T {
return {
...dossier,
case: { ...dossier.case, accepted_time: "05:07" },
};
}
function nextFocusHandler() {
return {
set_agentic_rectification_conversation_focus: (_fn: string, args: Record<string, unknown>) => ({
focus: {
id: NEXT_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-09-04T08:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
};
}
test("post-adopt skip_probe keeps the case open and asks the next verify question", async () => {
const accounting = persistChoiceAccounting(withAcceptedTime(twoProbeDossier()), nextFocusHandler());
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: SKIP_PROBE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: RELOCATION_2015_PROBE.id,
optionId: "skip_probe",
expectedRevision: twoProbeInference().revision,
});
assert.equal(applied.optionId, "skip_probe");
assert.notEqual(applied.nextAction.session_outcome, "provisional_range_user_stopped");
assert.doesNotMatch(applied.narration, new RegExp(RECTIFICATION_USER_COPY.adoptCue));
assert.equal(applied.nextChoiceReady, true);
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.equal(setFocus.args.p_intent, "reverse_verify");
const schema = setFocus.args.p_expected_answer_schema as { semantic_key?: string; choice?: { prompt?: string } };
assert.equal(schema.semantic_key, EDUCATION_2014_PROBE.semantic_key);
assert.match(applied.narration, /2014/);
assert.doesNotMatch(applied.narration, /2015 年 5 月/);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.equal(persist?.args.p_focus_status, "skipped");
const inference = persist?.args.p_inference as { answer_class?: string; raw_answer?: string } | null;
assert.equal(inference?.answer_class, "unsure");
assert.equal(inference?.raw_answer, "skip_probe");
});
function lastVerifyDossier() {
const inference = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 16 },
{ id: "05:10", time: "05:10", relative_support: 14 },
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month" },
{ id: "e2", domain: "career", year: 2024, precision: "day" },
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
{ id: "e4", domain: "career", year: 2026, precision: "day" },
{ id: "e5", domain: "relationship", year: 2024, precision: "day" },
],
probes: [EDUCATION_2014_PROBE],
});
const snapshot = candidateSnapshotFixture({
decisionReceipt: {
inference_state: inference,
discriminating_event_probes: [
eventProbeFromConflict(EDUCATION_2014_PROBE),
],
},
});
return dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_style",
expectedAnswerSchema: {
choice: {
prompt: "2014 年前后,有没有升学、转学或换学习环境?",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
probe_id: EDUCATION_2014_PROBE.id,
semantic_key: EDUCATION_2014_PROBE.semantic_key,
candidate_split_hash: EDUCATION_2014_PROBE.candidate_split_hash,
},
}),
}),
turns: [{
id: TURN_ID,
role: "assistant",
text: "2014 年前后,有没有升学、转学或换学习环境?",
status: "completed",
created_at: "2026-08-28T07:36:54.000Z",
completed_at: "2026-08-28T07:37:34.000Z",
}],
});
}
test("skipping the last post-adopt verify question closes with start_consultation", async () => {
const dossier = withAcceptedTime(lastVerifyDossier());
const accounting = persistChoiceAccounting(dossier, nextFocusHandler());
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: SKIP_PROBE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: EDUCATION_2014_PROBE.id,
optionId: "skip_probe",
expectedRevision: (
dossier.latest_result as {
decision_receipt: { inference_state: { revision: number } };
}
).decision_receipt.inference_state.revision,
});
assert.equal(applied.nextUserAction.id, "start_consultation");
assert.equal(applied.narration, RECTIFICATION_USER_COPY.postAdoptVerifyDone);
assert.equal(applied.nextChoiceReady, false);
assert.doesNotMatch(applied.narration, new RegExp(RECTIFICATION_USER_COPY.adoptCue));
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
@@ -151,6 +151,30 @@ test("two scoreable events do not open OOS even when holdout prompts exist", ()
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/);
});
test("family collect spoken stem has no year prefix while probe_year stays dated", () => {
const plan = buildMethodFollowupPlan({
evidence: TWO_SCOREABLE,
evidenceCollectionProbes: [{
year: 2021,
year_label: "2021 年前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "age_band",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect",
phase: "evidence_collection",
information_gain: 0,
semantic_key: "family.2021",
}],
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
});
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
const plan = collectPlan(FOUR_SCOREABLE, {
@@ -14,7 +14,7 @@ import {
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { buildMethodFollowupPlan, spokenFollowupForUser } 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";
@@ -484,7 +484,10 @@ test("revision 5 with uncovered relatives asks the dated family collect, not a y
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
assert.match(plan.next_followup?.year_label ?? "", /2021/);
// 旧:year_label 含 2021 → 新:采集口语不带年份前缀,year_label 不进 followup
// 原因:决策 7probe_year 仍锁定 dated 家庭采集,不出无年份 D12 卡
assert.equal(plan.next_followup?.year_label, undefined);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
@@ -239,6 +239,7 @@ const ADVANCING_RECTIFICATION_ACTIONS = [
"message",
"answer_choice",
"stop_and_review",
"skip_probe",
] as const;
function routeExitContract(route: string, turnExit: string) {
@@ -284,7 +285,10 @@ test("nonterminal invariant 1: every advancing action exits through an awaited c
failures.push("schema actions and execution declarations differ");
}
for (const action of ADVANCING_RECTIFICATION_ACTIONS) {
const immediate = action === "message" || action === "answer_choice" || action === "stop_and_review";
const immediate = action === "message"
|| action === "answer_choice"
|| action === "stop_and_review"
|| action === "skip_probe";
const streamed = action === "opening" || action === "message";
if (immediate && !contract.immediateAwaited) {
failures.push(`${action}: HTTP 200 response can return without awaiting the common exit gate`);
@@ -13,6 +13,7 @@ import {
parseWindowScan,
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
@@ -2266,9 +2267,12 @@ test("family collect attaches the collection-probe year instead of a yearless D2
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
assert.match(plan.next_followup?.year_label ?? "", /2021/);
// 旧:year_label / spoken 带「2021 年前后,」前缀
// 新:probe_year 仍是 2021,口语等于 USER_COLLECT_QUESTION.family,不填 year_label
// 原因:决策 7,年份只用于计分与去重,不进采集题干
assert.equal(plan.next_followup?.year_label, undefined);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后,家里/);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
});
test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => {
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
templatePostAdoptExplain,
validateAdoptNarration,
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
rectificationQuestionRetryActive,
type RectificationQuestionGapInput,
} from "../src/lib/rectification-surface-state.ts";
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
function verifyProbe(input: {
domain: "finance" | "relocation";
year: number;
semantic: string;
split: string;
}) {
return {
year: input.year,
year_label: `${input.year} 年前后`,
domain: input.domain,
event_family: input.domain === "finance"
? "收入明显变化、大笔支出或欠债"
: "搬家或长期住到外地",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: `${input.year} 年前后是否有相关经历。`,
role: "distinguish" as const,
style_options: STYLE_OPTIONS,
semantic_key: input.semantic,
candidate_split_hash: input.split,
};
}
const FINANCE_2013 = verifyProbe({
domain: "finance",
year: 2013,
semantic: "finance.2013.verify",
split: "finance-2013-split",
});
const RELOC_2017 = verifyProbe({
domain: "relocation",
year: 2017,
semantic: "relocation.2017.verify",
split: "reloc-2017-split",
});
const EDUCATION_EVIDENCE = [{
status: "confirmed" as const,
domain: "education",
datePrecision: "year" as const,
occurredFrom: "2008-01-01",
occurredTo: null,
}];
const gapBase: RectificationQuestionGapInput = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
};
test("post-adopt reverse-verify skips a probe already present in askedKeys", () => {
const remaining = buildMethodFollowupPlan({
evidence: EDUCATION_EVIDENCE,
accepted: true,
eventProbes: [FINANCE_2013, RELOC_2017],
askedProbeKeys: [FINANCE_2013.semantic_key],
});
assert.equal(remaining.next_followup?.domain, "relocation");
assert.equal(remaining.next_followup?.semantic_key, RELOC_2017.semantic_key);
const none = buildMethodFollowupPlan({
evidence: EDUCATION_EVIDENCE,
accepted: true,
eventProbes: [FINANCE_2013, RELOC_2017],
askedProbeKeys: [FINANCE_2013.semantic_key, RELOC_2017.semantic_key],
});
assert.equal(none.next_followup, null);
const action = buildNextUserAction({
scorableCount: 1,
evidenceCount: 1,
hasLatestResult: true,
selectionAllowed: true,
sessionOutcome: "adopt_representative",
nextFollowup: none.next_followup,
workingTime: "05:07",
accepted: true,
});
assert.equal(action.id, "start_consultation");
});
test("post-adopt reverse-verify also dedupes by candidate_split_hash", () => {
const plan = buildMethodFollowupPlan({
evidence: EDUCATION_EVIDENCE,
accepted: true,
eventProbes: [FINANCE_2013, RELOC_2017],
askedProbeKeys: [FINANCE_2013.candidate_split_hash],
});
assert.equal(plan.next_followup?.domain, "relocation");
assert.notEqual(plan.next_followup?.semantic_key, FINANCE_2013.semantic_key);
});
test("empty reverse-verify list forbids a 会拿…核对 promise", () => {
const facts = {
precision_stage: "ready_to_adopt" as const,
already_accepted: false,
credible_range: ["05:00", "05:06"] as const,
representative_minute: "05:00",
runner_up_minute: "05:06",
opening_window: ["04:45", "05:15"] as const,
active_candidates: [],
answered_rounds: 5,
stop_facts: [],
post_adopt_verification: [],
minutes: ["05:00", "05:06"],
years: [],
};
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
const denied = validateAdoptNarration("采用后会拿 2016 年教育题来核对。", facts);
assert.equal(denied.ok, false);
if (!denied.ok) assert.equal(denied.reason, "promise");
});
test("question gap: start_consultation is verified_idle; missing next_user_action keeps the old path", () => {
assert.equal(rectificationQuestionGapState({
...gapBase,
nextUserActionId: "start_consultation",
}), "verified_idle");
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
assert.equal(rectificationQuestionGapState({
...gapBase,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
}), "unavailable");
});
test("verified_idle UI is a closing line without reload, gap copy, or consult handoff", () => {
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(chat, /questionGap === "verified_idle"/);
assert.match(chat, /RECTIFICATION_USER_COPY\.postAdoptVerifyDone/);
assert.match(chat, /skip_this_probe/);
assert.match(chat, /SKIP_PROBE_ACTION/);
assert.doesNotMatch(chat, /rectification-consult-handoff/);
const idle = chat.slice(chat.indexOf('questionGap === "verified_idle"'));
assert.doesNotMatch(idle.slice(0, 400), /rectification-question-gap__copy/);
assert.doesNotMatch(idle.slice(0, 400), /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /已采用 \{savedTime\}/);
assert.match(chat, /之后新建对话即按此时间排盘/);
});
test("family collect spoken stem equals USER_COLLECT_QUESTION.family while probe_year stays dated", () => {
const plan = buildMethodFollowupPlan({
evidence: [
{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2011-01-01", occurredTo: null },
{ status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2014-01-01", occurredTo: null },
],
evidenceCollectionProbes: [{
year: 2021,
year_label: "2021 年前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "age_band",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect",
phase: "evidence_collection",
information_gain: 0,
semantic_key: "family.2021",
}],
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(plan.next_followup?.choice_frame, null);
const spoken = spokenFollowupForUser(plan.next_followup);
assert.equal(spoken, USER_COLLECT_QUESTION.family);
assert.doesNotMatch(spoken ?? "", /^\d{4} 年前后,/);
});
@@ -505,10 +505,13 @@ test("spoken collect followup persists a collect focus without a choice card", a
assert.equal(result.focus?.intent, "collect_method_evidence");
assert.equal(result.focus?.expectedAnswerSchema.collect, true);
assert.equal(result.focus?.expectedAnswerSchema.choice, undefined);
assert.match(String(result.focus?.expectedAnswerSchema.prompt ?? ""), /2021/);
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family
// 原因:决策 7,采集题不拼年份前缀;fixture 仍带 probe_year 供计分
assert.equal(result.focus?.expectedAnswerSchema.prompt, USER_COLLECT_QUESTION.family);
const openCollect = openQuestionFromPersistedFocus(result);
assert.equal(openCollect?.kind, "collect_spoken");
assert.match(openCollect?.prompt ?? "", /2021/);
assert.equal(openCollect?.prompt, USER_COLLECT_QUESTION.family);
assert.doesNotMatch(openCollect?.prompt ?? "", /2021/);
assert.notEqual(openCollect?.unrenderable, true);
});
@@ -77,6 +77,7 @@ test("the question gap is a live row with retries, then a reload; it never tells
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/);
assert.match(chat, /questionGap === "preparing" && \(/);
assert.match(chat, /questionGap === "unavailable" && \(/);
assert.match(chat, /questionGap === "verified_idle" && \(/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
@@ -56,6 +56,7 @@ test("question gap: preparing while retries remain, then unavailable with a relo
assert.equal(rectificationQuestionRetryActive("preparing"), true);
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
assert.equal(rectificationQuestionRetryActive("idle"), false);
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
});
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {