fix(rectification): render adoption carrier on deterministic choice path
This commit is contained in:
@@ -6942,3 +6942,19 @@
|
||||
- 相关记录:BUG-441、BUG-449、BUG-450
|
||||
- 复发自:BUG-450
|
||||
- 修复版本:`fix(rectification): prevent silent collect focus stalls`(Skill 保持 `10.0.13`)
|
||||
|
||||
## BUG-453 | 可采用区间已由服务端放行但前端不渲染时间卡
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-30
|
||||
- 最近更新:2026-08-30
|
||||
- 影响面:`rectification-agentic-chat.tsx` 时间卡渲染门控、`choice-action` 点选快路径、Case GET 默认投影
|
||||
- 用户现象:答完最后一道选择题后助手只说「已记录你的选择,并更新了候选比较。」;界面没有时间卡也没有下一问,流程停住。
|
||||
- 触发条件:服务端已给出 `can_adopt=true`、`selection_allowed=true`、`precision_stage=ready_to_adopt`,但当前轮是确定性点选路径,没有产生 `rectification-offer-candidates` 模型工具 receipt;前端仍以历史 `offeredSelectionOnce` 作为时间卡前置条件。
|
||||
- 根因:服务端允许采用,前端却把承载绑定到模型是否调用某个工具;确定性点选路径不会产生该 receipt step,因此 `selectionAllowed=true` 仍无法渲染时间卡。点选快路径原正文也只保留确认句,没有区间、代表分钟和采用提示。
|
||||
- 修复:P0:时间卡改为只依赖服务端 `selectionAllowed=true` 与 `canAdopt=true`,保留最新已结算助手消息、忙碌/只读/重生成状态和选择卡互斥门;不再依赖 `offeredSelectionOnce`。P1:点选进入 `offer_provisional_range + can_adopt=true` 时由服务端确定性写出可信区间、代表分钟、代表分钟免责声明和「可以从下面的时间里选一个采用」;该正文同时落入确定性 turn。P2:非终态轮末只看 `current_question` 或决策 `canAdopt`,缺失时确定性补口述采集焦点并记录结构化日志。P3:Case GET 默认只保留活跃候选/代表分钟对应宫位表与当前焦点 probe;`detail=full` 保留完整 receipt;`decision`、`gates`、`inference_state` 不被瘦身修改。模型侧继续使用既有的精简 projection。
|
||||
- 验证:新增/更新前端门控与 `canAdopt` camel/snake 解析回归;保留 duplicate focus、skipped focus、非终态出口和五证据 case 回归;P1 断言确定性落库正文包含区间、代表分钟、免责声明和采用提示;P3 断言默认投影保留决策字段与完整 `inference_state`,并移除非当前 probe 与非活跃宫位表。提交前运行 TypeScript、改动文件 ESLint、rectification/agentic 回归及全量测试;真实线上模型 74 秒路径耗时未在本轮复测。
|
||||
- 防复发:UI 承载只看服务端状态,不得绑定模型工具 receipt;ready-to-adopt 的确定性正文必须写区间、代表分钟和采用提示。非终态轮次必须有 `current_question` 或真实可采用承载;所有承载判断只看焦点与 decision 字段,不得用正文文本判断。不得为无选项问题新造视觉容器或复用选择卡样式,不得引入语义正则、关键词表或 A/B/C/D 位置推断;不得改变 confirmation gate、`resolved`/`declined` 语义或 Skill `10.0.13`。
|
||||
- 相关记录:BUG-441、BUG-449、BUG-450、BUG-452
|
||||
- 复发自:BUG-450
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
evidenceLedgerFingerprint,
|
||||
loadV9CaseCompute,
|
||||
loadV9CaseDossier,
|
||||
loadV9TurnReceipt,
|
||||
persistV9DeterministicTurn,
|
||||
RectificationToolServiceError,
|
||||
transitionV9CaseStatus,
|
||||
@@ -686,14 +685,10 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
try {
|
||||
const receipt = await loadV9TurnReceipt(accounting as never, userId, caseId, result.turnId);
|
||||
const exit = await ensureNonTerminalTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
adoptCarrierReady: Boolean(receipt?.toolActivities.some((activity) => (
|
||||
activity.tool === "rectification-offer-candidates" && activity.status === "completed"
|
||||
))),
|
||||
});
|
||||
idleHostNarration = exit.hostNarration ?? idleHostNarration;
|
||||
} catch (error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } f
|
||||
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
|
||||
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
|
||||
import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision";
|
||||
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -49,7 +50,9 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
|
||||
}
|
||||
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const sessionId = searchParams.get("sessionId") ?? "";
|
||||
const fullDetail = searchParams.get("detail") === "full";
|
||||
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
|
||||
@@ -66,7 +69,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity));
|
||||
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail }));
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const message = error.message;
|
||||
@@ -85,6 +88,7 @@ function dossierResponse(
|
||||
dossier: V9CaseDossier,
|
||||
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
|
||||
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
|
||||
options: { fullDetail?: boolean } = {},
|
||||
) {
|
||||
const decision = decideFromDossier(dossier, {
|
||||
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
|
||||
@@ -115,13 +119,30 @@ function dossierResponse(
|
||||
receipt: turnReceipt(turn.id, receipts),
|
||||
})),
|
||||
evidence: dossier.evidence,
|
||||
latest_result: dossier.latestResult ? overlayPublicDecision(dossier.latestResult, decision) : null,
|
||||
latest_result: dossier.latestResult
|
||||
? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true)
|
||||
: null,
|
||||
interview: publicDecisionFields(decision),
|
||||
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
|
||||
choice_card: choiceCardFromCaseDossier(dossier),
|
||||
};
|
||||
}
|
||||
|
||||
function publicLatestResult(
|
||||
latest: V9CaseDossier["latestResult"],
|
||||
decision: ReturnType<typeof decideFromDossier>,
|
||||
dossier: V9CaseDossier,
|
||||
fullDetail: boolean,
|
||||
) {
|
||||
if (!latest) return null;
|
||||
const projected = overlayPublicDecision(latest, decision);
|
||||
if (fullDetail || !projected.decisionReceipt) return projected;
|
||||
return {
|
||||
...projected,
|
||||
decisionReceipt: slimDecisionReceipt(projected.decisionReceipt, dossier),
|
||||
};
|
||||
}
|
||||
|
||||
function turnReceipt(
|
||||
turnId: string,
|
||||
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
|
||||
|
||||
@@ -1020,7 +1020,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& !message.failed
|
||||
&& Boolean(message.text)
|
||||
));
|
||||
const offeredSelectionOnce = messages.some(turnOfferedSelection);
|
||||
const answeredQuestionIds = new Set(
|
||||
messages.flatMap((message) => message.choiceAttachment
|
||||
? [message.choiceAttachment.card.question_id]
|
||||
@@ -1049,10 +1048,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}, [showLiveChoiceCard, updateFollowState]);
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult?.selectionAllowed
|
||||
&& offeredSelectionOnce
|
||||
&& candidateResult?.canAdopt
|
||||
&& latestSettledAssistant
|
||||
&& !showLiveChoiceCard
|
||||
&& !busy
|
||||
&& !readonly
|
||||
&& regeneratingMessageKey === null,
|
||||
);
|
||||
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
|
||||
|
||||
@@ -729,6 +729,14 @@ ${nextInterview.hostNarration}`
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
}
|
||||
const adoptionNarration = nextAction.type === "offer_provisional_range" && nextAction.can_adopt
|
||||
? `${nonConvergingRangeNarration({
|
||||
credibleRange: nextAction.credible_range,
|
||||
representativeTime: nextAction.representative_time,
|
||||
})} 可以从下面的时间里选一个采用。`
|
||||
: null;
|
||||
if (adoptionNarration) hostNarration = adoptionNarration;
|
||||
|
||||
if (
|
||||
command.deferFollowup !== true
|
||||
&& (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted }))
|
||||
@@ -745,7 +753,7 @@ ${nextInterview.hostNarration}`
|
||||
}
|
||||
}
|
||||
|
||||
const narration = (nextInterviewPersisted ? hostNarration : null)
|
||||
const narration = (adoptionNarration || nextInterviewPersisted ? hostNarration : null)
|
||||
|| (persisted.narration && persisted.narration.trim())
|
||||
|| hostNarration
|
||||
|| composeChoiceNarration({
|
||||
@@ -783,7 +791,6 @@ export async function ensureNonTerminalTurnExit(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
adoptCarrierReady: boolean;
|
||||
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
if (projectCurrentQuestion(dossier.conversationSummary.activeFocus)) {
|
||||
@@ -801,7 +808,7 @@ export async function ensureNonTerminalTurnExit(input: {
|
||||
dossier.case.acceptedTime
|
||||
|| dossier.case.confirmedTime
|
||||
|| decision.completionStatus === "provisional_range_user_stopped"
|
||||
|| (decision.canAdopt && input.adoptCarrierReady)
|
||||
|| decision.canAdopt
|
||||
) {
|
||||
return { persisted: false, choiceReady: false, hostNarration: null };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { previousInferenceFromReceipt } from "./inference-adapter";
|
||||
import type { V9CaseDossier } from "./tool-service";
|
||||
|
||||
export function slimDecisionReceipt(
|
||||
receipt: Readonly<Record<string, unknown>>,
|
||||
dossier: Pick<V9CaseDossier, "case" | "conversationSummary" | "latestResult">,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
const activeTimes = new Set<string>();
|
||||
const inference = previousInferenceFromReceipt(receipt);
|
||||
const activeInferenceCandidates = inference?.candidates.filter((candidate) => candidate.status === "active") ?? [];
|
||||
for (const candidate of activeInferenceCandidates.length > 0
|
||||
? activeInferenceCandidates
|
||||
: dossier.latestResult?.candidates ?? []) {
|
||||
activeTimes.add(candidate.time);
|
||||
}
|
||||
for (const value of [inference?.representative_time, dossier.latestResult?.representativeTime, dossier.latestResult?.selectedTime]) {
|
||||
if (typeof value === "string" && value.trim()) activeTimes.add(value.slice(0, 5));
|
||||
}
|
||||
const focusSchema = dossier.conversationSummary.activeFocus?.expectedAnswerSchema ?? {};
|
||||
const currentProbeIds = new Set(
|
||||
[focusSchema.probe_id, focusSchema.semantic_key, focusSchema.candidate_split_hash]
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0),
|
||||
);
|
||||
const result = { ...receipt };
|
||||
const tables = receipt.house_tables_by_time;
|
||||
if (tables && typeof tables === "object" && !Array.isArray(tables)) {
|
||||
result.house_tables_by_time = Object.fromEntries(
|
||||
Object.entries(tables as Record<string, unknown>)
|
||||
.filter(([time]) => activeTimes.has(time.slice(0, 5))),
|
||||
);
|
||||
}
|
||||
if (receipt.house_table && typeof receipt.house_table === "object" && !Array.isArray(receipt.house_table)) {
|
||||
const table = receipt.house_table as Record<string, unknown>;
|
||||
if (typeof table.time === "string" && !activeTimes.has(table.time.slice(0, 5))) {
|
||||
delete result.house_table;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"discriminating_event_probes",
|
||||
"event_clarification_probes",
|
||||
"evidence_collection_probes",
|
||||
]) {
|
||||
const probes = result[key];
|
||||
if (!Array.isArray(probes)) continue;
|
||||
result[key] = probes.filter((probe) => {
|
||||
if (!probe || typeof probe !== "object" || Array.isArray(probe)) return false;
|
||||
const row = probe as Record<string, unknown>;
|
||||
return [row.id, row.probe_id, row.semantic_key, row.candidate_split_hash]
|
||||
.some((value) => typeof value === "string" && currentProbeIds.has(value));
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export type RectificationCandidateResult = Readonly<{
|
||||
candidates: readonly RectificationCandidate[];
|
||||
overallConfidence: "low" | "medium" | "high";
|
||||
selectionAllowed: boolean;
|
||||
canAdopt: boolean;
|
||||
confirmationAllowed: boolean;
|
||||
representativeTime: string | null;
|
||||
selectedTime: string | null;
|
||||
@@ -267,6 +268,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
|
||||
? snapshot.overallConfidence
|
||||
: "low",
|
||||
selectionAllowed: snapshot.selectionAllowed === true || snapshot.selection_allowed === true,
|
||||
canAdopt: snapshot.canAdopt === true || snapshot.can_adopt === true,
|
||||
confirmationAllowed: snapshot.confirmationAllowed === true,
|
||||
representativeTime,
|
||||
selectedTime,
|
||||
|
||||
@@ -531,7 +531,7 @@ test("conversation and house board reuse the quiet overlay scrollbar", () => {
|
||||
assert.doesNotMatch(styles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \{[^}]*scrollbar-gutter/);
|
||||
});
|
||||
|
||||
test("time-selection cards appear under the latest settled agent bubble only after offer-candidates", () => {
|
||||
test("time-selection cards use server adoption state and stay mutually exclusive with choice cards", () => {
|
||||
const messageLoop = chat.slice(
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{savedTime &&"),
|
||||
@@ -543,7 +543,11 @@ test("time-selection cards appear under the latest settled agent bubble only aft
|
||||
assert.match(chat, /turnOfferedSelection/);
|
||||
assert.match(chat, /rectification-offer-candidates/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\(\s*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*offeredSelectionOnce[\s\S]*!showLiveChoiceCard[\s\S]*!busy/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*candidateResult\?\.canAdopt[\s\S]*!showLiveChoiceCard[\s\S]*!busy[\s\S]*!readonly/);
|
||||
assert.doesNotMatch(
|
||||
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const selectionCardMessageKey")),
|
||||
/offeredSelectionOnce/,
|
||||
);
|
||||
assert.match(
|
||||
chat,
|
||||
/selectionCardMessageKey = showSelectionCards && latestSettledAssistant\s*\? latestSettledAssistant\.renderKey\s*: undefined/,
|
||||
|
||||
@@ -423,6 +423,74 @@ function familyCollectDossier() {
|
||||
});
|
||||
}
|
||||
|
||||
function adoptionInference() {
|
||||
return buildInferenceState({
|
||||
range_start: "04:45",
|
||||
range_end: "05:15",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 16 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 14 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "career", year: 2020, precision: "month" },
|
||||
{ id: "e2", domain: "career", year: 2024, precision: "day" },
|
||||
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
|
||||
{ id: "e4", domain: "relationship", year: 2024, precision: "day" },
|
||||
],
|
||||
probes: [RELOCATION_2015_PROBE],
|
||||
});
|
||||
}
|
||||
|
||||
function adoptionDossier() {
|
||||
const inference = adoptionInference();
|
||||
return dossierFixture({
|
||||
evidenceCount: 6,
|
||||
evidence: [
|
||||
...fourEventRows(),
|
||||
{
|
||||
id: "occupation-note",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "occupation_note",
|
||||
domain: "occupation",
|
||||
occurred_from: null,
|
||||
occurred_to: null,
|
||||
date_precision: "unknown",
|
||||
summary: "长期从事技术工作",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-28T07:38:00.000Z",
|
||||
},
|
||||
],
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "05:00",
|
||||
decisionReceipt: { inference_state: inference },
|
||||
}),
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }],
|
||||
activeFocus: activeFocusFixture({
|
||||
intent: "distinguish_candidates",
|
||||
targetDomain: "relocation",
|
||||
targetKind: "home_change",
|
||||
expectedAnswerSchema: {
|
||||
choice: {
|
||||
prompt: RELOCATION_2015_PROBE.question,
|
||||
options: STYLE_OPTIONS.map((option, index) => ({
|
||||
key: (["A", "B", "C", "D"] as const)[index]!,
|
||||
label: option.label,
|
||||
answer_class: option.answer_class,
|
||||
})),
|
||||
},
|
||||
probe_id: RELOCATION_2015_PROBE.id,
|
||||
semantic_key: RELOCATION_2015_PROBE.semantic_key,
|
||||
candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function persistChoiceAccounting(
|
||||
dossier: ReturnType<typeof twoProbeDossier>,
|
||||
extra: Parameters<typeof fakeAccounting>[0] = {},
|
||||
@@ -515,6 +583,30 @@ test("synthetic choice taps are not kept as chat user lines", () => {
|
||||
assert.equal(isStructuredChoiceUserText("没有,那年很顺利"), false);
|
||||
});
|
||||
|
||||
test("last structured choice emits the adoption range and persists the same narration", async () => {
|
||||
const accounting = persistChoiceAccounting(adoptionDossier());
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
actionId: ACTION_ID,
|
||||
action: CHOICE_ACTION,
|
||||
focusId: FOCUS_ID,
|
||||
questionId: QUESTION_ID,
|
||||
probeId: RELOCATION_2015_PROBE.id,
|
||||
optionId: "A",
|
||||
expectedRevision: adoptionInference().revision,
|
||||
});
|
||||
assert.equal(applied.nextAction.type, "offer_provisional_range");
|
||||
assert.equal(applied.nextAction.can_adopt, true);
|
||||
assert.match(applied.narration, /当前可信区间是/);
|
||||
assert.match(applied.narration, /代表分钟/);
|
||||
assert.match(applied.narration, /代表分钟只是代表性候选,不是已确认的唯一出生分钟。/);
|
||||
assert.match(applied.narration, /可以从下面的时间里选一个采用/);
|
||||
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
assert.match(String(turn?.args.p_assistant_message ?? ""), /可以从下面的时间里选一个采用/);
|
||||
});
|
||||
|
||||
test("clicking A applies the choice without invoking a language model", async () => {
|
||||
const accounting = choiceAccounting();
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
|
||||
@@ -19,6 +19,7 @@ const camelCaseSnapshot = {
|
||||
],
|
||||
overallConfidence: "low",
|
||||
selectionAllowed: true,
|
||||
canAdopt: true,
|
||||
confirmationAllowed: false,
|
||||
representativeTime: null,
|
||||
selectedTime: null,
|
||||
@@ -34,9 +35,24 @@ test("parses the camelCase Candidate Snapshot returned by the Case API", () => {
|
||||
assert.equal(result.candidates[0]?.candidateId, CANDIDATE_ID);
|
||||
assert.equal(result.candidates[0]?.relativeSupport, 34);
|
||||
assert.equal(result.selectionAllowed, true);
|
||||
assert.equal(result.canAdopt, true);
|
||||
assert.equal(result.confirmationAllowed, false);
|
||||
});
|
||||
|
||||
test("adoption rendering state parses both API spellings and fails closed when absent", () => {
|
||||
const snake = parseRectificationCandidateResult({
|
||||
...camelCaseSnapshot,
|
||||
can_adopt: true,
|
||||
});
|
||||
assert.equal(snake?.canAdopt, true);
|
||||
|
||||
const absent = parseRectificationCandidateResult({
|
||||
...camelCaseSnapshot,
|
||||
canAdopt: undefined,
|
||||
});
|
||||
assert.equal(absent?.canAdopt, false);
|
||||
});
|
||||
|
||||
test("low-confidence near ties never receive a recommendation badge", () => {
|
||||
const result = parseRectificationCandidateResult(camelCaseSnapshot);
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { slimDecisionReceipt } from "../src/lib/rectification-agentic/v9/case-receipt-projection.ts";
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
activeFocusFixture,
|
||||
candidateSnapshotFixture,
|
||||
conversationSummaryFixture,
|
||||
dossierFixture,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
test("default Case receipt projection keeps decision and inference state while slimming render-only payloads", () => {
|
||||
const inference = buildInferenceState({
|
||||
range_start: "04:53",
|
||||
range_end: "05:07",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 50 },
|
||||
{ id: "05:07", time: "05:07", relative_support: 40 },
|
||||
{ id: "04:00", time: "04:00", relative_support: 10 },
|
||||
],
|
||||
events: [],
|
||||
probes: [],
|
||||
});
|
||||
const fullInference = {
|
||||
...inference,
|
||||
candidates: inference.candidates.map((candidate) => (
|
||||
candidate.time === "04:00" ? { ...candidate, status: "eliminated" as const } : candidate
|
||||
)),
|
||||
};
|
||||
const dossier = parseV9CaseDossier(dossierFixture({
|
||||
latestResult: candidateSnapshotFixture({
|
||||
representativeTime: "05:00",
|
||||
decisionReceipt: { inference_state: fullInference },
|
||||
}),
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus: activeFocusFixture({
|
||||
expectedAnswerSchema: {
|
||||
collect: true,
|
||||
probe_id: "probe-current",
|
||||
semantic_key: "current.semantic",
|
||||
candidate_split_hash: "current-split",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
assert.ok(dossier);
|
||||
const decision = { status: "nonterminal", next: "ask" };
|
||||
const receipt = {
|
||||
decision,
|
||||
gates: { can_adopt: false, selection_allowed: false },
|
||||
inference_state: fullInference,
|
||||
house_tables_by_time: {
|
||||
"04:00": { time: "04:00", houses: Array(12).fill({ sign: "x" }) },
|
||||
"05:00": { time: "05:00", houses: Array(12).fill({ sign: "x" }) },
|
||||
"05:07": { time: "05:07", houses: Array(12).fill({ sign: "x" }) },
|
||||
},
|
||||
house_table: { time: "05:00", houses: Array(12).fill({ sign: "x" }) },
|
||||
discriminating_event_probes: [
|
||||
{ id: "probe-current", question: "当前" },
|
||||
{ id: "probe-old", question: "旧" },
|
||||
],
|
||||
evidence_collection_probes: [
|
||||
{ semantic_key: "current.semantic", question: "当前" },
|
||||
{ semantic_key: "old.semantic", question: "旧" },
|
||||
],
|
||||
};
|
||||
|
||||
const slim = slimDecisionReceipt(receipt, dossier!);
|
||||
assert.deepEqual(slim.decision, decision);
|
||||
assert.deepEqual(slim.gates, receipt.gates);
|
||||
assert.deepEqual(slim.inference_state, fullInference);
|
||||
assert.deepEqual(Object.keys(slim.house_tables_by_time as Record<string, unknown>).sort(), ["05:00", "05:07"]);
|
||||
assert.deepEqual((slim.discriminating_event_probes as Array<Record<string, unknown>>).map((row) => row.id), ["probe-current"]);
|
||||
assert.deepEqual((slim.evidence_collection_probes as Array<Record<string, unknown>>).map((row) => row.semantic_key), ["current.semantic"]);
|
||||
});
|
||||
@@ -894,7 +894,6 @@ test("nonterminal turn exit deterministically restores a spoken question", async
|
||||
accounting: ReturnType<typeof fakeAccounting>["client"];
|
||||
userId: string;
|
||||
caseId: string;
|
||||
adoptCarrierReady: boolean;
|
||||
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
|
||||
assert.equal(typeof ensureExit, "function");
|
||||
const accounting = fakeAccounting({
|
||||
@@ -908,7 +907,6 @@ test("nonterminal turn exit deterministically restores a spoken question", async
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
adoptCarrierReady: false,
|
||||
});
|
||||
assert.equal(repaired.persisted, true);
|
||||
assert.match(repaired.hostNarration ?? "", /当前可信区间/);
|
||||
|
||||
Reference in New Issue
Block a user