fix(rectification): dedupe post-adopt verify and skip this probe only
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:
+2
-1
@@ -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`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
|
||||
@@ -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
|
||||
// 原因:决策 7;probe_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", () => {
|
||||
|
||||
Reference in New Issue
Block a user