fix(rectification): keep choice options and scoring on the server
Agent set-focus was writing or dropping choice schema, so probes never scored. Force server-owned options, fail closed when focus list RPC errors, and require the probe year in spokenPrompt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7569,3 +7569,51 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-492 | 消息路径 set-focus 用 Agent 选项覆盖服务端 choice schema,探针不计分
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`rectification-set-focus`、`expectedAnswerSchemaFor`、`projectCurrentQuestion`、method-followup Agent hint
|
||||
- 用户现象:答完采集题后的区分题没有 A–D,或点选后候选比较不更新。刷新后同一条助手消息里仍可能只有题干没有选项。
|
||||
- 触发条件:message 路径 Agent 只传 `spokenPrompt`(或只传 `choice.option_a..d`、或自写 `options[].answer_class`)调用 `rectification-set-focus`,且 `next_followup` 带 `choice_frame`。
|
||||
- 根因:`d9404976` 把 set-focus 重新交给 Agent 写题干,但 `execute` 仍用 `parseAgentChoiceCopy(Agent expectedAnswerSchema)` 建 choice。只写 `spokenPrompt` 时 schema=`{prompt}`,投影成采集/空题,idle persist 见 active focus 早退,服务端 choice 永远建不出,该探针不计分。Agent 自写 `answer_class` 则模型拥有映射,违反选项与计分服务端所有。
|
||||
- 修复:set-focus 按 `next_followup.choice_frame` 强制 `expectedAnswerSchemaFor` / collect schema,忽略 Agent `choice`。无下一问返回 `no_pending_question`。hint 改为「你只写 spokenPrompt」。同探针再 set-focus 走 prompt 外 identity 幂等。
|
||||
- 验证:`rectification-question-ownership` 锁只传 spokenPrompt → 四选项与 `serverOwnedChoiceCopy` 相等、`probe_id` 非空、`kind===choice`;额外 Agent choice 被忽略;采集题 `collect:true`;无下一问不写 focus;第二次 set-focus 幂等且选项不丢。`rectification-question-in-message` 的 choice fixture 补 `options[].answer_class` 并断言 `kind` 与四选项。
|
||||
- 防复发:任何路径不得再从 Agent 输入读 `choice`。choice fixture 必须能过 `parseAgentChoiceCopy`。已知边界:用户不答上一题直接发新消息时,旧 focus 仍挂在旧消息、新 turn 无 question(不变量 1 短暂不成立),旧卡仍可点,属可接受。
|
||||
- 相关记录:BUG-490、BUG-491
|
||||
- 复发自:BUG-490(题目进消息后开放 Agent set-focus,未改 schema 所有权)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-493 | 题目列表 RPC 失败时 fail-open,聊天里全部题目消失
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`listV10ConversationFocuses`、GET `/api/rectification/cases/[caseId]` `question_source`、`rectification-agentic-chat`
|
||||
- 用户现象:迁移未跑或 RPC 权限错误时,历史消息上的题干和选项全部消失,界面只剩「等待服务端更新」,没有失败提示。
|
||||
- 触发条件:`list_agentic_rectification_conversation_focuses` 返回 error 或抛异常。
|
||||
- 根因:列表函数失败时静默 `return []`,GET 把空列表当成「没有题」,前端无法区分加载失败与真的缺题。
|
||||
- 修复:失败时 `console.warn` 并返回 `{ focuses: [], available: false }`。GET 带 `question_source: "unavailable"|"focus"`。前端文案「题目加载失败,请刷新。」与「正在等待服务端更新」分开。
|
||||
- 验证:`rectification-question-ownership` 锁 RPC error → 列表 `available===false`、turns 上 question 全 null、`question_source==="unavailable"`、warn 含 case 与 reason。GET 仍走 200 组装路径,不把列表失败提升为 503。
|
||||
- 防复发:题目加载失败不得再吞成空数组。`question_source` 为 `unavailable` 时不得显示「服务端更新中」。
|
||||
- 相关记录:BUG-490、BUG-492
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-494 | 题干可省略探针年份,嵌入卡又隐藏 why
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`validateSpokenPrompt`、`rectification-set-focus`、`VOICE.md`、嵌入 `RectificationChoiceCard`
|
||||
- 用户现象:区分题看起来像「你有没有一段认真开始或结束的关系?」,用户看不到是哪一年,答错年份会记到错误探针上。
|
||||
- 触发条件:`followup.probe_year` 或 `choice_frame.period` 含年份,但 Agent `spokenPrompt` 不写该年份。嵌入卡 `variant="embedded"` 隐藏 `why`。
|
||||
- 根因:`year_mismatch` 只在题干已经写出 19xx/20xx 时比对;缺年份直接放过。
|
||||
- 修复:有 `probe_year` 时题干必须包含该年,否则 `year_missing`;无 `probe_year` 但 period 含年份时每个年份都要出现。采集题不加年份要求。VOICE 增加漏写年份坏例。
|
||||
- 验证:`rectification-spoken-prompt` 锁 `year_missing` / `year_mismatch` / 通过;嵌入卡测试断言 `question.prompt` 含探针年份(结构断言,不扫正文)。
|
||||
- 防复发:区分题题干必须能单独看出年份/期间,不得依赖已隐藏的 `why`。
|
||||
- 相关记录:BUG-490、BUG-492
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 2023 感情区分 | 2023 年前后,你有没有一段认真开始或结束的关系?(逐字复读模板) | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读整句模板。 |
|
||||
| 2016 升学 | 2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
|
||||
| 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 |
|
||||
| 2023 感情区分 | 你有没有一段认真开始或结束的关系? | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 题干必须写出服务端给的年份;漏写年份用户看不见期间。 |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
receiptStatusFromTurn,
|
||||
RectificationToolServiceError,
|
||||
listV10ConversationFocuses,
|
||||
type ConversationFocus,
|
||||
questionSourceFromFocusList,
|
||||
type ConversationFocusList,
|
||||
type V9CaseDossier,
|
||||
} from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state";
|
||||
@@ -72,8 +73,8 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
}),
|
||||
);
|
||||
const focuses = await listV10ConversationFocuses(accounting, user.id, caseId);
|
||||
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, focuses }));
|
||||
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
|
||||
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, listed }));
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const message = error.message;
|
||||
@@ -88,16 +89,17 @@ 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>>,
|
||||
options: { fullDetail?: boolean; focuses?: readonly ConversationFocus[] } = {},
|
||||
options: { fullDetail?: boolean; listed?: ConversationFocusList } = {},
|
||||
) {
|
||||
const decision = decideFromDossier(dossier, {
|
||||
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
|
||||
});
|
||||
const turns = attachQuestionsToTurns(dossier.turns, options.focuses ?? []);
|
||||
const listed = options.listed ?? { focuses: [] as const, available: true };
|
||||
const turns = attachQuestionsToTurns(dossier.turns, listed.focuses);
|
||||
return {
|
||||
case: {
|
||||
case_id: dossier.case.caseId,
|
||||
@@ -131,6 +133,7 @@ function dossierResponse(
|
||||
interview: publicDecisionFields(decision),
|
||||
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
|
||||
choice_card: choiceCardFromCaseDossier(dossier),
|
||||
question_source: questionSourceFromFocusList(listed),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,12 @@ function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | nul
|
||||
};
|
||||
}
|
||||
|
||||
function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | null {
|
||||
if (value === "unavailable") return "unavailable";
|
||||
if (value === "focus") return "focus";
|
||||
return null;
|
||||
}
|
||||
|
||||
function RectificationCandidateCards({
|
||||
result,
|
||||
acceptingCandidateId,
|
||||
@@ -380,6 +386,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
|
||||
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
|
||||
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
@@ -447,6 +454,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
current_question?: unknown;
|
||||
choice_card?: unknown;
|
||||
turns?: unknown;
|
||||
question_source?: unknown;
|
||||
case?: {
|
||||
status?: unknown;
|
||||
accepted_time?: unknown;
|
||||
@@ -464,6 +472,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
setCaseSnapshotLoaded(true);
|
||||
@@ -1139,12 +1148,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& message.question.focus_id === currentQuestion.focus_id
|
||||
&& !questionIsAnswered(message.question)
|
||||
));
|
||||
const questionLoadFailed = questionSource === "unavailable";
|
||||
const showMissingQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion === null,
|
||||
&& currentQuestion === null
|
||||
&& !questionLoadFailed,
|
||||
);
|
||||
const showUnavailableQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
@@ -1152,7 +1163,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion !== null
|
||||
&& !liveQuestionOnMessages,
|
||||
&& !liveQuestionOnMessages
|
||||
&& !questionLoadFailed,
|
||||
);
|
||||
const showQuestionLoadFailed = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& questionLoadFailed,
|
||||
);
|
||||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||||
|
||||
@@ -1321,11 +1340,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
{!conversationAnchor.anchored && (
|
||||
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
|
||||
)}
|
||||
{(showMissingQuestion || showUnavailableQuestion) && (
|
||||
{(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
|
||||
<p className="rectification-composer-status" role="status">
|
||||
{showMissingQuestion
|
||||
? "当前没有可回答的问题,正在等待服务端更新。"
|
||||
: "当前问题暂时无法显示,请等待服务端更新。"}
|
||||
{showQuestionLoadFailed
|
||||
? "题目加载失败,请刷新。"
|
||||
: showMissingQuestion
|
||||
? "当前没有可回答的问题,正在等待服务端更新。"
|
||||
: "当前问题暂时无法显示,请等待服务端更新。"}
|
||||
</p>
|
||||
)}
|
||||
<ChatComposer
|
||||
|
||||
@@ -942,7 +942,7 @@ function collectHint(
|
||||
extra = "",
|
||||
evidence: readonly MethodFollowupEvidence[] = [],
|
||||
): string {
|
||||
return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}用 rectification-set-focus 的 spokenPrompt 用自然语言写出采集题。正文不提问,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim();
|
||||
return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}用 rectification-set-focus 的 spokenPrompt 用自然语言写出采集题。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。正文不提问,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function agentHint(
|
||||
@@ -951,7 +951,7 @@ function agentHint(
|
||||
extra = "",
|
||||
evidence: readonly MethodFollowupEvidence[] = [],
|
||||
): string {
|
||||
return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用 rectification-set-focus 的 spokenPrompt 写出题干;时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。正文不要提问、不要复述选项。`.replace(/\s+/g, " ").trim();
|
||||
return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。正文不要提问、不要复述选项。`.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function shouldAttachChoiceFrame(
|
||||
@@ -1544,8 +1544,8 @@ export function buildMethodFollowupPlan(input: {
|
||||
domain: focus.targetDomain,
|
||||
kind_hint: focus.targetKind,
|
||||
user_prompt_hint: keepChoice
|
||||
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
|
||||
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。不要写 expectedAnswerSchema.choice。",
|
||||
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
|
||||
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
|
||||
source: "active_focus",
|
||||
...(liveProbe && focus.intent === "distinguish_candidates"
|
||||
? {
|
||||
|
||||
@@ -112,8 +112,8 @@ export async function regenerateV9AssistantTurn(
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_terminal");
|
||||
}
|
||||
|
||||
const focuses = await listV10ConversationFocuses(accounting, userId, caseId);
|
||||
const turns = attachQuestionsToTurns(dossier.turns, focuses);
|
||||
const listed = await listV10ConversationFocuses(accounting, userId, caseId);
|
||||
const turns = attachQuestionsToTurns(dossier.turns, listed.focuses);
|
||||
const target = turns.find((turn) => (
|
||||
turn.id === turnId
|
||||
&& turn.role === "assistant"
|
||||
|
||||
@@ -71,7 +71,7 @@ export function shouldSkipDiscriminatorFollowup(followup: MethodFollowup): Persi
|
||||
return null;
|
||||
}
|
||||
|
||||
function expectedAnswerSchemaFor(
|
||||
export function expectedAnswerSchemaFor(
|
||||
frame: RectificationChoiceFrame,
|
||||
questionId: string,
|
||||
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
|
||||
@@ -224,7 +224,7 @@ export async function linkFocusAskedTurn(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
|
||||
export function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
|
||||
const prompt = spokenFollowupForUser({ ...followup, choice_frame: null });
|
||||
if (!prompt) return null;
|
||||
const schema: Record<string, unknown> = {
|
||||
@@ -235,6 +235,24 @@ function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> |
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function serverOwnedExpectedAnswerSchema(
|
||||
followup: MethodFollowup,
|
||||
decisionReceipt?: Readonly<Record<string, unknown>> | null,
|
||||
): Record<string, unknown> | null {
|
||||
const frame = followup.choice_frame;
|
||||
if (frame) {
|
||||
const schema = expectedAnswerSchemaFor(
|
||||
frame,
|
||||
stableFollowupQuestionId(followup),
|
||||
decisionReceipt,
|
||||
followup,
|
||||
);
|
||||
if (schema?.choice) return schema;
|
||||
return collectFocusSchema(spokenCollectFallbackFollowup(followup));
|
||||
}
|
||||
return collectFocusSchema(followup);
|
||||
}
|
||||
|
||||
export function isCollectFocusSchema(schema: Readonly<Record<string, unknown>> | null | undefined): boolean {
|
||||
return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string";
|
||||
}
|
||||
|
||||
@@ -61,10 +61,23 @@ export function validateSpokenPrompt(input: {
|
||||
if (followup.choice_frame && input.questionId && input.questionId !== stableFollowupQuestionId(followup)) {
|
||||
return { ok: false, error: "invalid_spoken_prompt", reason: "probe_mismatch" };
|
||||
}
|
||||
if (followup.probe_year) {
|
||||
const years = prompt.match(/19\d{2}|20\d{2}/g) ?? [];
|
||||
if (years.some((year) => Number(year) !== followup.probe_year)) {
|
||||
return { ok: false, error: "invalid_spoken_prompt", reason: "year_mismatch" };
|
||||
if (followup.choice_frame) {
|
||||
if (followup.probe_year) {
|
||||
const year = String(followup.probe_year);
|
||||
const years = prompt.match(/19\d{2}|20\d{2}/g) ?? [];
|
||||
if (years.some((item) => item !== year)) {
|
||||
return { ok: false, error: "invalid_spoken_prompt", reason: "year_mismatch" };
|
||||
}
|
||||
if (!prompt.includes(year)) {
|
||||
return { ok: false, error: "invalid_spoken_prompt", reason: "year_missing" };
|
||||
}
|
||||
} else {
|
||||
const periodYears = followup.choice_frame.period.match(/19\d{2}|20\d{2}/g) ?? [];
|
||||
for (const year of periodYears) {
|
||||
if (!prompt.includes(year)) {
|
||||
return { ok: false, error: "invalid_spoken_prompt", reason: "year_missing" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,28 +1089,51 @@ export async function resolveV10ConversationFocus(
|
||||
};
|
||||
}
|
||||
|
||||
export type ConversationFocusList = Readonly<{
|
||||
focuses: readonly ConversationFocus[];
|
||||
available: boolean;
|
||||
}>;
|
||||
|
||||
export function questionSourceFromFocusList(listed: ConversationFocusList): "focus" | "unavailable" {
|
||||
return listed.available ? "focus" : "unavailable";
|
||||
}
|
||||
|
||||
export async function listV10ConversationFocuses(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
): Promise<readonly ConversationFocus[]> {
|
||||
): Promise<ConversationFocusList> {
|
||||
try {
|
||||
const { data, error } = await accounting.rpc(
|
||||
"list_agentic_rectification_conversation_focuses",
|
||||
{ p_user_id: userId, p_case_id: caseId },
|
||||
);
|
||||
if (error) return [];
|
||||
if (error) {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: rowText((error as { message?: unknown }).message) ?? String(error);
|
||||
console.warn(
|
||||
`[rectification-v9] list focuses failed case=${caseId} reason=${safeToolErrorCode(new Error(message))}`,
|
||||
);
|
||||
return { focuses: [], available: false };
|
||||
}
|
||||
const items = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray((data as { value?: unknown } | null)?.value)
|
||||
? (data as { value: unknown[] }).value
|
||||
: [];
|
||||
return items.flatMap((item) => {
|
||||
const focus = parseConversationFocus(item);
|
||||
return focus ? [focus] : [];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
return {
|
||||
focuses: items.flatMap((item) => {
|
||||
const focus = parseConversationFocus(item);
|
||||
return focus ? [focus] : [];
|
||||
}),
|
||||
available: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] list focuses failed case=${caseId} reason=${safeToolErrorCode(error)}`,
|
||||
);
|
||||
return { focuses: [], available: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1792,6 +1815,7 @@ export function safeToolErrorCode(error: unknown): string {
|
||||
"invalid_choice_copy",
|
||||
"precision_downgrade",
|
||||
"offer_not_allowed",
|
||||
"no_pending_question",
|
||||
"no_candidate_result",
|
||||
"stale_probe",
|
||||
"revision_conflict",
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { V9CaseDossier } from "./tool-service";
|
||||
import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
|
||||
import { parseAgentChoiceCopy } from "./choice-card";
|
||||
import { isCollectFocusSchema } from "./server-focus";
|
||||
|
||||
export const TURN_DECISION_MAX_BYTES = 6 * 1024;
|
||||
export const TURN_DECISION_RECENT_TURNS = 6;
|
||||
@@ -93,6 +94,17 @@ export function projectCurrentQuestion(
|
||||
};
|
||||
}
|
||||
const collectPrompt = spoken || "";
|
||||
if (isCollectFocusSchema(schema) && collectPrompt) {
|
||||
return {
|
||||
question_id: focus.questionId ?? null,
|
||||
focus_id: focus.id ?? null,
|
||||
probe_id: probeId,
|
||||
prompt: collectPrompt,
|
||||
kind: "collect_spoken",
|
||||
intent: focus.intent,
|
||||
domain: focus.targetDomain ?? null,
|
||||
};
|
||||
}
|
||||
if (focus.intent === "collect_method_evidence" && collectPrompt && !looksLikeChoiceSchema(schema)) {
|
||||
return {
|
||||
question_id: focus.questionId ?? null,
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
} from "@/lib/rectification-agentic/v9/evidence-model";
|
||||
import {
|
||||
isHoldoutVerificationQuote,
|
||||
parseAgentChoiceCopy,
|
||||
} from "@/lib/rectification-agentic/v9/choice-card";
|
||||
import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau";
|
||||
import {
|
||||
@@ -75,12 +74,16 @@ import {
|
||||
buildCaseInferenceState,
|
||||
compactInferenceProjection,
|
||||
previousInferenceFromReceipt,
|
||||
stampChoiceSchemaWithProbe,
|
||||
} from "@/lib/rectification-agentic/v9/inference-adapter";
|
||||
import {
|
||||
COLLECT_FOCUS_SCHEMA_KEY,
|
||||
collectFocusSchema,
|
||||
isCollectFocusSchema,
|
||||
openQuestionFromPersistedFocus,
|
||||
persistableFocusDomain,
|
||||
persistServerOwnedFocus,
|
||||
serverOwnedExpectedAnswerSchema,
|
||||
stableFollowupQuestionId,
|
||||
} from "@/lib/rectification-agentic/v9/server-focus";
|
||||
import { validateSpokenPrompt, withSpokenPrompt } from "@/lib/rectification-agentic/v9/spoken-prompt";
|
||||
import {
|
||||
@@ -1177,7 +1180,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const setFocusTool = createTool({
|
||||
id: "rectification-set-focus",
|
||||
description:
|
||||
"在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。新的 focus 会由服务器 supersede 旧 active focus。仅当 method_followup_plan.next_followup 带 choice_frame 时才在 expectedAnswerSchema.choice 写入 option_a、option_b、option_c、option_d。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。",
|
||||
"在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。新的 focus 会由服务器 supersede 旧 active focus。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。",
|
||||
inputSchema: z.object({
|
||||
caseId: z.string().uuid(),
|
||||
questionId: z.string().trim().min(1).max(160),
|
||||
@@ -1196,58 +1199,34 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
if (input.targetKind && !isEvidenceKind(input.targetKind)) {
|
||||
throw new RectificationToolServiceError("invalid_event_kind");
|
||||
}
|
||||
const expectedAnswerSchemaInput = input.expectedAnswerSchema ?? {};
|
||||
const hasChoice = expectedAnswerSchemaInput.choice != null;
|
||||
const choiceCopy = parseAgentChoiceCopy(expectedAnswerSchemaInput);
|
||||
if (hasChoice && !choiceCopy) {
|
||||
throw new RectificationToolServiceError("invalid_choice_copy");
|
||||
}
|
||||
const expectedAnswerSchema: Record<string, unknown> = { ...expectedAnswerSchemaInput };
|
||||
if (choiceCopy) {
|
||||
expectedAnswerSchema.choice = {
|
||||
prompt: choiceCopy.prompt,
|
||||
option_a: choiceCopy.option_a,
|
||||
option_b: choiceCopy.option_b,
|
||||
option_c: choiceCopy.option_c,
|
||||
option_d: choiceCopy.option_d,
|
||||
};
|
||||
} else {
|
||||
delete expectedAnswerSchema.choice;
|
||||
}
|
||||
if (expectedAnswerSchema.choice) {
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
Object.assign(
|
||||
expectedAnswerSchema,
|
||||
stampChoiceSchemaWithProbe(
|
||||
expectedAnswerSchema,
|
||||
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
|
||||
input.questionId,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Probe identity is only a hint for the C/D write path.
|
||||
}
|
||||
}
|
||||
const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input);
|
||||
let nextFollowup: ReturnType<typeof sessionAwareFollowupForParsed>["plan"]["next_followup"] = null;
|
||||
let decisionReceipt: Readonly<Record<string, unknown>> | null = null;
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
const parsed = parseDossierForTools(dossier);
|
||||
if (parsed.latestResult) {
|
||||
nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup;
|
||||
decisionReceipt = parsed.latestResult.decisionReceipt ?? null;
|
||||
}
|
||||
} catch {
|
||||
nextFollowup = null;
|
||||
}
|
||||
if (!nextFollowup) {
|
||||
await receipt("rectification-set-focus", "intent.classified", "failed", {
|
||||
inputFingerprint,
|
||||
safeErrorCode: "no_pending_question",
|
||||
});
|
||||
return { ok: false, error: "no_pending_question" };
|
||||
}
|
||||
const spoken = validateSpokenPrompt({
|
||||
spokenPrompt: input.spokenPrompt,
|
||||
followup: nextFollowup,
|
||||
targetDomain: input.targetDomain ?? nextFollowup?.domain ?? null,
|
||||
targetDomain: input.targetDomain ?? nextFollowup.domain ?? null,
|
||||
questionId: input.questionId,
|
||||
});
|
||||
if (!spoken.ok) {
|
||||
spokenPromptFailures += 1;
|
||||
const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input);
|
||||
await receipt("rectification-set-focus", "intent.classified", "failed", {
|
||||
inputFingerprint,
|
||||
safeErrorCode: "invalid_spoken_prompt",
|
||||
@@ -1259,16 +1238,26 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
retryable: spokenPromptFailures < 2,
|
||||
};
|
||||
}
|
||||
Object.assign(expectedAnswerSchema, withSpokenPrompt(expectedAnswerSchema, spoken.prompt));
|
||||
const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input);
|
||||
let persistFollowup = nextFollowup;
|
||||
let expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt);
|
||||
if (persistFollowup.choice_frame && !expectedAnswerSchema?.choice) {
|
||||
persistFollowup = spokenCollectFallbackFollowup(persistFollowup);
|
||||
expectedAnswerSchema = collectFocusSchema(persistFollowup);
|
||||
}
|
||||
if (!expectedAnswerSchema) {
|
||||
expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true };
|
||||
}
|
||||
expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spoken.prompt);
|
||||
await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint });
|
||||
try {
|
||||
const result = await setV10ConversationFocus(accounting, userId, input.caseId, {
|
||||
questionId: input.questionId,
|
||||
intent: input.intent,
|
||||
targetEvidenceId: input.targetEvidenceId ?? null,
|
||||
targetDomain: input.targetDomain ?? null,
|
||||
targetKind: input.targetKind ?? null,
|
||||
questionId: stableFollowupQuestionId(persistFollowup),
|
||||
intent: persistFollowup.intent,
|
||||
targetEvidenceId: null,
|
||||
targetDomain: persistFollowup.choice_frame
|
||||
? persistFollowup.domain
|
||||
: persistableFocusDomain(persistFollowup.domain),
|
||||
targetKind: null,
|
||||
expectedAnswerSchema,
|
||||
askedTurnId: turnId,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,28 @@ import {
|
||||
|
||||
const OTHER_TURN = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
const EXISTENCE_OPTIONS = [
|
||||
{ key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes" },
|
||||
{ key: "B" as const, label: "发生过但时间偏了", answer_class: "weak_yes" },
|
||||
{ key: "C" as const, label: "没有这回事", answer_class: "no" },
|
||||
{ key: "D" as const, label: "这段记不清楚", answer_class: "unsure" },
|
||||
];
|
||||
|
||||
function choiceSchema(prompt: string) {
|
||||
return {
|
||||
prompt,
|
||||
probe_id: "probe:test",
|
||||
choice: {
|
||||
prompt,
|
||||
option_a: EXISTENCE_OPTIONS[0]!.label,
|
||||
option_b: EXISTENCE_OPTIONS[1]!.label,
|
||||
option_c: EXISTENCE_OPTIONS[2]!.label,
|
||||
option_d: EXISTENCE_OPTIONS[3]!.label,
|
||||
options: EXISTENCE_OPTIONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
|
||||
const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8");
|
||||
@@ -44,8 +66,9 @@ test("question-on-turn migration adds asked_turn_id and answer_option", () => {
|
||||
});
|
||||
|
||||
test("GET turns attach question by asked_turn_id and never by timestamps", () => {
|
||||
assert.match(casesRoute, /attachQuestionsToTurns\(dossier\.turns, options\.focuses/);
|
||||
assert.match(casesRoute, /attachQuestionsToTurns\(dossier\.turns, listed\.focuses/);
|
||||
assert.match(casesRoute, /question: turn\.question/);
|
||||
assert.match(casesRoute, /question_source: questionSourceFromFocusList\(listed\)/);
|
||||
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
|
||||
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
|
||||
assert.doesNotMatch(attach, /askedAt|createdAt/);
|
||||
@@ -111,6 +134,8 @@ test("one assistant article owns the stem and options; the slot class is gone",
|
||||
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
|
||||
assert.match(chat, /role="status"/);
|
||||
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
|
||||
assert.match(chat, /题目加载失败,请刷新/);
|
||||
assert.match(chat, /question_source/);
|
||||
});
|
||||
|
||||
test("agent toolbag includes set-focus so spokenPrompt can run", () => {
|
||||
@@ -129,16 +154,7 @@ test("message-path idle persist links a pre-built choice focus to the current tu
|
||||
intent: "distinguish_candidates",
|
||||
questionId: "d9:relationship:2023",
|
||||
targetDomain: "relationship",
|
||||
expectedAnswerSchema: {
|
||||
prompt,
|
||||
choice: {
|
||||
prompt,
|
||||
option_a: "明确发生且时间吻合",
|
||||
option_b: "发生过但时间偏了",
|
||||
option_c: "没有这回事",
|
||||
option_d: "记不清",
|
||||
},
|
||||
},
|
||||
expectedAnswerSchema: choiceSchema(prompt),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -176,16 +192,6 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", ()
|
||||
const collectPrompt = GENERIC_COLLECT_QUESTION;
|
||||
const answeredPrompt = "2015 年前后,有没有搬家或换过主要居住地?";
|
||||
const livePrompt = "2014 年前后,有没有升学、转学或换学习环境?";
|
||||
const choiceSchema = (prompt: string) => ({
|
||||
prompt,
|
||||
choice: {
|
||||
prompt,
|
||||
option_a: "明确发生且时间吻合",
|
||||
option_b: "发生过但时间偏了",
|
||||
option_c: "没有这回事",
|
||||
option_d: "记不清",
|
||||
},
|
||||
});
|
||||
const attached = attachQuestionsToTurns(
|
||||
[
|
||||
{ id: TURN_ID, role: "assistant" as const, text: "你好,可以慢慢说。", status: "completed" },
|
||||
@@ -242,9 +248,15 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", ()
|
||||
);
|
||||
assert.equal(attached[0]?.question?.kind, "collect_spoken");
|
||||
assert.equal(attached[0]?.question?.status, "resolved");
|
||||
assert.equal(attached[1]?.question?.kind, "choice");
|
||||
assert.equal(attached[1]?.question?.options?.length, 4);
|
||||
assert.equal(attached[1]?.question?.answer_option, "A");
|
||||
assert.equal(attached[1]?.question?.prompt, answeredPrompt);
|
||||
assert.ok(attached[1]?.question?.prompt.includes("2015"));
|
||||
assert.equal(attached[2]?.question?.kind, "choice");
|
||||
assert.equal(attached[2]?.question?.options?.length, 4);
|
||||
assert.equal(attached[2]?.question?.prompt, livePrompt);
|
||||
assert.ok(attached[2]?.question?.prompt.includes("2014"));
|
||||
assert.equal(attached[2]?.question?.answer_option, null);
|
||||
assert.equal(attached[2]?.text, "2016 年入学记下了。");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { buildChoiceFrame, parseAgentChoiceCopy, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
|
||||
import {
|
||||
buildMethodFollowupPlan,
|
||||
type MethodFollowup,
|
||||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import {
|
||||
decideFromDossier,
|
||||
rectificationFollowupCatalog,
|
||||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import {
|
||||
expectedAnswerSchemaFor,
|
||||
serverOwnedExpectedAnswerSchema,
|
||||
stableFollowupQuestionId,
|
||||
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import {
|
||||
attachQuestionsToTurns,
|
||||
parseTurnQuestion,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import {
|
||||
listV10ConversationFocuses,
|
||||
parseV9CaseDossier,
|
||||
questionSourceFromFocusList,
|
||||
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
internalObservationsFromWindowScan,
|
||||
windowScanFromDecisionReceipt,
|
||||
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
|
||||
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
EVIDENCE_ID,
|
||||
FOCUS_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
activeFocusFixture,
|
||||
candidateSnapshotFixture,
|
||||
conversationSummaryFixture,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
type ExecutableTool<T = unknown> = {
|
||||
execute(input: unknown): Promise<T>;
|
||||
};
|
||||
|
||||
const STYLE_OPTIONS = [
|
||||
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
|
||||
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
|
||||
{ label: "明确没有发生", answer_class: "no" as const },
|
||||
{ label: "这段记不清楚", answer_class: "unsure" as const },
|
||||
];
|
||||
|
||||
const RELOCATION_2015 = {
|
||||
id: "p-reloc-2015",
|
||||
semantic_key: "relocation.2015.05.dasha_boundary",
|
||||
candidate_split_hash: "reloc-2015-split",
|
||||
domain: "relocation",
|
||||
year: 2015,
|
||||
month: 5,
|
||||
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.87,
|
||||
source: "dasha_boundary",
|
||||
};
|
||||
|
||||
function eventProbeFromConflict(probe: typeof RELOCATION_2015) {
|
||||
return {
|
||||
year: probe.year,
|
||||
year_label: `${probe.year} 年 ${probe.month} 月前后`,
|
||||
month: probe.month,
|
||||
domain: probe.domain,
|
||||
event_family: "搬家或长期住到外地",
|
||||
source: probe.source,
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
tracks_agree: false,
|
||||
unique_minute_claim: false,
|
||||
user_meaning: probe.question,
|
||||
role: "distinguish",
|
||||
phase: "candidate_discriminator",
|
||||
information_gain: probe.information_gain,
|
||||
semantic_key: probe.semantic_key,
|
||||
candidate_split_hash: probe.candidate_split_hash,
|
||||
candidate_ids: probe.candidate_ids,
|
||||
expected_outcomes: probe.expected_outcomes,
|
||||
style_options: STYLE_OPTIONS,
|
||||
choice_kind: "existence" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function coverageEvidence() {
|
||||
return [
|
||||
evidenceRow(EVIDENCE_ID, "career_entry", "career", "2020-04-01", "month", "2020 年 4 月开始实习"),
|
||||
evidenceRow("44444444-4444-4444-8444-444444444445", "education_start", "education", "2016-09-01", "month", "2016 年入学"),
|
||||
evidenceRow("44444444-4444-4444-8444-444444444446", "relationship_start", "relationship", "2024-05-01", "month", "2024 年 5 月认识一位女生"),
|
||||
evidenceRow("44444444-4444-4444-8444-444444444447", "family_event", "family", "2021-03-01", "month", "2021 年家里有人住院"),
|
||||
];
|
||||
}
|
||||
|
||||
function evidenceRow(
|
||||
id: string,
|
||||
eventKind: string,
|
||||
domain: string,
|
||||
occurredFrom: string,
|
||||
datePrecision: string,
|
||||
summary: string,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: eventKind,
|
||||
domain,
|
||||
occurred_from: occurredFrom,
|
||||
occurred_to: occurredFrom,
|
||||
date_precision: datePrecision,
|
||||
summary,
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-28T07:35:45.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function distinguishDossier(activeFocus: unknown = null) {
|
||||
const inference = 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: "education", year: 2016, precision: "month" },
|
||||
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
|
||||
{ id: "e4", domain: "family", year: 2021, precision: "month" },
|
||||
],
|
||||
probes: [RELOCATION_2015],
|
||||
});
|
||||
return dossierFixture({
|
||||
evidenceCount: 4,
|
||||
evidence: coverageEvidence(),
|
||||
latestResult: candidateSnapshotFixture({
|
||||
decisionReceipt: {
|
||||
inference_state: inference,
|
||||
discriminating_event_probes: [eventProbeFromConflict(RELOCATION_2015)],
|
||||
},
|
||||
}),
|
||||
conversationSummary: conversationSummaryFixture({ activeFocus }),
|
||||
});
|
||||
}
|
||||
|
||||
function nextFollowupFromRaw(raw: unknown): MethodFollowup | null {
|
||||
const parsed = parseV9CaseDossier(raw);
|
||||
assert.ok(parsed);
|
||||
const decision = decideFromDossier(parsed);
|
||||
const catalog = rectificationFollowupCatalog(parsed.latestResult, parsed.evidence);
|
||||
const observations = internalObservationsFromWindowScan(
|
||||
windowScanFromDecisionReceipt(parsed.latestResult?.decisionReceipt ?? null),
|
||||
);
|
||||
return buildMethodFollowupPlan({
|
||||
evidence: parsed.evidence,
|
||||
activeFocus: parsed.conversationSummary.activeFocus,
|
||||
declinedTopics: parsed.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: parsed.conversationSummary.declinedSkippedTopics,
|
||||
observations,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
...catalog,
|
||||
birthDate: null,
|
||||
accepted: Boolean(parsed.case.acceptedTime),
|
||||
candidatesSeparated: decision.separation.sufficient,
|
||||
holdoutValidation: decision.holdoutValidation,
|
||||
}).next_followup;
|
||||
}
|
||||
|
||||
function identityMinusPrompt(schema: Record<string, unknown>) {
|
||||
const next = { ...schema };
|
||||
delete next.prompt;
|
||||
if (next.choice && typeof next.choice === "object" && !Array.isArray(next.choice)) {
|
||||
const choice = { ...(next.choice as Record<string, unknown>) };
|
||||
delete choice.prompt;
|
||||
next.choice = choice;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function setFocusHandler(store: { focus: ReturnType<typeof activeFocusFixture> | null }) {
|
||||
return (_fn: string, args: Record<string, unknown>) => {
|
||||
const incoming = (args.p_expected_answer_schema as Record<string, unknown>) ?? {};
|
||||
const existing = store.focus;
|
||||
const sameIdentity = Boolean(
|
||||
existing
|
||||
&& existing.question_id === args.p_question_id
|
||||
&& existing.intent === args.p_intent
|
||||
&& JSON.stringify(identityMinusPrompt(existing.expected_answer_schema as Record<string, unknown>))
|
||||
=== JSON.stringify(identityMinusPrompt(incoming)),
|
||||
);
|
||||
store.focus = {
|
||||
...activeFocusFixture({
|
||||
id: existing && sameIdentity ? existing.id : FOCUS_ID,
|
||||
questionId: String(args.p_question_id),
|
||||
intent: String(args.p_intent),
|
||||
targetEvidenceId: args.p_target_evidence_id ? String(args.p_target_evidence_id) : null,
|
||||
targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null,
|
||||
targetKind: args.p_target_kind == null ? null : String(args.p_target_kind),
|
||||
expectedAnswerSchema: incoming,
|
||||
askedTurnId: args.p_asked_turn_id ? String(args.p_asked_turn_id) : null,
|
||||
}),
|
||||
};
|
||||
return { focus: store.focus, idempotent: sameIdentity };
|
||||
};
|
||||
}
|
||||
|
||||
function toolsFor(accounting: ReturnType<typeof fakeAccounting>) {
|
||||
return createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
}
|
||||
|
||||
function discriminatorFollowup(): MethodFollowup {
|
||||
const frame = buildChoiceFrame({
|
||||
method_id: "dasha_events",
|
||||
ask_theme: "dated_event",
|
||||
domain: "education",
|
||||
user_prompt_hint: "ask",
|
||||
}, {
|
||||
probes: [{
|
||||
year: 2016,
|
||||
year_label: "2016 年前后",
|
||||
domain: "education",
|
||||
event_family: "升学结果或学习环境出现明显变化",
|
||||
source: "dasha_activation",
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
tracks_agree: true,
|
||||
unique_minute_claim: false,
|
||||
user_meaning: "2016 年前后升学结果或学习环境出现明显变化",
|
||||
role: "distinguish",
|
||||
information_gain: 0.4,
|
||||
semantic_key: "education:2016",
|
||||
style_options: STYLE_OPTIONS,
|
||||
candidate_ids: ["05:00", "05:20"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
|
||||
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
|
||||
],
|
||||
}],
|
||||
});
|
||||
assert.ok(frame);
|
||||
return {
|
||||
method_id: "dasha_events",
|
||||
intent: "distinguish_candidates",
|
||||
ask_theme: "dated_event",
|
||||
domain: "education",
|
||||
kind_hint: null,
|
||||
user_prompt_hint: "ask",
|
||||
must_not_label: false,
|
||||
choice_frame: frame,
|
||||
source: "event_probe",
|
||||
information_gain: 0.4,
|
||||
semantic_key: "education:2016",
|
||||
probe_year: 2016,
|
||||
candidate_ids: ["05:00", "05:20"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
|
||||
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("server-owned schema matches choice_frame copy and ignores Agent choice", () => {
|
||||
const followup = discriminatorFollowup();
|
||||
const copy = serverOwnedChoiceCopy(followup.choice_frame!);
|
||||
assert.ok(copy);
|
||||
const schema = expectedAnswerSchemaFor(
|
||||
followup.choice_frame!,
|
||||
stableFollowupQuestionId(followup),
|
||||
null,
|
||||
followup,
|
||||
);
|
||||
assert.ok(schema?.choice);
|
||||
const parsed = parseAgentChoiceCopy(schema);
|
||||
assert.deepEqual(parsed?.options, copy.options);
|
||||
const viaWrapper = serverOwnedExpectedAnswerSchema(followup, null);
|
||||
assert.deepEqual(
|
||||
parseAgentChoiceCopy(viaWrapper)?.options,
|
||||
copy.options,
|
||||
);
|
||||
});
|
||||
|
||||
test("Agent set-focus with only spokenPrompt persists server choice options", async () => {
|
||||
const raw = distinguishDossier();
|
||||
const followup = nextFollowupFromRaw(raw);
|
||||
assert.ok(followup?.choice_frame, JSON.stringify({
|
||||
intent: followup?.intent,
|
||||
source: followup?.source,
|
||||
method_id: followup?.method_id,
|
||||
}));
|
||||
const questionId = stableFollowupQuestionId(followup);
|
||||
const copy = serverOwnedChoiceCopy(followup.choice_frame);
|
||||
assert.ok(copy);
|
||||
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = { focus: null };
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => raw,
|
||||
set_agentic_rectification_conversation_focus: setFocusHandler(store),
|
||||
});
|
||||
const tools = toolsFor(accounting);
|
||||
const spokenPrompt = "记下了。2015 年前后,有没有搬家或长期住到外地?";
|
||||
const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||||
caseId: CASE_ID,
|
||||
questionId,
|
||||
intent: "distinguish_candidates",
|
||||
spokenPrompt,
|
||||
}) as Record<string, unknown>;
|
||||
const withAgentChoice = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||||
caseId: CASE_ID,
|
||||
questionId,
|
||||
intent: "distinguish_candidates",
|
||||
spokenPrompt,
|
||||
expectedAnswerSchema: {
|
||||
choice: {
|
||||
prompt: "伪造题干",
|
||||
option_a: "模型自编A",
|
||||
option_b: "模型自编B",
|
||||
option_c: "模型自编C",
|
||||
option_d: "模型自编D",
|
||||
options: [
|
||||
{ key: "A", label: "模型自编A", answer_class: "no" },
|
||||
{ key: "B", label: "模型自编B", answer_class: "yes" },
|
||||
{ key: "C", label: "模型自编C", answer_class: "unsure" },
|
||||
{ key: "D", label: "模型自编D", answer_class: "weak_yes" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
assert.equal(first.error, undefined);
|
||||
assert.equal(withAgentChoice.error, undefined);
|
||||
const firstSchema = first.expected_answer_schema as Record<string, unknown>;
|
||||
const secondSchema = withAgentChoice.expected_answer_schema as Record<string, unknown>;
|
||||
const firstCopy = parseAgentChoiceCopy(firstSchema);
|
||||
assert.ok(firstCopy);
|
||||
assert.deepEqual(firstCopy.options, copy.options);
|
||||
assert.equal(typeof firstSchema.probe_id, "string");
|
||||
assert.ok(String(firstSchema.probe_id).length > 0);
|
||||
assert.equal(projectCurrentQuestion({
|
||||
id: String(first.focus_id),
|
||||
questionId: String(first.question_id),
|
||||
intent: String(first.intent),
|
||||
targetDomain: typeof first.target_domain === "string" ? first.target_domain : null,
|
||||
expectedAnswerSchema: firstSchema,
|
||||
})?.kind, "choice");
|
||||
assert.deepEqual(identityMinusPrompt(firstSchema), identityMinusPrompt(secondSchema));
|
||||
assert.equal(firstCopy.option_a, copy.option_a);
|
||||
assert.notEqual(firstCopy.option_a, "模型自编A");
|
||||
const attached = attachQuestionsToTurns(
|
||||
[{ id: TURN_ID, role: "assistant", text: "记下了。", status: "completed" }],
|
||||
[{
|
||||
id: String(first.focus_id),
|
||||
caseId: CASE_ID,
|
||||
questionId: String(first.question_id),
|
||||
intent: String(first.intent),
|
||||
targetEvidenceId: null,
|
||||
targetDomain: typeof first.target_domain === "string" ? first.target_domain : null,
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: firstSchema,
|
||||
status: "active",
|
||||
askedAt: "2026-09-02T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
askedTurnId: TURN_ID,
|
||||
answerOption: null,
|
||||
}],
|
||||
);
|
||||
assert.equal(attached[0]?.question?.kind, "choice");
|
||||
assert.equal(attached[0]?.question?.options?.length, 4);
|
||||
});
|
||||
|
||||
test("collect set-focus writes collect:true even if Agent sends choice", async () => {
|
||||
const raw = dossierFixture({ latestResult: candidateSnapshotFixture() });
|
||||
const followup = nextFollowupFromRaw(raw);
|
||||
assert.equal(followup?.choice_frame ?? null, null);
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => raw,
|
||||
set_agentic_rectification_conversation_focus: setFocusHandler({ focus: null }),
|
||||
});
|
||||
const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||||
caseId: CASE_ID,
|
||||
questionId: followup ? stableFollowupQuestionId(followup) : "collect:relationship:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
spokenPrompt: "你还记得哪年上的大学吗?",
|
||||
expectedAnswerSchema: {
|
||||
choice: {
|
||||
prompt: "伪造采集",
|
||||
option_a: "模型自编A",
|
||||
option_b: "模型自编B",
|
||||
option_c: "模型自编C",
|
||||
option_d: "模型自编D",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
assert.equal(result.error, undefined);
|
||||
const schema = result.expected_answer_schema as Record<string, unknown>;
|
||||
assert.equal(schema.collect, true);
|
||||
assert.equal(schema.choice, undefined);
|
||||
assert.equal(projectCurrentQuestion({
|
||||
id: String(result.focus_id),
|
||||
questionId: String(result.question_id),
|
||||
intent: String(result.intent),
|
||||
expectedAnswerSchema: schema,
|
||||
})?.kind, "collect_spoken");
|
||||
});
|
||||
|
||||
test("set-focus returns no_pending_question when there is no next followup", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||
});
|
||||
const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||||
caseId: CASE_ID,
|
||||
questionId: "career-month-question",
|
||||
intent: "clarify_event_date",
|
||||
spokenPrompt: "你还记得哪年上的大学吗?",
|
||||
}) as Record<string, unknown>;
|
||||
assert.deepEqual(result, { ok: false, error: "no_pending_question" });
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("second set-focus on the same choice probe is idempotent and keeps options", async () => {
|
||||
const raw = distinguishDossier();
|
||||
const followup = nextFollowupFromRaw(raw);
|
||||
assert.ok(followup?.choice_frame);
|
||||
const questionId = stableFollowupQuestionId(followup);
|
||||
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = { focus: null };
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => raw,
|
||||
set_agentic_rectification_conversation_focus: setFocusHandler(store),
|
||||
});
|
||||
const execute = (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute;
|
||||
const first = await execute({
|
||||
caseId: CASE_ID,
|
||||
questionId,
|
||||
intent: "distinguish_candidates",
|
||||
spokenPrompt: "记下了。2015 年前后,有没有搬家或长期住到外地?",
|
||||
}) as Record<string, unknown>;
|
||||
const second = await execute({
|
||||
caseId: CASE_ID,
|
||||
questionId,
|
||||
intent: "distinguish_candidates",
|
||||
spokenPrompt: "工作记下了。2015 年 5 月前后,还记不记得搬过家?",
|
||||
}) as Record<string, unknown>;
|
||||
assert.equal(first.focus_id, second.focus_id);
|
||||
assert.equal(second.idempotent, true);
|
||||
assert.deepEqual(
|
||||
parseAgentChoiceCopy(first.expected_answer_schema as Record<string, unknown>)?.options,
|
||||
parseAgentChoiceCopy(second.expected_answer_schema as Record<string, unknown>)?.options,
|
||||
);
|
||||
});
|
||||
|
||||
test("list focuses RPC error marks GET questions unavailable and warns", async () => {
|
||||
const warns: string[] = [];
|
||||
const original = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(args.map(String).join(" "));
|
||||
};
|
||||
try {
|
||||
const accounting = fakeAccounting({
|
||||
list_agentic_rectification_conversation_focuses: () => {
|
||||
throw new Error("agentic_rectification_focus_not_found");
|
||||
},
|
||||
});
|
||||
const listed = await listV10ConversationFocuses(accounting.client, USER_ID, CASE_ID);
|
||||
assert.equal(listed.available, false);
|
||||
assert.deepEqual(listed.focuses, []);
|
||||
assert.equal(questionSourceFromFocusList(listed), "unavailable");
|
||||
assert.match(warns.join("\n"), /list focuses failed/);
|
||||
assert.match(warns.join("\n"), /focus_not_found/);
|
||||
const attached = attachQuestionsToTurns(
|
||||
[
|
||||
{ id: TURN_ID, role: "assistant", text: "你好。", status: "completed" },
|
||||
{ id: "44444444-4444-4444-8444-444444444444", role: "assistant", text: "记下了。", status: "completed" },
|
||||
],
|
||||
listed.focuses,
|
||||
);
|
||||
assert.ok(attached.every((turn) => turn.question === null));
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8");
|
||||
assert.match(casesRoute, /question_source: questionSourceFromFocusList\(listed\)/);
|
||||
assert.match(casesRoute, /return NextResponse\.json\(dossierResponse/);
|
||||
});
|
||||
|
||||
test("walkthrough-shaped chain rebuilds each assistant question from asked_turn_id", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(chat, /question: parseTurnQuestion\(turn\.question\)/);
|
||||
const collect = (id: string, prompt: string, status: "active" | "resolved") => ({
|
||||
id,
|
||||
caseId: CASE_ID,
|
||||
questionId: `collect:${id}:collect_method_evidence`,
|
||||
intent: "collect_method_evidence",
|
||||
targetEvidenceId: null,
|
||||
targetDomain: "relationship",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: { prompt, collect: true },
|
||||
status,
|
||||
askedAt: "2026-09-02T00:00:00.000Z",
|
||||
resolvedAt: status === "resolved" ? "2026-09-02T00:01:00.000Z" : null,
|
||||
askedTurnId: id,
|
||||
answerOption: null,
|
||||
});
|
||||
const choice = (
|
||||
id: string,
|
||||
prompt: string,
|
||||
year: string,
|
||||
status: "active" | "resolved",
|
||||
answer: "A" | "B" | "C" | "D" | null,
|
||||
) => ({
|
||||
id,
|
||||
caseId: CASE_ID,
|
||||
questionId: `probe:${year}`,
|
||||
intent: "distinguish_candidates",
|
||||
targetEvidenceId: null,
|
||||
targetDomain: "relocation",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: {
|
||||
prompt,
|
||||
probe_id: `probe:${year}`,
|
||||
choice: {
|
||||
prompt,
|
||||
option_a: STYLE_OPTIONS[0]!.label,
|
||||
option_b: STYLE_OPTIONS[1]!.label,
|
||||
option_c: STYLE_OPTIONS[2]!.label,
|
||||
option_d: STYLE_OPTIONS[3]!.label,
|
||||
options: STYLE_OPTIONS.map((option, index) => ({
|
||||
key: (["A", "B", "C", "D"] as const)[index]!,
|
||||
label: option.label,
|
||||
answer_class: option.answer_class,
|
||||
})),
|
||||
},
|
||||
},
|
||||
status,
|
||||
askedAt: "2026-09-02T00:00:00.000Z",
|
||||
resolvedAt: status === "resolved" ? "2026-09-02T00:01:00.000Z" : null,
|
||||
askedTurnId: id,
|
||||
answerOption: answer,
|
||||
});
|
||||
const opening = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
|
||||
const collectTwo = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2";
|
||||
const collectThree = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3";
|
||||
const tapOne = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4";
|
||||
const tapTwo = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5";
|
||||
const tapThree = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa6";
|
||||
const typed = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa7";
|
||||
const reverse = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa8";
|
||||
const turns = [
|
||||
{ id: opening, role: "assistant" as const, text: "从你最容易想起来的开始。", status: "completed" },
|
||||
{ id: collectTwo, role: "assistant" as const, text: "工作记下了。", status: "completed" },
|
||||
{ id: collectThree, role: "assistant" as const, text: "感情这块也记下了。", status: "completed" },
|
||||
{ id: tapOne, role: "assistant" as const, text: "接下来对一下这段经历。", status: "completed" },
|
||||
{ id: tapTwo, role: "assistant" as const, text: "候选比较更新了。", status: "completed" },
|
||||
{ id: tapThree, role: "assistant" as const, text: "再看一件相近的。", status: "completed" },
|
||||
{ id: typed, role: "assistant" as const, text: "也可以打字回答。", status: "completed" },
|
||||
{ id: reverse, role: "assistant" as const, text: "采用之后再核对一件前事。", status: "completed" },
|
||||
];
|
||||
const attached = attachQuestionsToTurns(turns, [
|
||||
collect(opening, "从你最容易想起来的开始就好,记得大概年份就行。", "resolved"),
|
||||
collect(collectTwo, "钱的方面,还记得哪年收入明显变过吗?", "resolved"),
|
||||
collect(collectThree, "家里有没有结婚、添丁或住院这类事?", "resolved"),
|
||||
choice(tapOne, "2015 年前后,有没有搬家或长期住到外地?", "2015", "resolved", "A"),
|
||||
choice(tapTwo, "2014 年前后,有没有升学、转学或换学习环境?", "2014", "resolved", "B"),
|
||||
choice(tapThree, "2018 年前后,有没有换过工作?", "2018", "resolved", "C"),
|
||||
choice(typed, "2023 年前后,有没有一段认真开始或结束的关系?", "2023", "resolved", null),
|
||||
{
|
||||
id: reverse,
|
||||
caseId: CASE_ID,
|
||||
questionId: "reverse:family:2021",
|
||||
intent: "reverse_verify",
|
||||
targetEvidenceId: null,
|
||||
targetDomain: "family",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: {
|
||||
prompt: "2021 年前后,家里有没有结婚、添丁或住院?",
|
||||
collect: true,
|
||||
},
|
||||
status: "active",
|
||||
askedAt: "2026-09-02T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
askedTurnId: reverse,
|
||||
answerOption: null,
|
||||
},
|
||||
]);
|
||||
const shapes = attached.map((turn) => {
|
||||
const question = parseTurnQuestion(turn.question);
|
||||
return {
|
||||
id: turn.id,
|
||||
kind: question?.kind ?? null,
|
||||
focus_id: question?.focus_id ?? null,
|
||||
answer_option: question?.answer_option ?? null,
|
||||
status: question?.status ?? null,
|
||||
options: question?.options?.length ?? 0,
|
||||
};
|
||||
});
|
||||
assert.deepEqual(shapes, [
|
||||
{ id: opening, kind: "collect_spoken", focus_id: opening, answer_option: null, status: "resolved", options: 0 },
|
||||
{ id: collectTwo, kind: "collect_spoken", focus_id: collectTwo, answer_option: null, status: "resolved", options: 0 },
|
||||
{ id: collectThree, kind: "collect_spoken", focus_id: collectThree, answer_option: null, status: "resolved", options: 0 },
|
||||
{ id: tapOne, kind: "choice", focus_id: tapOne, answer_option: "A", status: "resolved", options: 4 },
|
||||
{ id: tapTwo, kind: "choice", focus_id: tapTwo, answer_option: "B", status: "resolved", options: 4 },
|
||||
{ id: tapThree, kind: "choice", focus_id: tapThree, answer_option: "C", status: "resolved", options: 4 },
|
||||
{ id: typed, kind: "choice", focus_id: typed, answer_option: null, status: "resolved", options: 4 },
|
||||
{ id: reverse, kind: "reverse_verify", focus_id: reverse, answer_option: null, status: "active", options: 0 },
|
||||
]);
|
||||
assert.ok(attached[3]?.question?.prompt.includes("2015"));
|
||||
});
|
||||
|
||||
test("set-focus description no longer tells the Agent to write option_a", () => {
|
||||
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
|
||||
const hints = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8");
|
||||
assert.match(tools, /选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt/);
|
||||
assert.match(tools, /题干必须写出服务端给你的年份\/期间/);
|
||||
assert.doesNotMatch(tools, /写入 option_a/);
|
||||
assert.doesNotMatch(tools, /parseAgentChoiceCopy\(expectedAnswerSchemaInput\)/);
|
||||
assert.match(hints, /选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt/);
|
||||
assert.doesNotMatch(hints, /不要写 expectedAnswerSchema\.choice/);
|
||||
});
|
||||
@@ -147,6 +147,7 @@ test("missing current_question is explicit only for resumable cases", () => {
|
||||
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
|
||||
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
|
||||
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
|
||||
assert.match(chat, /题目加载失败,请刷新/);
|
||||
assert.match(chat, /readonly && \(/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,13 @@ test("spokenPrompt rejects a different year or domain than the server probe", ()
|
||||
questionId: "d9_relationship:relationship:2023",
|
||||
});
|
||||
assert.equal(yearMismatch.ok ? null : yearMismatch.reason, "year_mismatch");
|
||||
const yearMissing = validateSpokenPrompt({
|
||||
spokenPrompt: "你有没有一段认真开始或结束的关系?",
|
||||
followup: probe,
|
||||
targetDomain: "relationship",
|
||||
questionId: "d9_relationship:relationship:2023",
|
||||
});
|
||||
assert.equal(yearMissing.ok ? null : yearMissing.reason, "year_missing");
|
||||
const domainMismatch = validateSpokenPrompt({
|
||||
spokenPrompt: "2023 年前后,你有没有换过工作?",
|
||||
followup: probe,
|
||||
@@ -80,3 +87,31 @@ test("spokenPrompt rejects a different year or domain than the server probe", ()
|
||||
questionId: "d9_relationship:relationship:2023",
|
||||
}).ok, true);
|
||||
});
|
||||
|
||||
test("spokenPrompt requires each year in a period when probe_year is empty", () => {
|
||||
const ranged = followup({
|
||||
probe_year: undefined,
|
||||
choice_frame: {
|
||||
...followup().choice_frame!,
|
||||
period: "2014–2016 年前后",
|
||||
},
|
||||
});
|
||||
const questionId = "d9:relationship:2023";
|
||||
const missing = validateSpokenPrompt({
|
||||
spokenPrompt: "你有没有升学或换过学习环境?",
|
||||
followup: ranged,
|
||||
questionId,
|
||||
});
|
||||
assert.equal(missing.ok ? null : missing.reason, "year_missing");
|
||||
assert.equal(validateSpokenPrompt({
|
||||
spokenPrompt: "2014 到 2016 年前后,有没有升学或换过学习环境?",
|
||||
followup: ranged,
|
||||
questionId,
|
||||
}).ok, true);
|
||||
const collect = followup({ choice_frame: null, probe_year: 2023 });
|
||||
assert.equal(validateSpokenPrompt({
|
||||
spokenPrompt: "你还记得哪年入的职吗?",
|
||||
followup: collect,
|
||||
targetDomain: "relationship",
|
||||
}).ok, true);
|
||||
});
|
||||
|
||||
@@ -112,8 +112,18 @@ test("set/resolve focus services preserve the exact durable RPC contract", async
|
||||
test("set/resolve focus tools return safe projections and never infer the active focus", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
set_agentic_rectification_conversation_focus: () => ({
|
||||
focus: activeFocusFixture({ targetEvidenceId: EVIDENCE_ID }),
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
latestResult: candidateSnapshotFixture(),
|
||||
}),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: activeFocusFixture({
|
||||
questionId: String(args.p_question_id),
|
||||
intent: String(args.p_intent),
|
||||
targetEvidenceId: args.p_target_evidence_id ? String(args.p_target_evidence_id) : null,
|
||||
targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null,
|
||||
targetKind: args.p_target_kind == null ? null : String(args.p_target_kind),
|
||||
expectedAnswerSchema: (args.p_expected_answer_schema as Record<string, unknown>) ?? {},
|
||||
}),
|
||||
idempotent: false,
|
||||
}),
|
||||
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
@@ -129,23 +139,15 @@ test("set/resolve focus tools return safe projections and never infer the active
|
||||
caseId: CASE_ID,
|
||||
questionId: "career-month-question",
|
||||
intent: "clarify_event_date",
|
||||
spokenPrompt: "2016年前后,你是哪年上的大学?",
|
||||
targetEvidenceId: EVIDENCE_ID,
|
||||
targetDomain: "career",
|
||||
targetKind: "career_entry",
|
||||
});
|
||||
assert.deepEqual(setResult, {
|
||||
focus_id: FOCUS_ID,
|
||||
question_id: "question-1",
|
||||
intent: "confirm_revision",
|
||||
target_evidence_id: EVIDENCE_ID,
|
||||
target_domain: "career",
|
||||
target_kind: "career_entry",
|
||||
expected_answer_schema: {},
|
||||
status: "active",
|
||||
asked_at: "2026-08-12T10:00:05.000Z",
|
||||
idempotent: false,
|
||||
});
|
||||
spokenPrompt: "你还记得哪年上的大学吗?",
|
||||
}) as Record<string, unknown>;
|
||||
assert.equal(setResult.ok, undefined);
|
||||
assert.equal(setResult.focus_id, FOCUS_ID);
|
||||
assert.equal(setResult.error, undefined);
|
||||
const schema = setResult.expected_answer_schema as Record<string, unknown>;
|
||||
assert.equal(schema.collect, true);
|
||||
assert.equal(typeof schema.prompt, "string");
|
||||
assert.equal(schema.choice, undefined);
|
||||
|
||||
const resolved = await (tools["rectification-resolve-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||||
caseId: CASE_ID,
|
||||
|
||||
@@ -75,6 +75,7 @@ function validAccounting() {
|
||||
return fakeAccounting({
|
||||
get_agentic_rectification_turn_regeneration: () => null,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ turns: completedTurns() }),
|
||||
list_agentic_rectification_conversation_focuses: () => [],
|
||||
insert_agentic_rectification_skill_run_receipt: () => ({
|
||||
receipt_id: "99999999-9999-4999-8999-999999999999",
|
||||
}),
|
||||
@@ -214,6 +215,7 @@ test("only the latest completed Assistant turn can be regenerated", async () =>
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_turn_regeneration: () => null,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ turns }),
|
||||
list_agentic_rectification_conversation_focuses: () => [],
|
||||
});
|
||||
const { agent, calls } = fakeAgent();
|
||||
|
||||
@@ -237,6 +239,7 @@ test("missing or empty completed Assistant text is rejected", async () => {
|
||||
created_at: "2026-08-13T01:00:00.000Z",
|
||||
}],
|
||||
}),
|
||||
list_agentic_rectification_conversation_focuses: () => [],
|
||||
});
|
||||
const { agent, calls } = fakeAgent();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user