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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user