fix(rectification): ask remaining split lines one card at a time (BUG-661~663)
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user