fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
loadV9CaseDossier,
|
||||
loadV9CaseSkillIdentityStatus,
|
||||
loadV9TurnReceipt,
|
||||
RectificationToolServiceError,
|
||||
listV10ConversationFocuses,
|
||||
} from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice";
|
||||
import { dossierResponse } from "../route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = { params: Promise<{ caseId: string }> };
|
||||
|
||||
const repairSchema = z.object({
|
||||
sessionId: z.string().uuid().optional(),
|
||||
}).strict();
|
||||
|
||||
/**
|
||||
* POST /api/rectification/cases/[caseId]/repair-exit
|
||||
*
|
||||
* Repair a turn that settled without a visible next question or card.
|
||||
* Only runs ensureNonTerminalTurnExit, then returns the same snapshot as GET.
|
||||
*/
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit");
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
|
||||
}
|
||||
const parsed = repairSchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "请求内容不正确", code: "invalid_repair_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureNonTerminalTurnExit({
|
||||
accounting,
|
||||
userId: user.id,
|
||||
caseId,
|
||||
});
|
||||
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
|
||||
if (parsed.data.sessionId && dossier.case.sessionId !== parsed.data.sessionId) {
|
||||
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
|
||||
}
|
||||
const skillIdentity = await loadV9CaseSkillIdentityStatus(accounting, user.id, caseId);
|
||||
const receipts = await Promise.all(
|
||||
dossier.turns.map(async (turn) => {
|
||||
try {
|
||||
return await loadV9TurnReceipt(accounting, user.id, caseId, turn.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...dossierResponse(dossier, receipts, skillIdentity, { listed }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const message = error.message;
|
||||
if (message.includes("agentic_rectification_case_not_found")) {
|
||||
return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法接上下一个问题", code: "rectification_repair_failed" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
function dossierResponse(
|
||||
export function dossierResponse(
|
||||
dossier: V9CaseDossier,
|
||||
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
|
||||
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
|
||||
RECTIFICATION_QUESTION_PREPARING_LABEL,
|
||||
RECTIFICATION_QUESTION_RELOAD_LABEL,
|
||||
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
|
||||
RECTIFICATION_QUESTION_REPAIR_LIMIT,
|
||||
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
@@ -484,6 +486,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
|
||||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||||
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
|
||||
const [questionRepairing, setQuestionRepairing] = useState(false);
|
||||
const [openingRequested, setOpeningRequested] = useState(false);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
@@ -547,6 +551,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setBusy(value);
|
||||
// A turn starting is a fresh chance for the next question to arrive.
|
||||
if (value) setQuestionRetryAttempts(0);
|
||||
if (value) setQuestionRepairAttempts(0);
|
||||
onPendingChange?.(value);
|
||||
}, [onPendingChange]);
|
||||
|
||||
@@ -649,6 +654,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setCaseStatus(nextCaseStatus);
|
||||
setNextUserActionId(nextActionId || null);
|
||||
setCaseSnapshotLoaded(true);
|
||||
if (nextQuestion || nextChoice || acceptedTime || confirmedTime) {
|
||||
setQuestionRepairAttempts(0);
|
||||
}
|
||||
if (confirmedTime) {
|
||||
setSavedTime(confirmedTime);
|
||||
setSavedStatus("confirmed");
|
||||
@@ -1500,7 +1508,7 @@ 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 reload button) — never bare copy telling the
|
||||
// `unavailable` (copy and a repair button) — never bare copy telling the
|
||||
// reader to wait for the server.
|
||||
const questionGap = rectificationQuestionGapState({
|
||||
liveQuestionVisible: liveQuestionOnMessages,
|
||||
@@ -1532,7 +1540,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
});
|
||||
// Question recovery: the turn already refetched once on completion; while
|
||||
// the gap is `preparing`, refetch on a timer up to the retry limit, then
|
||||
// hand the reader a reload button. Attempts reset once a question arrives
|
||||
// hand the reader a repair button. Attempts reset once a question arrives
|
||||
// or another turn starts (both in handlers, never in an effect).
|
||||
useVisibilityAwarePoll({
|
||||
enabled: questionGap === "preparing",
|
||||
@@ -1546,9 +1554,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
async function refetchQuestion() {
|
||||
await loadCaseSnapshot();
|
||||
}
|
||||
function reloadQuestion() {
|
||||
setQuestionRetryAttempts(0);
|
||||
void refetchQuestion();
|
||||
async function repairQuestion() {
|
||||
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
|
||||
setQuestionRepairing(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
},
|
||||
);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (response.ok && payload) {
|
||||
startTransition(() => {
|
||||
applyCaseSnapshot(payload);
|
||||
});
|
||||
const recovered = Boolean(
|
||||
currentQuestionFromSnapshot(payload.current_question)
|
||||
|| parseRectificationChoiceCard(payload.choice_card)
|
||||
|| payload.case?.accepted_time
|
||||
|| payload.case?.confirmed_time
|
||||
);
|
||||
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
|
||||
} else {
|
||||
await loadCaseSnapshot();
|
||||
setQuestionRepairAttempts((current) => current + 1);
|
||||
}
|
||||
} catch {
|
||||
setQuestionRepairAttempts((current) => current + 1);
|
||||
} finally {
|
||||
setQuestionRepairing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// A Case with no turns and no automatic first turn (the server never starts
|
||||
@@ -1768,10 +1807,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
)}
|
||||
{questionGap === "unavailable" && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={reloadQuestion}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={() => void repairQuestion()} disabled={questionRepairing}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
|
||||
|
||||
@@ -32,7 +32,7 @@ import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "
|
||||
import { toAgentModelFinishReason } from "../../agent-observability.ts";
|
||||
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
|
||||
import { decideFromDossier } from "./decision-from-dossier";
|
||||
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { alreadyDelivered } from "./delivery-turn-guard";
|
||||
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import {
|
||||
@@ -622,6 +622,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
console.warn(
|
||||
`[rectification-v9] persist interview before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
|
||||
);
|
||||
try {
|
||||
interviewIdle = await ensureNonTerminalTurnExit({ accounting, userId, caseId });
|
||||
} catch (repairError) {
|
||||
console.warn(
|
||||
`[rectification-v9] nonterminal exit after idle failure failed case=${caseId} reason=${safeErrorCode(repairError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action === "evidence") {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
withNakshatraBoundaryProbe,
|
||||
} from "./inference-adapter";
|
||||
import { datedEventCount } from "./divergence-panel.ts";
|
||||
import { logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
|
||||
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
|
||||
import {
|
||||
decideAfterInferenceChange,
|
||||
decideFromDossier,
|
||||
@@ -114,6 +114,47 @@ export function isExhaustedGateState(input: {
|
||||
&& !input.accepted;
|
||||
}
|
||||
|
||||
export function looksLikeTerminalNoteNarration(text: string | null | undefined): boolean {
|
||||
const trimmed = text?.trim() ?? "";
|
||||
if (!trimmed) return false;
|
||||
return trimmed.includes(RECTIFICATION_USER_COPY.noCandidatesGate)
|
||||
|| trimmed.includes("这只是代表性候选")
|
||||
|| /还差(?: \d+ 件)?带月份的经历/.test(trimmed)
|
||||
|| trimmed.includes("两件事的日期还没对清")
|
||||
|| trimmed.includes("这次给出的范围");
|
||||
}
|
||||
|
||||
function terminalNoteHostPresent(input: {
|
||||
caseId: string;
|
||||
dossier: {
|
||||
turns?: readonly Readonly<{ role: string; text: string | null }>[];
|
||||
latestResult?: { resultId?: string } | null;
|
||||
};
|
||||
}): boolean {
|
||||
const resultId = input.dossier.latestResult?.resultId ?? "";
|
||||
if (alreadyDelivered({
|
||||
caseId: input.caseId,
|
||||
resultId,
|
||||
hasUserMessage: false,
|
||||
})) return true;
|
||||
return (input.dossier.turns ?? []).some((turn) => (
|
||||
turn.role === "assistant" && looksLikeTerminalNoteNarration(turn.text)
|
||||
));
|
||||
}
|
||||
|
||||
function logDuplicateFocus(caseId: string, questionId: string | null | undefined): void {
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_focus_duplicate",
|
||||
case_id: caseId,
|
||||
question_id: questionId ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function exhaustionCarrierNarration(range: string | null | undefined, gate: string | null | undefined): string {
|
||||
return [range, gate].filter((item): item is string => Boolean(item?.trim())).join("")
|
||||
|| RECTIFICATION_USER_COPY.noCandidatesGate;
|
||||
}
|
||||
|
||||
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
|
||||
return `${askedTurnId ?? caseId}:gate`;
|
||||
}
|
||||
@@ -132,6 +173,9 @@ export async function persistExhaustionGateTurn(input: {
|
||||
resultId: input.resultId ?? null,
|
||||
terminalNote: true,
|
||||
});
|
||||
if (input.resultId) {
|
||||
markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
|
||||
}
|
||||
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
|
||||
userMessage: null,
|
||||
@@ -868,6 +912,9 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
}
|
||||
if (followup?.intent === "collect_method_evidence") {
|
||||
const spoken = spokenFollowupForUser(followup);
|
||||
if (persistedFocus.status === "duplicate_focus") {
|
||||
logDuplicateFocus(input.caseId, persistedFocus.questionId);
|
||||
}
|
||||
if (
|
||||
spoken
|
||||
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
|
||||
@@ -945,6 +992,9 @@ async function persistFocusAfterChoice(input: {
|
||||
|| persisted.status === "duplicate_focus"
|
||||
|| !input.followup
|
||||
) {
|
||||
if (persisted.status === "duplicate_focus") {
|
||||
logDuplicateFocus(input.caseId, persisted.questionId);
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) {
|
||||
@@ -1422,12 +1472,28 @@ async function persistExhaustionCollect(input: {
|
||||
const persisted = Boolean(spoken) && (
|
||||
persistedFocus.status === "created" || persistedFocus.status === "already_open"
|
||||
);
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: spoken ?? range,
|
||||
focus: persistedFocus.focus,
|
||||
};
|
||||
if (persisted) {
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: spoken ?? range,
|
||||
focus: persistedFocus.focus,
|
||||
};
|
||||
}
|
||||
// duplicate_focus: this ask is already closed; fall through to adopt/gate.
|
||||
// Other persist misses still return the spoken collect so mid-session
|
||||
// turns keep a next question instead of a terminal gate.
|
||||
if (persistedFocus.status !== "duplicate_focus") {
|
||||
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
|
||||
if (hostNarration) {
|
||||
return {
|
||||
persisted: false,
|
||||
choiceReady: false,
|
||||
hostNarration,
|
||||
focus: persistedFocus.focus,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
|
||||
const adopted = {
|
||||
@@ -1463,7 +1529,7 @@ async function persistExhaustionCollect(input: {
|
||||
openingRange: openingRangeFromDossier(dossier),
|
||||
variant: "intermediate",
|
||||
});
|
||||
const hostNarration = [range, gate].filter(Boolean).join("");
|
||||
const hostNarration = exhaustionCarrierNarration(range, gate);
|
||||
return {
|
||||
persisted: false,
|
||||
choiceReady: false,
|
||||
@@ -1713,42 +1779,13 @@ async function inspectNonTerminalTurnExit(input: {
|
||||
birthDate,
|
||||
snapshotCurrent: rescored.snapshotCurrent,
|
||||
});
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const remainingCollect = exhaustionSpokenCollectFollowup({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
answeredProbes: catalog.answeredProbes,
|
||||
eventProbes: catalog.eventProbes,
|
||||
});
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: dossier.evidence,
|
||||
activeFocus: null,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
...catalog,
|
||||
birthDate,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}),
|
||||
});
|
||||
const exhausted = isExhaustedGateState({
|
||||
remainingCollect,
|
||||
methods: plan.methods,
|
||||
canAdopt: decision.canAdopt,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
});
|
||||
const satisfied = Boolean(
|
||||
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|
||||
|| dossier.case.acceptedTime
|
||||
|| dossier.case.confirmedTime
|
||||
|| decision.completionStatus === "provisional_range_user_stopped"
|
||||
|| publicCanAdopt(decision)
|
||||
|| exhausted
|
||||
|| terminalNoteHostPresent({ caseId: input.caseId, dossier })
|
||||
);
|
||||
return { dossier, decision, satisfied };
|
||||
}
|
||||
@@ -1775,11 +1812,38 @@ export async function ensureNonTerminalTurnExit(input: {
|
||||
decision: before.decision,
|
||||
decisionReceipt: before.dossier.latestResult?.decisionReceipt,
|
||||
});
|
||||
if (repaired.persisted && repaired.focus) {
|
||||
const afterFocus = await inspectNonTerminalTurnExit(input);
|
||||
if (!afterFocus.satisfied && !repaired.hostNarration) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
const hostNarration = (repaired.hostNarration ?? "").trim()
|
||||
|| RECTIFICATION_USER_COPY.noCandidatesGate;
|
||||
try {
|
||||
await persistExhaustionGateTurn({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
hostNarration,
|
||||
resultId: before.dossier.latestResult?.resultId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] persist exhaustion gate during exit repair failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
||||
);
|
||||
}
|
||||
const after = await inspectNonTerminalTurnExit(input);
|
||||
if (!after.satisfied && !repaired.terminalNote) {
|
||||
if (!after.satisfied && !hostNarration) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
|
||||
}
|
||||
return repaired;
|
||||
return {
|
||||
...repaired,
|
||||
persisted: true,
|
||||
hostNarration,
|
||||
terminalNote: true,
|
||||
};
|
||||
}
|
||||
|
||||
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
|
||||
|
||||
@@ -1778,6 +1778,15 @@ function holdoutAskFields(
|
||||
};
|
||||
}
|
||||
|
||||
function declinedForHoldout(declined: ReadonlySet<string>): Set<string> {
|
||||
const next = new Set(declined);
|
||||
if (declinedHealth(declined)) {
|
||||
next.add("health");
|
||||
next.add("health_pressure");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function holdoutFollowupFor(
|
||||
input: {
|
||||
evidence: readonly MethodFollowupEvidence[];
|
||||
@@ -1788,21 +1797,13 @@ export function holdoutFollowupFor(
|
||||
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
|
||||
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
|
||||
const occupied = holdoutOccupiedDomains(input.evidence);
|
||||
const healthDeclined = declinedHealth(declined);
|
||||
const prompt = (input.oosBlindPrompts ?? []).find((item) => {
|
||||
if (!item.domain || occupied.has(item.domain)) return false;
|
||||
if (item.domain === "health" || item.domain === "health_pressure") {
|
||||
return !healthDeclined;
|
||||
}
|
||||
return !declined.has(item.domain);
|
||||
}) ?? null;
|
||||
const reserved = (input.holdoutEvents ?? []).find((item) => {
|
||||
if (item.year == null || occupied.has(item.domain)) return false;
|
||||
if (item.domain === "health" || item.domain === "health_pressure") {
|
||||
return !healthDeclined;
|
||||
}
|
||||
return !declined.has(item.domain);
|
||||
}) ?? null;
|
||||
const blocked = declinedForHoldout(declined);
|
||||
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
|
||||
Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain)
|
||||
)) ?? null;
|
||||
const reserved = (input.holdoutEvents ?? []).find((item) => (
|
||||
item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain)
|
||||
)) ?? null;
|
||||
return holdoutAskFields(prompt, reserved);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
|
||||
|
||||
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
|
||||
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
|
||||
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
|
||||
@@ -291,7 +293,7 @@ export type RectificationQuestionGapInput = Readonly<{
|
||||
* The gap between a settled turn and its next question. While retries remain
|
||||
* it is `preparing` (one live row, timed refetches); once they run out, or the
|
||||
* server itself reports the question unavailable, it is `unavailable` with a
|
||||
* manual reload. A snapshot that never arrived (hydration timed out) is a gap
|
||||
* repair button. A snapshot that never arrived (hydration timed out) is a gap
|
||||
* too: the same retries fill it. Questions themselves live inside assistant
|
||||
* messages, so a visible live question means there is no gap.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user