fix(rectification): prevent collect focus dead-end after choice answers
This commit is contained in:
@@ -1944,13 +1944,15 @@ export function buildMethodFollowupPlan(input: {
|
||||
const deferAdoption = sessionOutcome === "adopt_representative"
|
||||
|| sessionOutcome === "validated_range"
|
||||
|| sessionOutcome === "exact_minute_confirmed"
|
||||
|| sessionOutcome === "provisional_range"
|
||||
|| sessionOutcome === "provisional_range_user_stopped"
|
||||
|| sessionOutcome === "completed_with_range";
|
||||
const deferProvisionalDiscriminator = sessionOutcome === "provisional_range"
|
||||
&& next?.intent === "distinguish_candidates";
|
||||
const deferFollowup = deferAdoption || deferProvisionalDiscriminator;
|
||||
return {
|
||||
methods,
|
||||
next_followup: deferAdoption ? null : next,
|
||||
deferred_followup: deferAdoption ? next : null,
|
||||
next_followup: deferFollowup ? null : next,
|
||||
deferred_followup: deferFollowup ? next : null,
|
||||
session_outcome: sessionOutcome ?? "collect_evidence",
|
||||
stop_domain_rotation: true,
|
||||
do_not_poll: DO_NOT_POLL,
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
stampChoiceSchemaWithProbe,
|
||||
} from "@/lib/rectification-agentic/v9/inference-adapter";
|
||||
import {
|
||||
isCollectFocusSchema,
|
||||
openQuestionFromPersistedFocus,
|
||||
persistServerOwnedFocus,
|
||||
} from "@/lib/rectification-agentic/v9/server-focus";
|
||||
@@ -738,6 +739,7 @@ export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context)
|
||||
export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const { accounting, userId, caseId, turnId, attemptId, userMessage } = ctx;
|
||||
const engineVersion = v9EngineVersion();
|
||||
let hasReadCase = false;
|
||||
|
||||
const receipt = async (
|
||||
toolName: string,
|
||||
@@ -1109,6 +1111,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
)
|
||||
: projectTurnDecision(refreshed);
|
||||
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
|
||||
hasReadCase = true;
|
||||
return projection;
|
||||
}
|
||||
}
|
||||
@@ -1120,6 +1123,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
)
|
||||
: projectTurnDecision(dossier);
|
||||
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
|
||||
hasReadCase = true;
|
||||
return projection;
|
||||
} catch (error) {
|
||||
await receipt("rectification-read-case", "case.loaded", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
|
||||
@@ -1360,6 +1364,10 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
assertCaseRef(input);
|
||||
if (!hasReadCase) {
|
||||
await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
hasReadCase = true;
|
||||
}
|
||||
for (const item of input.items) {
|
||||
if (!isEvidenceKind(item.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind");
|
||||
if (!isEvidenceDomain(item.domain)) throw new RectificationToolServiceError("invalid_domain");
|
||||
@@ -1459,6 +1467,22 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
}));
|
||||
if (result.acceptedCount > 0) {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
const acceptedEvidenceId = result.items.find(
|
||||
(item) => item.outcome === "accepted" && item.evidenceId,
|
||||
)?.evidenceId ?? null;
|
||||
const activeFocus = dossier.conversationSummary.activeFocus;
|
||||
if (
|
||||
acceptedEvidenceId
|
||||
&& activeFocus
|
||||
&& activeFocus.intent === "collect_method_evidence"
|
||||
&& isCollectFocusSchema(activeFocus.expectedAnswerSchema)
|
||||
) {
|
||||
await resolveV10ConversationFocus(accounting, userId, input.caseId, {
|
||||
focusId: activeFocus.id,
|
||||
status: "resolved",
|
||||
evidenceId: acceptedEvidenceId,
|
||||
});
|
||||
}
|
||||
if (isResumableStatus(dossier.case.status as RectificationCaseStatus)) {
|
||||
await transitionV9CaseStatus(accounting, userId, input.caseId, "collecting_evidence");
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ import {
|
||||
USER_ID,
|
||||
candidateSnapshotFixture,
|
||||
computeFixture,
|
||||
conversationSummaryFixture,
|
||||
dossierFixture,
|
||||
activeFocusFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
@@ -918,14 +920,24 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin
|
||||
assert.match(JSON.stringify(projection.method_followup_plan.next_followup), /自然语言/);
|
||||
});
|
||||
|
||||
test("accepted batch evidence triggers server rescore without offering adoption", async () => {
|
||||
test("accepted batch evidence resolves a spoken collect focus before the next question", async () => {
|
||||
const restore = stubEngine(ENGINE_SCORE);
|
||||
let collectFocusResolved = false;
|
||||
try {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [educationEvidence],
|
||||
latestResult: null,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus: collectFocusResolved
|
||||
? null
|
||||
: activeFocusFixture({
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "education",
|
||||
expectedAnswerSchema: { collect: true, prompt: "有没有记得住时间的升学经历?" },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
record_agentic_rectification_evidence_batch: () => ({
|
||||
@@ -943,6 +955,15 @@ test("accepted batch evidence triggers server rescore without offering adoption"
|
||||
rejected_count: 0,
|
||||
focus_id: null,
|
||||
}),
|
||||
resolve_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
collectFocusResolved = true;
|
||||
return {
|
||||
focus_id: args.p_focus_id,
|
||||
status: args.p_status,
|
||||
evidence_id: args.p_evidence_id,
|
||||
idempotent: false,
|
||||
};
|
||||
},
|
||||
persist_agentic_rectification_candidate_v2: () => ({
|
||||
...candidateSnapshotFixture(),
|
||||
cached: false,
|
||||
@@ -959,6 +980,7 @@ test("accepted batch evidence triggers server rescore without offering adoption"
|
||||
execute(input: unknown): Promise<{
|
||||
accepted_count: number;
|
||||
rescore: { status: string; executed_methods: string[]; error_code: string | null };
|
||||
open_question: { prompt?: string } | null;
|
||||
}>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
@@ -975,6 +997,15 @@ test("accepted batch evidence triggers server rescore without offering adoption"
|
||||
assert.equal(result.accepted_count, 1);
|
||||
assert.equal(result.rescore.status, "completed");
|
||||
assert.ok(result.rescore.executed_methods.includes("d1-rashi"));
|
||||
assert.equal(accounting.calls[0]?.fn, "get_agentic_rectification_case_dossier");
|
||||
const resolvedFocus = accounting.calls.find((call) => call.fn === "resolve_agentic_rectification_conversation_focus");
|
||||
assert.equal(resolvedFocus?.args.p_focus_id, FOCUS_ID);
|
||||
assert.equal(resolvedFocus?.args.p_status, "resolved");
|
||||
assert.equal(resolvedFocus?.args.p_evidence_id, EDUCATION_ID);
|
||||
const recordIndex = accounting.calls.findIndex((call) => call.fn === "record_agentic_rectification_evidence_batch");
|
||||
const resolveIndex = accounting.calls.findIndex((call) => call.fn === "resolve_agentic_rectification_conversation_focus");
|
||||
assert.ok(recordIndex >= 0 && resolveIndex > recordIndex);
|
||||
assert.doesNotMatch(result.open_question?.prompt ?? "", /升学经历/);
|
||||
const persistCall = accounting.calls.find((call) => call.fn === "persist_agentic_rectification_candidate_v2");
|
||||
assert.ok(persistCall);
|
||||
const receipt = persistCall.args.p_decision_receipt as { window_scan?: { d9_candidates_differ?: boolean; d9_sign_names?: unknown } };
|
||||
@@ -2306,7 +2337,7 @@ test("coverage-complete tie with encoded D24/D10 collects a dated move instead o
|
||||
}), "discriminate_candidates");
|
||||
});
|
||||
|
||||
test("coverage-complete tie with no remaining split offers a provisional range", () => {
|
||||
test("provisional range still exposes method coverage followup", () => {
|
||||
const packet = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "05:00-05:04",
|
||||
candidateTimes: DUMP_SCORES.map((item) => item.time),
|
||||
@@ -2329,7 +2360,9 @@ test("coverage-complete tie with no remaining split offers a provisional range",
|
||||
contrastPacket: packet,
|
||||
sessionOutcome: "provisional_range",
|
||||
});
|
||||
assert.equal(plan.next_followup, null);
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(plan.next_followup?.domain, "horary");
|
||||
assert.equal(plan.deferred_followup, null);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
|
||||
Reference in New Issue
Block a user