fix(rectification): invite-first collect, holdout at 4 events, Skill 10.0.23 (BUG-646–648)

Stop domain-wheel collecting and age-band years in prompts. Ask until the training gate, then discriminate until convergence, then deliver a range plus a concrete follow-up. Reserve holdout only with four dated events.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-11 01:43:02 +08:00
co-authored by Cursor
parent cb04529728
commit dd8f35f7ba
70 changed files with 2576 additions and 1183 deletions
@@ -57,6 +57,7 @@ import {
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
RECTIFICATION_STOPPED_NOTICE,
isAbortError,
rectificationConversationState,
@@ -66,6 +67,9 @@ import {
contrastProbesFromReceipt,
searchWindowFromSnapshot,
caseStageFromSnapshot,
interviewCollectWaiting,
interviewStopReasonFromSnapshot,
interviewSessionOutcomeFromSnapshot,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import { RectificationTimeline } from "@/components/rectification-timeline";
@@ -418,6 +422,8 @@ type CaseSnapshotState = Readonly<{
nextUserActionId: string | null;
searchWindow: readonly [string, string] | null;
stage: RectificationTimelineStage | null;
interviewStopReason: string | null;
interviewSessionOutcome: string | null;
}>;
function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
@@ -441,6 +447,8 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
nextUserActionId: nextUserActionIdFromSnapshot(payload),
searchWindow: searchWindowFromSnapshot(payload.case?.candidate_range),
stage: caseStageFromSnapshot(payload.case?.stage),
interviewStopReason: interviewStopReasonFromSnapshot(payload),
interviewSessionOutcome: interviewSessionOutcomeFromSnapshot(payload),
};
}
@@ -484,6 +492,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [searchWindow, setSearchWindow] = useState<readonly [string, string] | null>(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null);
const [caseStage, setCaseStage] = useState<RectificationTimelineStage | null>(() => caseSnapshotState(initialSnapshot)?.stage ?? null);
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [interviewStopReason, setInterviewStopReason] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.interviewStopReason ?? null);
const [interviewSessionOutcome, setInterviewSessionOutcome] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.interviewSessionOutcome ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
@@ -612,7 +622,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
const applyCaseSnapshot = useCallback((payload: RectificationCaseSnapshotPayload | {
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
@@ -620,6 +630,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
turns?: unknown;
question_source?: unknown;
next_user_action?: { id?: unknown };
interview?: { stop_reason?: unknown; session_outcome?: unknown };
case?: {
status?: unknown;
accepted_time?: unknown;
@@ -629,6 +640,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
};
} | null) => {
if (!payload) return;
const snapshot = payload as RectificationCaseSnapshotPayload;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
@@ -653,6 +665,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setInterviewStopReason(interviewStopReasonFromSnapshot(snapshot));
setInterviewSessionOutcome(interviewSessionOutcomeFromSnapshot(snapshot));
setCaseSnapshotLoaded(true);
if (nextQuestion || nextChoice || acceptedTime || confirmedTime) {
setQuestionRepairAttempts(0);
@@ -1508,7 +1522,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
// `unavailable` (copy and a repair button) — never bare copy telling the
// reader to wait for the server.
// reader to wait for the server. Collect-waiting is the training-gate gap:
// no next card, keep the case open, do not show 「没有拿到下一个问题」.
const collectWaiting = interviewCollectWaiting({
stopReason: interviewStopReason,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
questionMissing: currentQuestion === null,
offeringCards: showSelectionCards,
});
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
questionMissing: currentQuestion === null,
@@ -1516,6 +1537,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus"),
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
collectWaiting,
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
@@ -1853,7 +1875,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? "点上面的选项即可;想补一句细节再写"
: (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
: questionGap === "collect_waiting"
? RECTIFICATION_COLLECT_WAITING_PLACEHOLDER
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
inputDisabled={readonly}
submitLabel="发送"