fix(rectification): ask remaining split lines one card at a time (BUG-661~663)
Independent Staging Quality Gate / validate (push) Successful in 12m9s
Independent Staging Quality Gate / publish (push) Successful in 2m17s

Dated-pool empty now yields existence cards per remaining layer, leftover-candidate copy, and an optional D9/D10 tie-break on the range card. Skill 10.0.25.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-13 16:50:25 +08:00
co-authored by Cursor
parent b063c66835
commit 530f260f76
63 changed files with 2184 additions and 323 deletions
@@ -11,7 +11,7 @@ import {
listV10ConversationFocuses,
} from "@/lib/rectification-agentic/v9/tool-service";
import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice";
import { dossierResponse } from "../route";
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
export const runtime = "nodejs";
@@ -4,26 +4,13 @@ import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
evidenceLedgerFingerprint,
loadV9CaseDossier,
loadV9CaseSkillIdentityStatus,
loadV9TurnReceipt,
receiptStatusFromTurn,
RectificationToolServiceError,
listV10ConversationFocuses,
questionSourceFromFocusList,
type ConversationFocusList,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision, nextUserActionFromDossier, stepStateFromCaseDossier } 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";
import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision";
import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/evidence-model";
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
export const runtime = "nodejs";
@@ -92,132 +79,3 @@ export async function GET(request: Request, context: RouteContext) {
);
}
}
export function dossierResponse(
dossier: V9CaseDossier,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
options: { fullDetail?: boolean; listed?: ConversationFocusList } = {},
) {
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
const listed = options.listed ?? { focuses: [] as const, available: true };
const turnsWithQuestions = attachQuestionsToTurns(dossier.turns, listed.focuses);
const fields = publicDecisionFields(decision);
const overlaid = dossier.latestResult
? overlayPublicDecision(dossier.latestResult, decision)
: fields;
const turns = attachOfferResultToTurns(turnsWithQuestions, {
resultId: dossier.latestResult?.resultId ?? null,
canAdopt: overlaid.can_adopt === true,
acceptedTime: dossier.case.acceptedTime,
});
return {
case: {
case_id: dossier.case.caseId,
session_id: dossier.case.sessionId,
status: dossier.case.status,
skill_name: dossier.case.skillName,
skill_version: dossier.case.skillVersion,
skill_identity_status: skillIdentity.status,
requires_skill_adoption: skillIdentity.requiresSkillAdoption,
candidate_range: dossier.case.candidateRange,
stage: dossier.case.stage,
accepted_time: dossier.case.acceptedTime,
confirmed_time: dossier.case.confirmedTime,
completed_at: dossier.case.completedAt,
closed_reason: dossier.case.closedReason,
last_activity_at: dossier.case.lastActivityAt,
case_revision: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.revision ?? 0,
},
turns: turns.map((turn) => ({
id: turn.id,
role: turn.role,
text: turn.text,
status: turn.status,
created_at: turn.createdAt,
receipt: turnReceipt(turn.id, receipts),
question: turn.question,
offer_result_id: turn.offer_result_id,
})),
evidence: dossier.evidence,
latest_result: dossier.case.stage === "block_scan" || decision.sessionOutcome === "compare_blocks"
? null
: dossier.latestResult
? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true)
: null,
interview: {
...fields,
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
},
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: choiceCardFromCaseDossier(dossier),
step_state: stepStateFromCaseDossier(dossier),
next_user_action: nextUserActionFromDossier(dossier),
question_source: questionSourceFromFocusList(listed),
};
}
function publicLatestResult(
latest: V9CaseDossier["latestResult"],
decision: ReturnType<typeof decideFromDossier>,
dossier: V9CaseDossier,
fullDetail: boolean,
) {
if (!latest) return null;
const projected = overlayPublicDecision(latest, decision);
const toolProjection = latestResultToolProjection(latest, decision);
const rangeDelivery = rangeDeliveryForSnapshot({
decisionReceipt: projected.decisionReceipt,
candidates: projected.candidates,
representativeTime: projected.representativeTime ?? decision.representativeTime,
representative_time: projected.representativeTime ?? decision.representativeTime,
credibleRange: projected.credible_range ?? decision.credibleRange,
credible_range: projected.credible_range ?? decision.credibleRange,
skill_verification_report: toolProjection.skill_verification_report,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
});
const withDelivery = {
...projected,
range_delivery: rangeDelivery,
rangeDelivery,
};
if (fullDetail || !projected.decisionReceipt) return withDelivery;
return {
...withDelivery,
decisionReceipt: slimDecisionReceipt(projected.decisionReceipt, dossier),
};
}
function turnReceipt(
turnId: string,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
) {
const receipt = receipts.find((item) => item?.turnId === turnId) ?? null;
if (!receipt) return null;
return {
turn_id: receipt.turnId,
skill_name: receipt.skillName,
skill_version: receipt.skillVersion,
engine_version: receipt.engineVersion,
status: receiptStatusFromTurn(receipt.status),
phases: receipt.phases.map((phase) => phase.phase),
tool_activities: receipt.toolActivities.map((activity) => ({
tool: activity.tool,
status: activity.status,
methods: activity.methods,
started_at: activity.startedAt ?? null,
elapsed_ms: activity.elapsedMs ?? null,
detail: activity.detail ?? null,
})),
tools: receipt.tools,
methods: receipt.methods,
started_at: receipt.startedAt,
completed_at: receipt.completedAt,
...(receipt.phases.some((phase) => phase.phase === "answer.host_fallback")
? { answer_origin: "host_fallback" as const }
: {}),
};
}
@@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isProductEnabled } from "@/lib/product-access";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ caseId: string }> };
const tieBreakSchema = z.object({
sessionId: z.string().uuid(),
requestId: z.string().uuid(),
}).strict();
/**
* POST /api/rectification/cases/[caseId]/tie-break
*
* Opt-in D9/D10 personality cards from the range card (BUG-663).
* Scoring stays on the existing tie_break path; this route only persists
* the next varga_style focus.
*/
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]/tie-break");
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
if (!await isProductEnabled("rectification")) {
return NextResponse.json(
{ error: "生时校正服务暂未开放", code: "rectification_product_disabled" },
{ status: 503 },
);
}
const { caseId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
}
const parsed = tieBreakSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_tie_break_request" }, { status: 400 });
}
const { data: caseRow, error: caseError } = await accounting.rpc(
"get_agentic_rectification_case",
{ p_user_id: user.id, p_case_id: caseId },
);
if (caseError) {
const message = caseError.message ?? "";
if (message.includes("agentic_rectification_case_not_found")) {
return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 });
}
return NextResponse.json({ error: "暂时无法开始参考题", code: "tie_break_failed" }, { status: 503 });
}
const caseView = Array.isArray(caseRow) ? caseRow[0] : caseRow;
const boundSessionId = caseView && typeof caseView === "object"
? (caseView as { session_id?: unknown }).session_id
: null;
if (typeof boundSessionId !== "string" || boundSessionId !== parsed.data.sessionId) {
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
}
try {
const { requestTieBreakPersonality } = await import(
"@/lib/rectification-agentic/v9/answer-choice"
);
const result = await requestTieBreakPersonality({
accounting,
userId: user.id,
caseId,
requestId: parsed.data.requestId,
});
if (!result.choiceReady) {
return NextResponse.json(
{ error: "现在没有可答的参考题", code: "tie_break_unavailable", ok: false },
{ status: 409 },
);
}
return NextResponse.json({
ok: true,
choiceReady: true,
hostNarration: result.hostNarration,
focusId: result.focus?.id ?? null,
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
return NextResponse.json(
{ error: "暂时无法开始参考题", code: error.code },
{ status: 409 },
);
}
return NextResponse.json({ error: "暂时无法开始参考题", code: "tie_break_failed" }, { status: 503 });
}
}