diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 19120d1d..6d7fa889 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5531,6 +5531,22 @@ - 复发自:无 - 修复版本:未修复 +## BUG-368 | 工具步骤英文规划被当成回答,set-focus 失败又整轮重放证据 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-24 +- 最近更新:2026-08-24 +- 影响面:`POST /api/rectification/agent`、Mastra `fullStream` 步骤边界、`rectification-set-focus`、Evidence Batch、用户消息 origin、推荐问题 +- 用户现象:用户说「2020年4月开始实习、6月转正、10月离职」后,界面先刷出残缺英文(`Let me`、`_probe`、`_gain`)。`rectification-set-focus` 连续失败后整轮重跑,已成功的证据再提交一次,只落地实习、另外两条被引文拒。随后聊天里出现一条用户从未输入的「2002 年发生什么了」。 +- 触发条件:自由文本经历回合;Agent 在调用工具前输出规划文本;`set-focus` 因重复探针或零信息增益被拒;失败运行的推荐问题被写进历史。 +- 根因:三组独立回归。(1) 禁止 `thinking.delta` 后,把每个 step 的 `text-delta` 都公开成 `answer.delta`,工具前规划变成用户正文;再对碎片做英文过滤,句子被剪成残片。(2) `compare-candidates` 已算出下一问,仍让模型自己调 `set-focus`;确定性校验失败后 `attempt.reset` 重放已成功的 Evidence 写入。(3) 未完成运行的 suggestion / 角色映射把「2002 年发生什么了」写成 user 消息。2002 不是引擎从 2020 算出来的。 +- 修复:按 step 缓冲,只发布无工具且 `stop`/`length` 的终端文本;`reasoning-delta` 不下发。Agent 工具列表去掉 `set-focus`,由 compare/read-case 在服务端持久化 `open_question`。`duplicate_focus` 等域错误不整轮重试,相同工具参数最多一次。用户消息记录 `origin`/`clientActionId`/`content_hash`。Evidence quote 用源消息 offset,按条返回 created/already_exists/quote_mismatch。推荐问题只在 `run.completed` 后解析,纠正 UI 仍无 suggestion chip。不改已哈希 Skill `10.0.11`。 +- 验证:`frontend/tests/rectification-step-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-server-focus.test.ts`、`frontend/tests/rectification-evidence-quote.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/rectification-v10-tool-contract.test.ts`。 +- 防复发:禁止把含工具调用的 step 的 `text-delta` 发给浏览器。禁止把 `thinking.delta` 改名为 `answer.delta`。禁止模型驱动 `set-focus`。禁止对 `duplicate_focus` / `quote_mismatch` / `zero_information_gain` 做 `attempt.reset`。禁止未完成运行解析或自动提交 suggestion。禁止模型改写 Evidence quote。 +- 相关记录:BUG-345、BUG-354、BUG-357、BUG-367 +- 复发自:BUG-367(关掉公开 `thinking.delta` 后,中间 step 的 `text-delta` 被整段当成回答) +- 修复版本:待发布 + ## BUG-367 | 点选 A 被当成聊天,部分写入后长推理失败并抢走滚动 - 状态:resolved diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 0ad5b54b..8625ec0a 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -14,7 +14,7 @@ import { loadRuntimeFeatureFlags } from "@/lib/feature-flags"; import { isProductEnabled } from "@/lib/product-access"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin"; export const runtime = "nodejs"; export const maxDuration = 120; @@ -31,6 +31,15 @@ const agentRequestSchema = z.object({ probeId: z.string().trim().min(1).max(200).nullable().optional(), optionId: z.enum(["A", "B", "C", "D"]).optional(), expectedRevision: z.number().int().min(0).max(10_000).optional(), + origin: z.enum([ + "typed", + "suggestion_click", + "choice_click", + "voice_input", + "retry_replay", + "system_recovery", + ]).optional(), + clientActionId: z.string().uuid().optional(), }).strict(); function actionToBudget(action: "opening" | "message" | "read_only"): RectificationAgentAction { @@ -346,6 +355,10 @@ export async function POST(request: Request) { requestId, action: actionToBudget(action), message: action === "message" ? parsed.data.message ?? "" : null, + messageOrigin: isRectificationMessageOrigin(parsed.data.origin) + ? parsed.data.origin + : defaultMessageOrigin(action), + clientActionId: parsed.data.clientActionId ?? requestId, modelName: selectedModel.id, skillName: RECTIFICATION_SKILL_NAME, skillVersion: skillVersion || RECTIFICATION_SKILL_VERSION, diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 1991af3f..179b1c28 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -468,6 +468,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { action, modelId: selectedModelId, ...(action === "message" ? { message: trimmed } : {}), + origin: "typed", + clientActionId: requestId, }), }); if (!response.ok) { @@ -527,14 +529,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { raw += event.text; activityTrace = freezeLiveThink(activityTrace); const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw); - const parsed = parseAgentReply(settled.spoken); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, - text: parsed.text, + text: settled.spoken, thinkingText: settled.thinking.trim() || undefined, activityTrace, - state: parsed.text ? "streaming" : "thinking", + state: settled.spoken ? "streaming" : "thinking", activity: nextActivityView(message.activity, { phase: "answer-composition", label: "正在组织回答…", @@ -639,7 +640,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw); - const parsed = parseAgentReply(settled.spoken); + const parsed = completed && !streamFailed ? parseAgentReply(settled.spoken) : { text: "", title: undefined }; const succeeded = completed && !streamFailed && Boolean(parsed.text); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; @@ -656,10 +657,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { activity: undefined, }]; } - if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) { + if (streamFailed || hasActivityReceipt(completedReceipt) || settled.spoken.trim()) { return [{ ...message, - text: parsed.text, + text: settled.spoken, thinkingText: settled.thinking.trim() || undefined, activityTrace: completeActivityTrace(activityTrace), state: "settled", @@ -670,7 +671,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } return []; })); - if (!succeeded && parsed.text) { + if (!succeeded && settled.spoken.trim()) { setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。"); } else if (!succeeded && runFailedMessage) { setError((current) => current || runFailedMessage); @@ -691,18 +692,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { : caught instanceof Error && caught.name === "AbortError"; if (aborted) { const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw); - const parsed = parseAgentReply(settled.spoken); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; - if (parsed.text || settled.thinking.trim()) { + if (settled.spoken.trim() || settled.thinking.trim() || hasActivityReceipt(completedReceipt)) { return [{ ...message, - text: parsed.text, + text: settled.spoken, thinkingText: settled.thinking.trim() || undefined, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, - failed: false, + failed: true, turnId: completedTurnId, }]; } @@ -784,6 +784,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { probeId: choiceCard.probe_id, optionId: optionId === "stop" ? undefined : optionId, expectedRevision: choiceCard.case_revision ?? 0, + origin: "choice_click", + clientActionId: actionId, }), }); const payload = await response.json().catch(() => null); diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index ea6914da..6ad99085 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -20,6 +20,7 @@ import { type V9CaseDossier, } from "./tool-service"; import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-status"; +import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt"; import { agentGenerationSettings } from "../../agent-generation-settings.ts"; import { toAgentModelFinishReason } from "../../agent-observability.ts"; import { @@ -36,6 +37,18 @@ import { } from "./stream-mapping"; import { splitRectificationSpokenAndThinking } from "./spoken-answer"; import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic"; +import { + applyStepAnswerChunk, + createStepAnswerState, + flushStepAnswerOnStreamFinish, +} from "./step-answer"; +import { composeRectificationTurnNarration, publicNarrationDtoFromDossier } from "./turn-narration"; +import { + defaultMessageOrigin, + isRectificationMessageOrigin, + messageContentHash, + type RectificationMessageOrigin, +} from "./message-origin"; export type V9RunBilling = Readonly<{ reserve(): Promise<{ success: boolean; reason?: string; status: number }>; @@ -50,6 +63,8 @@ export type V9AgentRunOptions = Readonly<{ requestId: string; action: RectificationAgentAction; message: string | null; + messageOrigin?: RectificationMessageOrigin; + clientActionId?: string | null; modelName: string; skillName?: string; skillVersion?: string; @@ -94,16 +109,14 @@ type AttemptOutcome = Readonly<{ attemptId: string; }>; -const REPEATED_TOOL_CALL_LIMIT = 3; +const REPEATED_TOOL_CALL_LIMIT = 1; const MAX_ATTEMPTS = 2; const RETRYABLE_ERROR_CODES = new Set([ - "empty_stream", "stream_aborted", "stream_unfinished", "skill_not_loaded", "skill_not_bound", "case_not_loaded", - "focus_persistence_failed", ]); function streamFinishReason(chunk: { @@ -111,7 +124,9 @@ function streamFinishReason(chunk: { payload?: { stepResult?: { reason?: unknown }; reason?: unknown }; }): ReturnType | null { if (chunk.type !== "finish") return null; - return toAgentModelFinishReason(chunk.payload?.stepResult?.reason ?? chunk.payload?.reason); + const raw = chunk.payload?.stepResult?.reason ?? chunk.payload?.reason; + if (typeof raw !== "string" || !raw.trim()) return null; + return toAgentModelFinishReason(raw); } function first(value: unknown): unknown { @@ -241,6 +256,21 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { activeTools: string[]; toolChoice: "auto"; - } | undefined; + }; }, ): Promise<{ fullStream: AsyncIterable<{ @@ -574,10 +603,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise | null = null; + const stepAnswer = createStepAnswerState(); + + const publishSpokenStep = async (pieces: readonly string[]) => { + const spoken = splitRectificationSpokenAndThinking(pieces.join("")).spoken.trim(); + if (!spoken || !caseLoaded) return; + answerText += spoken; + answerDeltas.push(spoken); + await emit({ type: "answer.delta", text: spoken }); + }; for await (const chunk of result.fullStream) { const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : ""; @@ -596,6 +637,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise; diff --git a/frontend/src/lib/rectification-agentic/v9/evidence-quote.ts b/frontend/src/lib/rectification-agentic/v9/evidence-quote.ts new file mode 100644 index 00000000..351d6612 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/evidence-quote.ts @@ -0,0 +1,78 @@ +/** + * Ground evidence quotes in the source user turn. The model may propose + * offsets or a quote string; the server always slices the original message. + */ + +const QUOTE_PUNCT = /[\s\u3000,。!?、;:“”‘’()《》·—…,!.;:?]/g; + +export function normalizeRectificationQuote(value: string): string { + return value.toLowerCase().replace(QUOTE_PUNCT, ""); +} + +export type EvidenceQuoteInput = Readonly<{ + quote?: string | null; + quoteStart?: number | null; + quoteEnd?: number | null; +}>; + +export type ResolvedEvidenceQuote = + | { ok: true; quote: string; quoteStart: number; quoteEnd: number } + | { ok: false; errorCode: "quote_mismatch" }; + +function sliceByOffsets(source: string, start: number, end: number): ResolvedEvidenceQuote { + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > source.length || start >= end) { + return { ok: false, errorCode: "quote_mismatch" }; + } + const quote = source.slice(start, end); + if (quote.trim().length < 2) return { ok: false, errorCode: "quote_mismatch" }; + return { ok: true, quote, quoteStart: start, quoteEnd: end }; +} + +function sliceByNormalizedQuote(source: string, quote: string): ResolvedEvidenceQuote { + const needle = normalizeRectificationQuote(quote); + if (needle.length < 2) return { ok: false, errorCode: "quote_mismatch" }; + const map: number[] = []; + let normalized = ""; + for (let index = 0; index < source.length; index += 1) { + const char = source[index] ?? ""; + if (QUOTE_PUNCT.test(char)) continue; + normalized += char.toLowerCase(); + map.push(index); + } + const at = normalized.indexOf(needle); + if (at < 0) return { ok: false, errorCode: "quote_mismatch" }; + const start = map[at]; + const last = map[at + needle.length - 1]; + if (start === undefined || last === undefined) return { ok: false, errorCode: "quote_mismatch" }; + return sliceByOffsets(source, start, last + 1); +} + +export function resolveEvidenceQuote( + sourceTurnText: string | null | undefined, + input: EvidenceQuoteInput, +): ResolvedEvidenceQuote { + const source = sourceTurnText ?? ""; + if (!source.trim()) return { ok: false, errorCode: "quote_mismatch" }; + if (input.quoteStart != null && input.quoteEnd != null) { + return sliceByOffsets(source, input.quoteStart, input.quoteEnd); + } + const quote = typeof input.quote === "string" ? input.quote.trim() : ""; + if (!quote) return { ok: false, errorCode: "quote_mismatch" }; + return sliceByNormalizedQuote(source, quote); +} + +export function publicEvidenceItemStatus(input: { + outcome: string; + idempotent: boolean; + errorCode: string | null; +}): "created" | "already_exists" | "needs_clarification" | "quote_mismatch" | "rejected" { + if (input.errorCode === "quote_not_grounded" || input.errorCode === "quote_mismatch") { + return "quote_mismatch"; + } + if (input.idempotent && (input.outcome === "accepted" || input.outcome === "already_exists")) { + return "already_exists"; + } + if (input.outcome === "accepted") return "created"; + if (input.outcome === "needs_clarification") return "needs_clarification"; + return "rejected"; +} diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index d7f3e3b2..0465f0ad 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -40,6 +40,7 @@ export function askedProbeKeysFromReceipt( for (const item of answers) { if (!item || typeof item !== "object") continue; const row = item as Record; + if (typeof row.probe_id === "string") keys.push(row.probe_id); if (typeof row.semantic_key === "string") keys.push(row.semantic_key); if (typeof row.candidate_split_hash === "string") keys.push(row.candidate_split_hash); } diff --git a/frontend/src/lib/rectification-agentic/v9/message-origin.ts b/frontend/src/lib/rectification-agentic/v9/message-origin.ts new file mode 100644 index 00000000..95f1dde6 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/message-origin.ts @@ -0,0 +1,27 @@ +import { createHash } from "node:crypto"; + +export const RECTIFICATION_MESSAGE_ORIGINS = [ + "typed", + "suggestion_click", + "choice_click", + "voice_input", + "retry_replay", + "system_recovery", +] as const; + +export type RectificationMessageOrigin = (typeof RECTIFICATION_MESSAGE_ORIGINS)[number]; + +export function isRectificationMessageOrigin(value: unknown): value is RectificationMessageOrigin { + return typeof value === "string" + && (RECTIFICATION_MESSAGE_ORIGINS as readonly string[]).includes(value); +} + +export function defaultMessageOrigin(action: string): RectificationMessageOrigin { + if (action === "answer_choice" || action === "stop_and_review") return "choice_click"; + if (action === "opening") return "system_recovery"; + return "typed"; +} + +export function messageContentHash(text: string | null | undefined): string { + return createHash("sha256").update(text ?? "").digest("hex"); +} diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 83412c63..522414f0 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -293,11 +293,11 @@ function coverage( } function collectHint(why: string, varga: string, extra = ""): string { - return `${why}本题绑定 ${varga}。${extra}用自然语言问一件带大概年份的经历。set-focus 不要写 expectedAnswerSchema.choice,界面不出点选卡。允许模糊年份。`.replace(/\s+/g, " ").trim(); + return `${why}本题绑定 ${varga}。${extra}用自然语言问一件带大概年份的经历。不要调用 set-focus,界面不出点选卡。允许模糊年份。`.replace(/\s+/g, " ").trim(); } function agentHint(why: string, varga: string, extra = ""): string { - return `${why}本题绑定 ${varga}。${extra}根据 choice_frame 自己写题干和 A/B/C/D,经 set-focus.expectedAnswerSchema.choice 交给点选卡。题干由你写成自然语言;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份,不要照抄 hint。A 是这件事大概就在那段时间,B 是有类似但年份不对或不够重大,C 是没有明显发生,D 是不记得。正文不要复述选项。`.replace(/\s+/g, " ").trim(); + return `${why}本题绑定 ${varga}。${extra}点选卡已由服务器按 choice_frame 持久化。用简体中文只问这一句已持久化的题干;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份。不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim(); } export function shouldAttachChoiceFrame( @@ -634,7 +634,7 @@ export function buildMethodFollowupPlan(input: { domain: focus.targetDomain, kind_hint: focus.targetKind, user_prompt_hint: keepChoice - ? "先承接当前服务器焦点。根据 choice_frame 自己写题干和 A/B/C/D,经 set-focus.expectedAnswerSchema.choice 交给点选卡;年份不得发明,不要照抄 hint。正文不要复述选项。" + ? "先承接当前服务器已持久化的焦点和点选卡。用简体中文只问这一句;年份不得发明。不要调用 set-focus。正文不要复述选项。" : "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则继续用自然语言问一件带大概年份的事。不要写 expectedAnswerSchema.choice。", source: "active_focus", }, true, keepChoice), diff --git a/frontend/src/lib/rectification-agentic/v9/public-receipt.ts b/frontend/src/lib/rectification-agentic/v9/public-receipt.ts index b530ca0b..16353841 100644 --- a/frontend/src/lib/rectification-agentic/v9/public-receipt.ts +++ b/frontend/src/lib/rectification-agentic/v9/public-receipt.ts @@ -54,6 +54,10 @@ export const PUBLIC_RECTIFICATION_TOOLS = [ export type PublicRectificationTool = (typeof PUBLIC_RECTIFICATION_TOOLS)[number]; +export const RECTIFICATION_AGENT_TOOLS = PUBLIC_RECTIFICATION_TOOLS.filter( + (tool) => tool !== "rectification-set-focus", +); + export const PUBLIC_RECTIFICATION_METHODS = [ "d1-rashi", "d2-hora", @@ -115,6 +119,7 @@ export type RectificationActivityEvent = Readonly<{ tool: PublicRectificationTool; status: RectificationActivityStatus; methods?: readonly PublicRectificationMethod[]; + code?: string; }>; export const RECEIPT_STATUSES = [ diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts new file mode 100644 index 00000000..bb8a24dd --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -0,0 +1,157 @@ +import { + serverOwnedChoiceCopy, + type RectificationChoiceFrame, +} from "./choice-card"; +import { + askedProbeKeysFromReceipt, + stampChoiceSchemaWithProbe, + previousInferenceFromReceipt, +} from "./inference-adapter"; +import type { MethodFollowup } from "./method-followup"; +import { + setV10ConversationFocus, + RectificationToolServiceError, + safeToolErrorCode, + type AccountingClient, + type ConversationFocus, +} from "./tool-service"; + +export type PersistServerFocusStatus = + | "created" + | "already_open" + | "duplicate_focus" + | "probe_already_answered" + | "zero_information_gain" + | "skipped"; + +export type PersistServerFocusResult = Readonly<{ + status: PersistServerFocusStatus; + focus: ConversationFocus | null; + questionId: string | null; + prompt: string | null; +}>; + +export function stableFollowupQuestionId(followup: MethodFollowup): string { + if (followup.semantic_key) return `probe:${followup.semantic_key}`.slice(0, 160); + if (followup.probe_year && followup.domain) { + return `${followup.method_id}:${followup.domain}:${followup.probe_year}`.slice(0, 160); + } + return (followup.choice_frame?.question_id ?? `${followup.method_id}:${followup.ask_theme}`).slice(0, 160); +} + +function schemaProbeId(schema: Readonly> | null | undefined): string | null { + const probeId = schema?.probe_id; + return typeof probeId === "string" && probeId.trim() ? probeId : null; +} + +export function shouldSkipDiscriminatorFollowup(followup: MethodFollowup): PersistServerFocusStatus | null { + if (followup.source === "event_probe" && (followup.information_gain ?? 0) <= 0) { + return "zero_information_gain"; + } + return null; +} + +function expectedAnswerSchemaFor( + frame: RectificationChoiceFrame, + questionId: string, + decisionReceipt: Readonly> | null | undefined, +): Record | null { + const copy = serverOwnedChoiceCopy(frame); + if (!copy) return null; + const schema: Record = { + choice: { + prompt: copy.prompt, + option_a: copy.option_a, + option_b: copy.option_b, + option_c: copy.option_c, + option_d: copy.option_d, + }, + }; + return stampChoiceSchemaWithProbe( + schema, + previousInferenceFromReceipt(decisionReceipt ?? null), + questionId, + ); +} + +export async function persistServerOwnedFocus(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + activeFocus: ConversationFocus | null; + decisionReceipt: Readonly> | null | undefined; + followup: MethodFollowup | null; +}): Promise { + const followup = input.followup; + const frame = followup?.choice_frame ?? null; + if (!followup || !frame) { + return { status: "skipped", focus: input.activeFocus, questionId: null, prompt: null }; + } + const skip = shouldSkipDiscriminatorFollowup(followup); + if (skip) { + return { status: skip, focus: input.activeFocus, questionId: null, prompt: null }; + } + const questionId = stableFollowupQuestionId(followup); + const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt); + if (!schema?.choice) { + return { status: "skipped", focus: input.activeFocus, questionId, prompt: null }; + } + const copy = serverOwnedChoiceCopy(frame); + const prompt = copy?.prompt ?? null; + const answeredKeys = new Set(askedProbeKeysFromReceipt(input.decisionReceipt)); + if (followup.semantic_key && answeredKeys.has(followup.semantic_key)) { + return { + status: "probe_already_answered", + focus: input.activeFocus, + questionId, + prompt: null, + }; + } + const active = input.activeFocus; + if ( + active + && ( + active.questionId === questionId + || (schemaProbeId(schema) && schemaProbeId(active.expectedAnswerSchema) === schemaProbeId(schema)) + ) + ) { + return { status: "already_open", focus: active, questionId: active.questionId, prompt }; + } + if (followup.source === "event_probe" && !schemaProbeId(schema)) { + return { + status: "probe_already_answered", + focus: input.activeFocus, + questionId, + prompt: null, + }; + } + try { + const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, { + questionId, + intent: followup.intent, + targetEvidenceId: null, + targetDomain: followup.domain, + targetKind: null, + expectedAnswerSchema: schema, + }); + return { + status: result.idempotent ? "already_open" : "created", + focus: result.focus, + questionId: result.focus.questionId, + prompt, + }; + } catch (error) { + const code = error instanceof RectificationToolServiceError + ? error.code + : safeToolErrorCode(error); + if (code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict")) { + return { + status: "duplicate_focus", + focus: input.activeFocus, + questionId, + prompt, + }; + } + throw error; + } +} diff --git a/frontend/src/lib/rectification-agentic/v9/step-answer.ts b/frontend/src/lib/rectification-agentic/v9/step-answer.ts new file mode 100644 index 00000000..ac3f3830 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/step-answer.ts @@ -0,0 +1,123 @@ +/** + * Isolate the terminal no-tool step from Mastra multi-step agent runs. + * + * `text-delta` is not a user answer. A tool-using step may emit planning + * prose before the call; that text must be discarded as a whole. Only a + * step that never called a public tool and finished with `stop` may become + * `answer.delta`. + */ + +export type StepAnswerChunk = Readonly<{ + type: string; + payload?: { + text?: unknown; + toolName?: unknown; + stepResult?: { reason?: unknown }; + reason?: unknown; + }; +}>; + +export type StepAnswerState = { + text: string; + pieces: string[]; + calledTool: boolean; +}; + +export type StepAnswerEffect = + | { kind: "none" } + | { kind: "discard" } + | { kind: "publish"; pieces: readonly string[] }; + +function reset(state: StepAnswerState): void { + state.text = ""; + state.pieces = []; + state.calledTool = false; +} + +export function createStepAnswerState(): StepAnswerState { + return { text: "", pieces: [], calledTool: false }; +} + +export function stepFinishReason(chunk: StepAnswerChunk): string | null { + if (chunk.type !== "step-finish" && chunk.type !== "finish") return null; + const raw = chunk.payload?.stepResult?.reason ?? chunk.payload?.reason; + return typeof raw === "string" && raw.trim() ? raw.trim() : null; +} + +export function shouldPublishStepText( + state: Pick, + reason: string | null, +): boolean { + if (state.calledTool) return false; + if (!state.text.trim()) return false; + return reason === "stop" || reason === "length" || reason === null; +} + +function isPublicToolCall( + chunk: StepAnswerChunk, + isPublicTool: (name: string) => boolean, +): boolean { + const name = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : ""; + return Boolean(name) && name !== "skill" && isPublicTool(name); +} + +/** + * Advance the per-step buffer. Publishing happens only on `step-finish` + * (or a later `flushStepAnswerOnStreamFinish` when Mastra omitted it). + * Tool-result ends the current step so later text is a new step. + */ +export function applyStepAnswerChunk( + state: StepAnswerState, + chunk: StepAnswerChunk, + isPublicTool: (name: string) => boolean, +): StepAnswerEffect { + switch (chunk.type) { + case "reasoning-start": + case "reasoning-delta": + case "reasoning-end": + return { kind: "none" }; + case "step-start": + reset(state); + return { kind: "none" }; + case "text-delta": { + const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : ""; + if (text) { + state.text += text; + state.pieces.push(text); + } + return { kind: "none" }; + } + case "tool-call": + if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true; + return { kind: "none" }; + case "tool-result": + case "tool-error": { + if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true; + const discarded = state.calledTool || state.text.length > 0; + reset(state); + return discarded ? { kind: "discard" } : { kind: "none" }; + } + case "step-finish": { + const reason = stepFinishReason(chunk); + const publish = shouldPublishStepText(state, reason); + const pieces = publish ? [...state.pieces] : []; + reset(state); + return publish ? { kind: "publish", pieces } : { kind: "discard" }; + } + default: + return { kind: "none" }; + } +} + +export function flushStepAnswerOnStreamFinish( + state: StepAnswerState, + finishReason: string | null, +): StepAnswerEffect { + if (!shouldPublishStepText(state, finishReason === "stop" || finishReason === null ? "stop" : finishReason)) { + reset(state); + return { kind: "none" }; + } + const pieces = [...state.pieces]; + reset(state); + return { kind: "publish", pieces }; +} diff --git a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts index 90861651..8b07d2a0 100644 --- a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts +++ b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts @@ -144,11 +144,8 @@ export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicPhaseStreamE const methods = METHOD_TOOLS.has(toolName) ? resultMethods(chunk) : []; return phase ? { type: phase, tool: toolName, ...(methods.length > 0 ? { methods } : {}) } : null; } - case "text-delta": { - const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : ""; - if (!text) return null; - return { type: "answer.delta", text }; - } + case "text-delta": + return null; case "finish": // Completion is decided by the runner after the skill/first-turn gates; // a finish chunk alone never proves a settled answer. @@ -186,6 +183,34 @@ export function mapStreamChunkToThinking(chunk: AgentChunkType): InternalThinkin * deliberately separate from the durable phase receipt: it never exposes * args, results, provider errors, scores, birth data or permission flags. */ +const SAFE_TOOL_ACTIVITY_CODES = new Set([ + "duplicate_focus", + "probe_already_answered", + "stale_revision", + "quote_mismatch", + "quote_not_grounded", + "zero_information_gain", + "invalid_tool_input", + "invalid_choice_copy", + "already_exists", + "focus_idempotency_conflict", + "invalid_focus", +]); + +function safeToolActivityCode(error: unknown): string | undefined { + const message = error instanceof Error ? error.message : String(error ?? ""); + for (const code of SAFE_TOOL_ACTIVITY_CODES) { + if (message.includes(code)) { + return code === "focus_idempotency_conflict" || code === "invalid_focus" + ? "duplicate_focus" + : code === "quote_not_grounded" + ? "quote_mismatch" + : code; + } + } + return undefined; +} + export function mapStreamChunkToActivity(chunk: AgentChunkType): RectificationActivityEvent | null { if (chunk.type !== "tool-call" && chunk.type !== "tool-result" && chunk.type !== "tool-error") { return null; @@ -196,7 +221,13 @@ export function mapStreamChunkToActivity(chunk: AgentChunkType): RectificationAc return { type: "tool.activity", tool: toolName, status: "started" }; } if (chunk.type === "tool-error") { - return { type: "tool.activity", tool: toolName, status: "failed" }; + const code = safeToolActivityCode(chunk.payload?.error); + return { + type: "tool.activity", + tool: toolName, + status: "failed", + ...(code ? { code } : {}), + }; } const methods = resultMethods(chunk); return { @@ -228,6 +259,7 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null { activity?: unknown; questionId?: unknown; recoverable?: unknown; + origin?: unknown; }; if (event.type === "thinking.delta") return null; if (event.type === "error") { @@ -254,11 +286,15 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null { const methods = event.status === "completed" && Array.isArray(event.methods) ? [...new Set(event.methods.filter(isPublicRectificationMethod))] : []; + const code = typeof event.code === "string" && SAFE_TOOL_ACTIVITY_CODES.has(event.code) + ? event.code + : undefined; return { type: "tool.activity", tool: event.tool, status: event.status, ...(methods.length > 0 ? { methods } : {}), + ...(code ? { code } : {}), }; } if (event.type === "activity.changed") { diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 0316b12f..6c9999b6 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -1153,6 +1153,8 @@ export type V10EvidenceBatchItem = Readonly<{ occurredTo: string | null; datePrecision: string; summary: string; + quoteStart?: number | null; + quoteEnd?: number | null; }>; export type V10EvidenceBatchResult = Readonly<{ @@ -1186,6 +1188,8 @@ export async function recordV10EvidenceBatch( sourceTurnId, JSON.stringify({ quote: item.quote, + quote_start: item.quoteStart ?? null, + quote_end: item.quoteEnd ?? null, subject: item.subject, event_kind: item.eventKind, domain: item.domain, diff --git a/frontend/src/lib/rectification-agentic/v9/turn-narration.ts b/frontend/src/lib/rectification-agentic/v9/turn-narration.ts new file mode 100644 index 00000000..82dd9923 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/turn-narration.ts @@ -0,0 +1,32 @@ +import { parseAgentChoiceCopy } from "./choice-card"; +import type { V9CaseDossier } from "./tool-service"; + +export type RectificationNarrationDto = Readonly<{ + acknowledgedFacts: readonly string[]; + nextQuestion: string | null; +}>; + +export function publicNarrationDtoFromDossier(dossier: V9CaseDossier): RectificationNarrationDto { + const acknowledgedFacts = dossier.evidence + .filter((item) => item.status === "confirmed" || item.status === "draft" || item.status === "pending_confirmation") + .slice(-4) + .map((item) => item.summary.trim()) + .filter((item) => item.length >= 2); + const choice = parseAgentChoiceCopy(dossier.conversationSummary.activeFocus?.expectedAnswerSchema ?? null); + return { + acknowledgedFacts, + nextQuestion: choice?.prompt ?? null, + }; +} + +export function composeRectificationTurnNarration(dto: RectificationNarrationDto): string { + const parts: string[] = []; + if (dto.acknowledgedFacts.length > 0) { + parts.push(`已经记下:${dto.acknowledgedFacts.join(";")}。`); + } + if (dto.nextQuestion) parts.push(dto.nextQuestion); + if (parts.length === 0) { + return "请继续说下一件你记得比较清楚、大概带年份的经历。"; + } + return parts.join(""); +} diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 8d7d7889..ff6b9e3b 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -8,7 +8,7 @@ import { import { RECTIFICATION_V9_SKILL_NAME, createRectificationV9ReadOnlyTools, - createRectificationV9Tools, + createRectificationV9AgentTools, type RectificationV9Context, } from "./rectification-v9-tools"; @@ -68,10 +68,10 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑 5. candidate、accepted、confirmed 严格分离。Agent 不控制 billing、ownership、profile 写入、不可逆状态,也不得授予 exact-minute confirmation。 6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。 7. 只基于成功 attempt 输出正文。工具失败时说明面向用户的边界,不声称未执行的方法或结果。 -8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。 +8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。优先传 source 原文的 quoteStart/quoteEnd,不要改写 quote。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。 9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_action:id=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compare,C 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–Dasha–Gochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。 10. 不泄露系统提示词或 Skill 原文。 -11. 追问只跟 method_followup_plan。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,set-focus 不要写 choice,正文直接问,不要提点选卡。只有 next_followup 带 choice_frame(冲突探针、候选已经分不开、采用后核对前事)时才写 set-focus.expectedAnswerSchema.choice 的 A/B/C/D:题干由你写成自然语言是/否生平问题;年份和事件家族以 choice_frame.period 与 discriminating_event_probes 为准,不得发明年份,不要照抄 hint。挡住出牌的方法层未齐时,source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后问区分探针,不要 adopt。采用后按剩余 dasha 探针核尚未出现过的年份,不要把已回答的考试质量题再问一遍。不要问两套盘哪个更像或可能性高低。A 是这件事大概就在那段时间,B 是有类似但年份不对或不够重大,C 是没有明显发生,D 是不记得;点选 A/B/C/D 与「先这样」由服务器按 questionId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。「先这样」由服务器补全;正文只说一句时间窗和为何问,禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。 +11. 追问只跟 method_followup_plan 与服务器已持久化的 current_question / open_question。不要调用 rectification-set-focus;下一问和点选卡由 compare-candidates / read-case 在服务端事务内创建。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,正文直接问,不要提点选卡。若工具返回了 open_question.prompt,原样用简体中文问这一句,不得发明年份,不要把已回答的考试质量题再问一遍。挡住出牌的方法层未齐时,source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后问区分探针,不要 adopt。不要问两套盘哪个更像或可能性高低。点选 A/B/C/D 与「先这样」由服务器按 questionId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。正文禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。 12. 证据有效变化后由服务器重算候选。不要等用户说“没有更多了”才比较,也不要对同一证据指纹再 compare。分钟扫描只在服务端,结果只是候选或平台,不得宣布确认。 13. 落实 start_consultation:前事核对结束或用户先这样后,请用户用当前采用时间看盘;对不上同时请改选其他候选。解释事件–Dasha 账本、双轨是否一致、换升时刻、精度阶段、D9/D10 类型对照和相对支持时,仍必须说候选范围不是出生时间真值。`; @@ -86,7 +86,7 @@ export function getRectificationV9Agent( model: model.model, instructions: agenticRectificationInstructions, skills: [resolveSkillPackageRuntimePath(skillPackage)], - tools: createRectificationV9Tools(ctx), + tools: createRectificationV9AgentTools(ctx), }); } diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index dc36310f..ab7bd5e7 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -71,6 +71,11 @@ import { previousInferenceFromReceipt, stampChoiceSchemaWithProbe, } from "@/lib/rectification-agentic/v9/inference-adapter"; +import { persistServerOwnedFocus } from "@/lib/rectification-agentic/v9/server-focus"; +import { + publicEvidenceItemStatus, + resolveEvidenceQuote, +} from "@/lib/rectification-agentic/v9/evidence-quote"; import { projectTurnDecision } from "@/lib/rectification-agentic/v9/turn-decision"; import { posteriorMap, @@ -818,6 +823,22 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { return { persisted, score, parsed, windowScan: score.windowScan }; }; + const persistPlanFocus = async ( + parsed: DossierForTools, + latest: NonNullable, + ) => { + const collectingPlan = collectingFollowupForParsed(parsed, latest); + const persistedFocus = await persistServerOwnedFocus({ + accounting, + userId, + caseId, + activeFocus: parsed.conversationSummary.activeFocus, + decisionReceipt: latest.decisionReceipt, + followup: collectingPlan.next_followup, + }); + return { collectingPlan, persistedFocus }; + }; + const autoRescoreAfterEvidenceChange = async (targetCaseId: string) => { try { const dossier = await loadV9CaseDossier(accounting, userId, targetCaseId); @@ -833,6 +854,18 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: true }; } const scored = await scoreAndPersistCurrentEvidence(targetCaseId); + const latest = { + resultId: scored.persisted.resultId, + candidates: scored.persisted.candidates, + selectionAllowed: scored.persisted.selectionAllowed, + confirmationAllowed: scored.persisted.confirmationAllowed, + representativeTime: scored.persisted.representativeTime, + selectedTime: null, + selectionKind: null, + algorithmVersion: scored.persisted.algorithmVersion, + decisionReceipt: scored.persisted.decisionReceipt, + }; + await persistPlanFocus(scored.parsed, latest); return { status: "completed" as const, executedMethods: scored.score.executedMethods, @@ -863,6 +896,22 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { await receipt("rectification-read-case", "case.loaded", "started", { inputFingerprint }); try { const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); + const parsed = parseDossierForTools(dossier); + if (parsed.latestResult) { + const persisted = await persistPlanFocus(parsed, parsed.latestResult); + if (persisted.persistedFocus.status === "created") { + const refreshed = await loadV9CaseDossier(accounting, userId, input.caseId); + const projectionKind = input.projection ?? "turn_decision"; + const projection = projectionKind === "full_diagnostics" + ? safeCaseProjection( + parseDossierForTools(refreshed), + await loadV9CaseCompute(accounting, userId, input.caseId), + ) + : projectTurnDecision(refreshed); + await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) }); + return projection; + } + } const projectionKind = input.projection ?? "turn_decision"; const projection = projectionKind === "full_diagnostics" ? safeCaseProjection( @@ -1098,7 +1147,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { caseId: z.string().uuid(), focusId: z.string().uuid().nullable().optional(), items: z.array(z.object({ - quote: z.string().trim().min(2).max(400), + quote: z.string().trim().min(2).max(400).optional(), + quoteStart: z.number().int().min(0).max(4000).optional(), + quoteEnd: z.number().int().min(1).max(4000).optional(), proposedKind: evidenceKindSchema, subject: z.enum(["self", "family", "other"]).default("self"), domain: evidenceDomainSchema, @@ -1115,14 +1166,32 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { if (!isEvidenceDomain(item.domain)) throw new RectificationToolServiceError("invalid_domain"); if (!isDatePrecision(item.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision"); } + const groundedItems = input.items.map((item) => { + const resolved = resolveEvidenceQuote(userMessage ?? null, { + quote: item.quote, + quoteStart: item.quoteStart, + quoteEnd: item.quoteEnd, + }); + return { item, resolved }; + }); const inputFingerprint = canonicalToolInputFingerprint("rectification-record-evidence-batch", input); await receipt("rectification-record-evidence-batch", "evidence.proposed", "started", { inputFingerprint }); try { - const scoringItems = input.items.flatMap((item, index) => ( - isHoldoutVerificationQuote(item.quote) ? [] : [{ item, index }] + const mismatchResults = groundedItems.flatMap(({ resolved }, index) => ( + resolved.ok + ? [] + : [{ + index, + outcome: "rejected" as const, + evidenceId: null, + status: "quote_mismatch", + idempotent: false, + clarificationFields: [] as string[], + errorCode: "quote_mismatch", + }] )); - const holdoutResults = input.items.flatMap((item, index) => ( - isHoldoutVerificationQuote(item.quote) + const holdoutResults = groundedItems.flatMap(({ resolved }, index) => ( + resolved.ok && isHoldoutVerificationQuote(resolved.quote) ? [{ index, outcome: "rejected" as const, @@ -1134,12 +1203,17 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }] : [] )); + const scoringItems = groundedItems.flatMap(({ item, resolved }, index) => ( + resolved.ok && !isHoldoutVerificationQuote(resolved.quote) + ? [{ item, index, quote: resolved.quote, quoteStart: resolved.quoteStart, quoteEnd: resolved.quoteEnd }] + : [] + )); const result = scoringItems.length === 0 ? { - items: holdoutResults, + items: [...mismatchResults, ...holdoutResults], acceptedCount: 0, needsClarificationCount: 0, - rejectedCount: holdoutResults.length, + rejectedCount: mismatchResults.length + holdoutResults.length, focusId: input.focusId ?? null, } : await recordV10EvidenceBatch( @@ -1148,8 +1222,10 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { input.caseId, turnId, input.focusId ?? null, - scoringItems.map(({ item }) => ({ - quote: item.quote, + scoringItems.map(({ item, quote, quoteStart, quoteEnd }) => ({ + quote, + quoteStart, + quoteEnd, subject: evidenceSubjectForDomain(item.domain, item.subject), eventKind: item.proposedKind as Parameters[5][number]["eventKind"], domain: item.domain, @@ -1165,10 +1241,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { index: scoringItems[offset]?.index ?? item.index, })), ...holdoutResults, + ...mismatchResults, ].sort((left, right) => left.index - right.index), acceptedCount: recorded.acceptedCount, needsClarificationCount: recorded.needsClarificationCount, - rejectedCount: recorded.rejectedCount + holdoutResults.length, + rejectedCount: recorded.rejectedCount + holdoutResults.length + mismatchResults.length, focusId: recorded.focusId, })); if (result.acceptedCount > 0) { @@ -1186,9 +1263,14 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { outcome: item.outcome, evidence_id: item.evidenceId, status: item.status, + public_status: publicEvidenceItemStatus({ + outcome: item.outcome, + idempotent: item.idempotent, + errorCode: item.errorCode, + }), idempotent: item.idempotent, clarification_fields: item.clarificationFields, - error_code: item.errorCode, + error_code: item.errorCode === "quote_not_grounded" ? "quote_mismatch" : item.errorCode, })), accepted_count: result.acceptedCount, needs_clarification_count: result.needsClarificationCount, @@ -1416,7 +1498,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { algorithmVersion: scored.persisted.algorithmVersion, decisionReceipt: scored.persisted.decisionReceipt, }; - const collectingPlan = collectingFollowupForParsed(scored.parsed, latest); + const { collectingPlan, persistedFocus } = await persistPlanFocus(scored.parsed, latest); const latestProjection = latestResultToolProjection(latest, { proposeAllowed: readProposeAllowed(latest.decisionReceipt), nextFollowup: collectingPlan.next_followup, @@ -1432,6 +1514,13 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { domain_count: Object.keys(scored.parsed.domainCounts).length, window_scan: scored.windowScan, internal_observations: internalObservationsFromWindowScan(scored.windowScan), + open_question: persistedFocus.prompt + ? { + question_id: persistedFocus.questionId, + prompt: persistedFocus.prompt, + status: persistedFocus.status, + } + : null, }; await receipt("rectification-compare-candidates", "candidates.comparing", "completed", { inputFingerprint, @@ -1702,6 +1791,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }; } +export function createRectificationV9AgentTools(ctx: RectificationV9Context) { + const tools = createRectificationV9Tools(ctx); + const { "rectification-set-focus": _omitted, ...agentTools } = tools; + return agentTools; +} + export type RectificationV9Tools = ReturnType; export const RECTIFICATION_V9_SKILL_NAME = RECTIFICATION_SKILL_NAME; diff --git a/frontend/supabase/migrations/20260824030000_rectification_turn_origin.sql b/frontend/supabase/migrations/20260824030000_rectification_turn_origin.sql new file mode 100644 index 00000000..417d44d0 --- /dev/null +++ b/frontend/supabase/migrations/20260824030000_rectification_turn_origin.sql @@ -0,0 +1,131 @@ +-- User-turn origin for birth-time rectification. +-- Typed, choice, suggestion, voice, retry, and recovery writes must be +-- distinguishable when a later transcript contains a year the engine never +-- produced. Business schema only; do not copy into frontend/db/migrations +-- (BUG-127 / BUG-144). + +begin; + +alter table public.agentic_rectification_turns + add column if not exists message_origin text; + +alter table public.agentic_rectification_turns + add column if not exists client_action_id uuid; + +alter table public.agentic_rectification_turns + add column if not exists content_hash text; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'agentic_rectification_turns_message_origin_check' + and conrelid = 'public.agentic_rectification_turns'::regclass + ) then + alter table public.agentic_rectification_turns + add constraint agentic_rectification_turns_message_origin_check + check ( + message_origin is null + or message_origin in ( + 'typed', + 'suggestion_click', + 'choice_click', + 'voice_input', + 'retry_replay', + 'system_recovery' + ) + ); + end if; +end; +$$; + +create unique index if not exists agentic_rectification_turns_case_client_action_uidx + on public.agentic_rectification_turns (case_id, client_action_id) + where client_action_id is not null; + +create or replace function public.record_agentic_rectification_turn_origin( + p_user_id uuid, + p_case_id uuid, + p_turn_id uuid, + p_origin text, + p_client_action_id uuid, + p_content_hash text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_turn public.agentic_rectification_turns%rowtype; +begin + if p_user_id is null or p_case_id is null or p_turn_id is null + or p_origin not in ( + 'typed', + 'suggestion_click', + 'choice_click', + 'voice_input', + 'retry_replay', + 'system_recovery' + ) then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select * into v_turn + from public.agentic_rectification_turns + where id = p_turn_id and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + + if v_turn.message_origin is not null then + if v_turn.message_origin is distinct from p_origin + or v_turn.client_action_id is distinct from p_client_action_id + or v_turn.content_hash is distinct from p_content_hash then + raise exception 'agentic_rectification_request_mismatch' using errcode = 'P0001'; + end if; + return jsonb_build_object( + 'turn_id', v_turn.id, + 'origin', v_turn.message_origin, + 'client_action_id', v_turn.client_action_id, + 'content_hash', v_turn.content_hash, + 'idempotent', true + ); + end if; + + update public.agentic_rectification_turns + set message_origin = p_origin, + client_action_id = p_client_action_id, + content_hash = p_content_hash, + updated_at = pg_catalog.now() + where id = p_turn_id; + + return jsonb_build_object( + 'turn_id', p_turn_id, + 'origin', p_origin, + 'client_action_id', p_client_action_id, + 'content_hash', p_content_hash, + 'idempotent', false + ); +end; +$$; + +revoke all on function public.record_agentic_rectification_turn_origin( + uuid, uuid, uuid, text, uuid, text +) from public, anon, authenticated; +grant execute on function public.record_agentic_rectification_turn_origin( + uuid, uuid, uuid, text, uuid, text +) to service_role; + +commit; diff --git a/frontend/tests/rectification-activity-receipt.test.ts b/frontend/tests/rectification-activity-receipt.test.ts index 9ae1c0df..8f065325 100644 --- a/frontend/tests/rectification-activity-receipt.test.ts +++ b/frontend/tests/rectification-activity-receipt.test.ts @@ -99,7 +99,7 @@ test("answer deltas preserve a still-running server activity", () => { chatSource.indexOf('event.type === "answer.delta"'), chatSource.indexOf('event.type === "run.failed"'), ); - assert.match(deltaBranch, /state: parsed\.text \? "streaming" : "thinking"/); + assert.match(deltaBranch, /state: settled\.spoken \? "streaming" : "thinking"/); assert.match(deltaBranch, /正在组织回答/); assert.doesNotMatch(deltaBranch, /activeActivity:\s*undefined/); }); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index d4450199..ad16f911 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -537,6 +537,28 @@ test("rectification keeps the composer but never renders generated suggestion ch assert.match(chat, /点上面的选项即可/); }); +test("does not parse suggestions from an incomplete run", () => { + assert.match(chat, /completed && !streamFailed \? parseAgentReply/); + assert.doesNotMatch(chat, /parseAgentReply\(raw\)/); + assert.doesNotMatch(chat, /parseAgentReply\(partial/); +}); + +test("does not auto-submit a suggestion during render or recovery", () => { + assert.match(chat, /origin: "typed"/); + assert.match(chat, /origin: "choice_click"/); + assert.doesNotMatch(chat, /origin: "suggestion_click"/); + assert.doesNotMatch(chat, /origin: "system_recovery"/); + assert.doesNotMatch(chat, /2002/); +}); + +test("persists message origin for every user turn", () => { + assert.match(route, /origin: z\.enum\(/); + assert.match(route, /messageOrigin:/); + assert.match(route, /clientActionId:/); + assert.match(chat, /clientActionId: requestId/); + assert.match(chat, /clientActionId: actionId/); +}); + test("rectification Agent output stays natural and keeps tool execution silent", () => { const skill = readFileSync( new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 47923605..75c8d13b 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -321,7 +321,7 @@ test("opening follow-up asks for a dated event in natural language, not a choice assert.equal(plan.next_followup?.choice_frame, null); assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/); assert.match(plan.next_followup?.user_prompt_hint ?? "", /自然语言/); - assert.match(plan.next_followup?.user_prompt_hint ?? "", /不要写 expectedAnswerSchema\.choice/); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /不要调用 set-focus/); }); test("distinguish follow-up copy forbids competing-chart ranking", () => { @@ -370,7 +370,7 @@ test("distinguish follow-up copy forbids competing-chart ranking", () => { assert.equal(plan.next_followup?.choice_frame?.period, "2016 年前后"); assert.doesNotMatch(plan.next_followup?.choice_frame?.option_a_hint ?? "", /更像哪一件|两套盘|可能性/); assert.doesNotMatch(plan.next_followup?.choice_frame?.option_b_hint ?? "", /更像哪一件|两套盘|可能性/); - assert.match(plan.next_followup?.user_prompt_hint ?? "", /自己写题干/); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /已持久化的题干/); assert.match(plan.next_followup?.user_prompt_hint ?? "", /不得发明年份/); }); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 082f2e62..8ae364e9 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -367,7 +367,7 @@ test("D9 differ keeps sign names for the type-table report and still forbids uni }); assert.equal(plan.next_followup?.source, "varga_observation"); assert.equal(plan.next_followup?.ask_theme, "relationship_style"); - assert.match(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /已持久化的题干/); assert.equal(plan.next_followup?.choice_frame?.choice_mode, "A/B/C/D"); assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY); }); @@ -610,6 +610,7 @@ test("accepted batch evidence triggers server rescore without offering adoption" userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, + userMessage: "2016年6月高考结束", accounting: accounting.client as never, }); const result = await (tools["rectification-record-evidence-batch"] as unknown as { @@ -684,6 +685,7 @@ test("rescore failure does not fail the evidence write", async () => { userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, + userMessage: "2016年6月高考结束", accounting: accounting.client as never, }); const result = await (tools["rectification-record-evidence-batch"] as unknown as { diff --git a/frontend/tests/rectification-evidence-quote.test.ts b/frontend/tests/rectification-evidence-quote.test.ts new file mode 100644 index 00000000..3e779491 --- /dev/null +++ b/frontend/tests/rectification-evidence-quote.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveEvidenceQuote, publicEvidenceItemStatus } from "../src/lib/rectification-agentic/v9/evidence-quote.ts"; + +const SOURCE = "2020年4月开始实习 6月转正 10月离职"; + +test("uses exact source-turn offsets for evidence quotes", () => { + const intern = resolveEvidenceQuote(SOURCE, { quoteStart: 0, quoteEnd: 11 }); + assert.equal(intern.ok, true); + if (intern.ok) assert.equal(intern.quote, "2020年4月开始实习"); + const rewritten = resolveEvidenceQuote(SOURCE, { quote: "2020年6月转正" }); + assert.equal(rewritten.ok, false); + if (!rewritten.ok) assert.equal(rewritten.errorCode, "quote_mismatch"); + const original = resolveEvidenceQuote(SOURCE, { quote: "6月转正" }); + assert.equal(original.ok, true); + if (original.ok) assert.equal(original.quote, "6月转正"); +}); + +test("returns per-item evidence batch results", () => { + assert.equal(publicEvidenceItemStatus({ + outcome: "accepted", + idempotent: false, + errorCode: null, + }), "created"); + assert.equal(publicEvidenceItemStatus({ + outcome: "accepted", + idempotent: true, + errorCode: null, + }), "already_exists"); + assert.equal(publicEvidenceItemStatus({ + outcome: "rejected", + idempotent: false, + errorCode: "quote_not_grounded", + }), "quote_mismatch"); +}); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts new file mode 100644 index 00000000..e2effdbc --- /dev/null +++ b/frontend/tests/rectification-server-focus.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { persistServerOwnedFocus, shouldSkipDiscriminatorFollowup, stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts"; + +function discriminatorFollowup(overrides: Partial = {}): 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", + }], + }); + 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, + ...overrides, + }; +} + +test("does not ask an already-answered discriminator probe again", async () => { + const followup = discriminatorFollowup(); + const active = activeFocusFixture({ + expectedAnswerSchema: { probe_id: "education:2016", choice: serverOwnedChoiceCopy(followup.choice_frame!) }, + }); + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: () => { + throw new Error("should not create a second focus"); + }, + }); + const result = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: { + ...active, + id: FOCUS_ID, + caseId: CASE_ID, + questionId: stableFollowupQuestionId(followup), + intent: "distinguish_candidates", + targetEvidenceId: null, + targetDomain: "education", + targetKind: null, + expectedAnswerSchema: { probe_id: "education:2016" }, + status: "active", + askedAt: "2026-08-24T00:00:00.000Z", + resolvedAt: null, + }, + decisionReceipt: { + inference_state: { + answered_probes: [{ probe_id: "education:2016", semantic_key: "education:2016", answer_class: "yes" }], + probes: [], + }, + }, + followup, + }); + assert.equal(result.status, "probe_already_answered"); + assert.equal(accounting.calls.length, 0); +}); + +test("zero information gain does not open a discriminator", () => { + assert.equal( + shouldSkipDiscriminatorFollowup(discriminatorFollowup({ information_gain: 0 })), + "zero_information_gain", + ); +}); + +test("duplicate focus conflict does not throw", async () => { + const followup = discriminatorFollowup({ source: "precision_stage", information_gain: 0.2 }); + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: () => { + throw new Error("agentic_rectification_focus_idempotency_conflict"); + }, + }); + const result = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup, + }); + assert.equal(result.status, "duplicate_focus"); +}); diff --git a/frontend/tests/rectification-step-answer.test.ts b/frontend/tests/rectification-step-answer.test.ts new file mode 100644 index 00000000..7c086bbe --- /dev/null +++ b/frontend/tests/rectification-step-answer.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyStepAnswerChunk, + createStepAnswerState, + flushStepAnswerOnStreamFinish, + shouldPublishStepText, +} from "../src/lib/rectification-agentic/v9/step-answer.ts"; +import { mapStreamChunkToPhase, mapStreamChunkToThinking } from "../src/lib/rectification-agentic/v9/stream-mapping.ts"; +import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts"; + +function isPublicTool(name: string): boolean { + return (PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes(name); +} + +function chunk(type: string, payload?: Record) { + return { type, payload }; +} + +test("does not publish intermediate tool-step text as answer.delta", () => { + const state = createStepAnswerState(); + applyStepAnswerChunk(state, chunk("text-delta", { text: "Let me set" }), isPublicTool); + applyStepAnswerChunk(state, chunk("tool-call", { toolName: "rectification-record-evidence-batch" }), isPublicTool); + const afterTool = applyStepAnswerChunk( + state, + chunk("tool-result", { toolName: "rectification-record-evidence-batch" }), + isPublicTool, + ); + assert.equal(afterTool.kind, "discard"); + applyStepAnswerChunk(state, chunk("text-delta", { text: "记下了,2020年4月开始实习。" }), isPublicTool); + const finish = applyStepAnswerChunk(state, chunk("step-finish", { reason: "stop" }), isPublicTool); + assert.deepEqual(finish, { kind: "publish", pieces: ["记下了,2020年4月开始实习。"] }); +}); + +test("never publishes reasoning-delta to the browser", () => { + assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "Let me" }) as never), null); + assert.equal(mapStreamChunkToPhase(chunk("text-delta", { text: "Let me" }) as never), null); + assert.ok(mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对经历。" }) as never)); +}); + +test("publishes only the terminal no-tool step as assistant text", () => { + const state = createStepAnswerState(); + applyStepAnswerChunk(state, chunk("step-start"), isPublicTool); + applyStepAnswerChunk(state, chunk("text-delta", { text: "_probe" }), isPublicTool); + applyStepAnswerChunk(state, chunk("tool-call", { toolName: "rectification-compare-candidates" }), isPublicTool); + applyStepAnswerChunk(state, chunk("step-finish", { stepResult: { reason: "tool-calls" } }), isPublicTool); + assert.equal(shouldPublishStepText({ calledTool: true, text: "_probe" }, "tool-calls"), false); + applyStepAnswerChunk(state, chunk("step-start"), isPublicTool); + applyStepAnswerChunk(state, chunk("text-delta", { text: "已经记下实习。" }), isPublicTool); + const finish = flushStepAnswerOnStreamFinish(state, "stop"); + assert.deepEqual(finish, { kind: "publish", pieces: ["已经记下实习。"] }); +}); diff --git a/frontend/tests/rectification-v10-tool-contract.test.ts b/frontend/tests/rectification-v10-tool-contract.test.ts index 22cf22e4..dba47b9c 100644 --- a/frontend/tests/rectification-v10-tool-contract.test.ts +++ b/frontend/tests/rectification-v10-tool-contract.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts"; -import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; +import { createRectificationV9AgentTools, createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { CASE_ID, EVIDENCE_ID, @@ -201,3 +201,29 @@ test("evidence kind and domain schemas enumerate legal values so education is no items: [{ ...item, proposedKind: "education_start", domain: "education" }], }).success, true); }); + +test("agent tools omit model-driven set-focus", () => { + const tools = createRectificationV9AgentTools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: fakeAccounting({}).client as never, + }); + assert.equal("rectification-set-focus" in tools, false); + assert.deepEqual( + Object.keys(tools).sort(), + EXACT_TOOL_KEYS.filter((name) => name !== "rectification-set-focus").slice().sort(), + ); +}); + +test("evidence batch items may use source offsets instead of a rewritten quote", () => { + const tools = toolsUnderTest() as unknown as Record; + const schema = tools["rectification-record-evidence-batch"].inputSchema; + const valid = validInputs["rectification-record-evidence-batch"]; + const item = { ...(valid.items as Record[])[0]! }; + delete item.quote; + assert.equal(schema.safeParse({ + ...valid, + items: [{ ...item, quoteStart: 0, quoteEnd: 11 }], + }).success, true); +}); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 8df680dd..f0013329 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -75,13 +75,12 @@ test("system prompt carries only high-priority boundaries, never the method copy assert.match(prompt, /方法覆盖已齐只进入候选区分/); assert.doesNotMatch(prompt, /方法覆盖已齐且 propose_allowed 时本轮 adopt/); assert.match(prompt, /不得询问外貌、体质、胎记或疤痕/); - assert.match(prompt, /expectedAnswerSchema.choice/); - assert.match(prompt, /不要写 choice/); - assert.match(prompt, /「先这样」由服务器补全/); + assert.match(prompt, /不要调用 rectification-set-focus/); + assert.match(prompt, /open_question\.prompt/); + assert.match(prompt, /「先这样」由服务器/); assert.match(prompt, /盘外核对(不计分)/); assert.match(prompt, /verify_adopted_time/); assert.match(prompt, /event_probe/); - assert.match(prompt, /题干由你写成自然语言/); assert.match(prompt, /不得发明年份/); assert.doesNotMatch(prompt, /两套盘各自的前事/); assert.doesNotMatch(prompt, /外貌、体质、胎记或疤痕可以问/); @@ -302,7 +301,13 @@ test("server-loaded Skill is bound before the provider and the first model step toolChoice: "auto", }); assert.equal(typeof firstStep?.toolChoice, "string"); - assert.equal(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }), undefined); + assert.deepEqual( + (await observedStreamOptions.prepareStep?.({ stepNumber: 1 }) as { activeTools?: string[] }).activeTools?.includes("rectification-set-focus"), + false, + ); + assert.ok( + ((await observedStreamOptions.prepareStep?.({ stepNumber: 1 })) as { activeTools?: string[] }).activeTools?.includes("rectification-read-case"), + ); assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 8192); assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "disabled" } }); assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1); @@ -410,7 +415,7 @@ test("a repeated identical tool call is detected and aborts the turn", async () chunk("start"), chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), chunk("tool-result", { toolName: "skill" }), - ...Array.from({ length: 4 }, () => chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } })), + ...Array.from({ length: 2 }, () => chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } })), chunk("finish"), ]) as never, }); diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index 05698c9c..df47c3d7 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -1253,3 +1253,21 @@ test("PR-4 exposes only the explicit V2 service-role RPC signatures", () => { /grant execute on function public\.confirm_agentic_rectification_candidate_for_case_v2\(uuid, uuid, uuid, uuid, uuid, text, uuid\)\s+to service_role/, ); }); + +test("turn origin migration stays out of the identity foundation", () => { + const turnOriginMigration = readFileSync( + new URL("../supabase/migrations/20260824030000_rectification_turn_origin.sql", import.meta.url), + "utf8", + ); + const turnOriginDbCopy = fileURLToPath( + new URL("../db/migrations/20260824030000_rectification_turn_origin.sql", import.meta.url), + ); + assert.equal( + existsSync(turnOriginDbCopy), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); + assert.match(turnOriginMigration, /record_agentic_rectification_turn_origin/); + assert.match(turnOriginMigration, /message_origin/); + assert.match(turnOriginMigration, /suggestion_click/); +}); diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index 131d99a4..a0f9850d 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -21,6 +21,7 @@ import { receiptHandlers, } from "./rectification-v9-test-support.ts"; import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { messageContentHash } from "../src/lib/rectification-agentic/v9/message-origin.ts"; import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { createRectificationActivityReceiptState, @@ -91,9 +92,9 @@ test("fullStream chunks map to the allowlisted NDJSON phases only", () => { }) as never), { type: "case.loaded", tool: "rectification-read-case" }, ); - assert.deepEqual( + assert.equal( mapStreamChunkToPhase(chunk("text-delta", { text: "你好" }) as never), - { type: "answer.delta", text: "你好" }, + null, ); assert.equal(mapStreamChunkToPhase(chunk("finish") as never), null); assert.equal(mapStreamChunkToPhase(chunk("error", { error: new Error("boom") }) as never), null); @@ -469,8 +470,7 @@ test("answer deltas stream in order and reasoning is never forwarded", async () assert.equal(result.ok, true); const deltas = emitted.filter((event) => event.type === "answer.delta"); assert.deepEqual(deltas, [ - { type: "answer.delta", text: "好的," }, - { type: "answer.delta", text: "先确认一下:" }, + { type: "answer.delta", text: "好的,先确认一下:" }, ]); assert.deepEqual( emitted.filter((event) => event.type === "thinking.delta"), @@ -569,7 +569,7 @@ test("Chinese process self-talk after tools is thinking, not the spoken answer", assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?"); }); -test("process-only self-talk after tools is not persisted as a completed spoken answer", async () => { +test("process-only self-talk after tools is replaced by server narration, not retried", async () => { let buildCount = 0; const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户"; const accounting = fakeAccounting({ @@ -582,48 +582,33 @@ test("process-only self-talk after tools is not persisted as a completed spoken accounting: accounting.client, buildAgent: async () => { buildCount += 1; - return buildCount === 1 - ? attemptStream([ - chunk("start"), - chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), - chunk("tool-result", { toolName: "skill" }), - chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), - chunk("tool-result", { toolName: "rectification-read-case" }), - chunk("tool-call", { - toolName: "rectification-record-evidence-batch", - args: { caseId: CASE_ID, proposedKind: "education_start" }, - }), - chunk("tool-result", { toolName: "rectification-record-evidence-batch" }), - chunk("reasoning-delta", { text: processTalk }), - chunk("finish"), - ], { inputTokens: 11, outputTokens: 12 }) as never - : attemptStream(successfulAttemptChunks(), { inputTokens: 31, outputTokens: 17 }) as never; + return attemptStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("tool-call", { + toolName: "rectification-record-evidence-batch", + args: { caseId: CASE_ID, proposedKind: "education_start" }, + }), + chunk("tool-result", { toolName: "rectification-record-evidence-batch" }), + chunk("reasoning-delta", { text: processTalk }), + chunk("finish"), + ], { inputTokens: 11, outputTokens: 12 }) as never; }, }); const result = await runV9AgentTurn(options); - assert.equal(buildCount, 2); + assert.equal(buildCount, 1); assert.equal(result.ok, true); - assert.equal(result.answerText, "第二次 attempt 成功"); - const resetAt = emitted.findIndex((event) => event.type === "attempt.reset"); - assert.ok(resetAt >= 0); - const firstAttemptAnswers = emitted.slice(0, resetAt).filter((event) => event.type === "answer.delta"); - assert.deepEqual(firstAttemptAnswers, []); - const firstAttemptThinking = emitted.slice(0, resetAt) - .filter((event) => event.type === "thinking.delta") - .map((event) => event.text) - .join(""); - assert.equal(firstAttemptThinking, ""); + assert.match(result.answerText, /已经记下|请继续说下一件/); + assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); + assert.doesNotMatch(JSON.stringify(emitted), /我需要用批量工具/); const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn"); - assert.equal(finalizedTurn?.args.p_assistant_message, "第二次 attempt 成功"); assert.equal(finalizedTurn?.args.p_status, "completed"); assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); - const firstAttempt = accounting.calls.find((call) => - call.fn === "finalize_agentic_rectification_run_attempt" - && call.args.p_attempt_id === "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); - assert.equal(firstAttempt?.args.p_status, "retryable"); - assert.equal(firstAttempt?.args.p_error_code, "empty_stream"); }); test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => { @@ -762,7 +747,7 @@ test("browser disconnect aborts the run, finalizes retryable and releases usage" assert.equal(billing.released, 1); }); -test("empty stream fails closed without completing billing", async () => { +test("empty stream uses server narration instead of retrying the attempt", async () => { const { options, emitted, billing } = runOptions({ buildAgent: async () => fakeAgentStream([ chunk("start"), @@ -774,10 +759,11 @@ test("empty stream fails closed without completing billing", async () => { ]) as never, }); const result = await runV9AgentTurn(options); - assert.equal(result.ok, false); - assert.equal(result.errorCode, "empty_stream"); - assert.equal(billing.released, 1); - assert.equal(emitted.some((event) => event.type === "run.completed"), false); + assert.equal(result.ok, true); + assert.match(result.answerText, /已经记下|请继续说下一件/); + assert.equal(billing.completed, 1); + assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); + assert.equal(emitted.some((event) => event.type === "run.completed"), true); }); test("legacy Skill identity fails before billing reservation", async () => { @@ -847,7 +833,7 @@ test("execution receipts are persisted per turn (phases + tools)", async () => { const SECOND_ATTEMPT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; -type AttemptFailure = "stream_aborted" | "stream_unfinished" | "empty_stream"; +type AttemptFailure = "stream_aborted" | "stream_unfinished"; function attemptStream( chunks: StreamChunk[], @@ -873,12 +859,10 @@ function failedAttemptChunks(errorCode: AttemptFailure): StreamChunk[] { chunk("tool-result", { toolName: "rectification-read-case" }), chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-set-focus" }), - chunk("text-delta", { text: errorCode === "empty_stream" ? " " : "失败 attempt 的半截文本" }), + chunk("text-delta", { text: "失败 attempt 的半截文本" }), ]; if (errorCode === "stream_aborted") { chunks.push(chunk("error", { error: new Error("provider stream aborted") })); - } else if (errorCode === "empty_stream") { - chunks.push(chunk("finish")); } return chunks; } @@ -897,7 +881,7 @@ function successfulAttemptChunks(): StreamChunk[] { ]; } -for (const failureCode of ["stream_aborted", "stream_unfinished", "empty_stream"] as const) { +for (const failureCode of ["stream_aborted", "stream_unfinished"] as const) { test(`${failureCode} attempt is isolated and the second successful attempt exclusively commits public truth`, async () => { let buildCount = 0; const completedUsage: Array<{ inputTokens: number; outputTokens: number; durationMs: number }> = []; @@ -935,7 +919,7 @@ for (const failureCode of ["stream_aborted", "stream_unfinished", "empty_stream" assert.ok(resetAt >= 0); assert.equal( emitted.some((event) => event.type === "answer.delta" && event.text?.includes("失败 attempt")), - failureCode !== "empty_stream", + false, ); assert.deepEqual( emitted.slice(resetAt + 1).filter((event) => event.type === "answer.delta"), @@ -1010,7 +994,7 @@ for (const failureCode of ["stream_aborted", "stream_unfinished", "empty_stream" }); } -test("a failed set-focus cannot complete a question turn even when the agent emits answer text", async () => { +test("a failed set-focus does not reset the attempt or hide the terminal answer", async () => { let buildCount = 0; const accounting = fakeAccounting({ ...receiptHandlers, @@ -1022,80 +1006,51 @@ test("a failed set-focus cannot complete a question turn even when the agent emi idempotent: false, }), }); - const failedFocusAttempt = () => attemptStream([ - chunk("start"), - chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), - chunk("tool-result", { toolName: "skill" }), - chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), - chunk("tool-result", { toolName: "rectification-read-case" }), - chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }), - chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }), - chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }), - chunk("finish"), - ], { inputTokens: 41, outputTokens: 23 }); const { options, emitted, billing } = runOptions({ accounting: accounting.client, buildAgent: async () => { buildCount += 1; - return failedFocusAttempt() as never; + return attemptStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }), + chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }), + chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }), + chunk("finish"), + ], { inputTokens: 41, outputTokens: 23 }) as never; }, }); const result = await runV9AgentTurn(options); - assert.equal(buildCount, 2); - assert.equal(result.ok, false); - assert.equal(result.turnStatus, "retryable"); - assert.equal(result.errorCode, "focus_persistence_failed"); - assert.equal(result.answerText, ""); - assert.deepEqual(result.toolsUsed, ["rectification-read-case", "rectification-set-focus"]); - assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 }); - assert.equal(emitted.some((event) => event.type === "answer.delta"), false); - assert.equal(emitted.some((event) => event.type === "thinking.delta"), false); - assert.equal(emitted.some((event) => event.type === "attempt.reset"), true); - assert.equal(emitted.some((event) => event.type === "run.completed"), false); + assert.equal(buildCount, 1); + assert.equal(result.ok, true); + assert.equal(result.turnStatus, "completed"); + assert.equal(result.answerText, "主问题:请确认这段经历发生在哪个月?"); + assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); + assert.deepEqual( + emitted.filter((event) => event.type === "answer.delta"), + [{ type: "answer.delta", text: "主问题:请确认这段经历发生在哪个月?" }], + ); assert.equal( emitted.some((event) => event.type === "tool.activity" && (event as { tool?: string; status?: string }).tool === "rectification-set-focus" - && (event as { tool?: string; status?: string }).status === "failed"), + && (event as { tool?: string; status?: string }).status === "failed" + && (event as { code?: string }).code === "duplicate_focus"), true, ); - assert.equal(emitted.at(-1)?.type, "run.failed"); - - const attemptFinalizations = accounting.calls - .filter((call) => call.fn === "finalize_agentic_rectification_run_attempt") - .map((call) => ({ - status: call.args.p_status, - errorCode: call.args.p_error_code, - usage: call.args.p_usage, - })); - assert.deepEqual(attemptFinalizations, [ - { status: "retryable", errorCode: "focus_persistence_failed", usage: { inputTokens: 0, outputTokens: 0 } }, - { status: "retryable", errorCode: "focus_persistence_failed", usage: { inputTokens: 0, outputTokens: 0 } }, - ]); - assert.equal( - accounting.calls.some((call) => call.fn === "insert_agentic_rectification_run_phase" - && call.args.p_phase === "run.completed"), - false, - ); - const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn"); - assert.deepEqual(finalizedTurn?.args, { - p_user_id: USER_ID, - p_case_id: CASE_ID, - p_turn_id: TURN_ID, - p_attempt_id: SECOND_ATTEMPT_ID, - p_status: "retryable", - p_assistant_message: null, - p_successful_attempt_id: null, - }); + assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); }); -test("a same-attempt set-focus retry that finishes completed can commit the question turn", async () => { +test("does not retry set-focus with identical arguments", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), - finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }), }); const { options, emitted, billing } = runOptions({ accounting: accounting.client, @@ -1116,33 +1071,10 @@ test("a same-attempt set-focus retry that finishes completed can commit the ques const result = await runV9AgentTurn(options); - assert.equal(result.ok, true); - assert.equal(result.turnStatus, "completed"); - assert.equal(result.errorCode, null); - assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); - assert.deepEqual( - emitted.filter((event) => event.type === "answer.delta"), - [{ type: "answer.delta", text: "主问题:请确认这段经历发生在哪个月?" }], - ); - assert.equal(emitted.some((event) => event.type === "run.completed"), true); - - let receiptState = createRectificationActivityReceiptState(); - const focusStatuses: string[] = []; - for (const event of emitted) { - const activity = event as { type: string; tool?: string; status?: string }; - if (activity.type !== "tool.activity" || activity.tool !== "rectification-set-focus") continue; - if (activity.status !== "started" && activity.status !== "completed" && activity.status !== "failed") continue; - focusStatuses.push(activity.status); - receiptState = reduceRectificationActivityReceipt(receiptState, { - tool: "rectification-set-focus", - status: activity.status, - }); - } - assert.deepEqual(focusStatuses, ["started", "failed", "started", "completed"]); - assert.deepEqual(receiptFromRectificationActivityState(receiptState), { - steps: ["rectification-set-focus"], - methods: [], - }); + assert.equal(result.ok, false); + assert.equal(result.errorCode, "repeated_tool_call"); + assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); + assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 }); }); test("an unclaimed V10 attempt never starts the model", async () => { @@ -1272,3 +1204,153 @@ test("thinking-mode tool_choice rejection fails the opening turn without a secon const attemptFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_run_attempt"); assert.equal(attemptFinalize?.args.p_error_code, "thinking_tool_choice_unsupported"); }); + +test("does not publish intermediate tool-step text as answer.delta", async () => { + const { options, emitted } = runOptions({ + message: "2020年4月开始实习 6月转正 10月离职", + buildAgent: async () => fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("text-delta", { text: "Let me set" }), + chunk("text-delta", { text: " _probe" }), + chunk("text-delta", { text: " _gain" }), + chunk("tool-call", { + toolName: "rectification-record-evidence-batch", + args: { caseId: CASE_ID }, + }), + chunk("tool-result", { toolName: "rectification-record-evidence-batch" }), + chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-compare-candidates" }), + chunk("text-delta", { text: "记下了,2020年4月开始实习。" }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.deepEqual( + emitted.filter((event) => event.type === "answer.delta"), + [{ type: "answer.delta", text: "记下了,2020年4月开始实习。" }], + ); + const publicText = JSON.stringify(emitted); + assert.doesNotMatch(publicText, /Let me set/); + assert.doesNotMatch(publicText, /_probe/); + assert.doesNotMatch(publicText, /_gain/); + assert.equal(emitted.some((event) => event.type === "thinking.delta"), false); +}); + +test("does not reset the whole attempt after duplicate_focus", async () => { + let buildCount = 0; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: (_fn, args) => ({ + turn_id: TURN_ID, + status: args.p_status, + idempotent: false, + }), + }); + const { options, emitted } = runOptions({ + accounting: accounting.client, + buildAgent: async () => { + buildCount += 1; + return attemptStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-record-evidence-batch" }), + chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }), + chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("duplicate_focus") }), + chunk("text-delta", { text: "已经记下实习,接下来对一下考试那年。" }), + chunk("finish"), + ], { inputTokens: 11, outputTokens: 12 }) as never; + }, + }); + const result = await runV9AgentTurn(options); + assert.equal(buildCount, 1); + assert.equal(result.ok, true); + assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); + assert.equal( + result.toolsUsed.filter((name) => name === "rectification-record-evidence-batch").length, + 1, + ); +}); + +test("persists message origin for every user turn", async () => { + const message = "2020年4月开始实习 6月转正 10月离职"; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + }); + const { options } = runOptions({ + accounting: accounting.client, + message, + messageOrigin: "typed", + clientActionId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + buildAgent: async () => fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("text-delta", { text: "记下了,2020年4月开始实习。" }), + chunk("finish"), + ]) as never, + }); + await runV9AgentTurn(options); + const origin = accounting.calls.find((call) => call.fn === "record_agentic_rectification_turn_origin"); + assert.deepEqual(origin?.args, { + p_user_id: USER_ID, + p_case_id: CASE_ID, + p_turn_id: TURN_ID, + p_origin: "typed", + p_client_action_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + p_content_hash: messageContentHash(message), + }); +}); + +test("does not create a 2002 user message from a 2020 assistant suggestion", async () => { + const message = "2020年4月开始实习 6月转正 10月离职"; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + }); + const { options, emitted } = runOptions({ + accounting: accounting.client, + message, + messageOrigin: "typed", + buildAgent: async () => fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("text-delta", { text: "202" }), + chunk("text-delta", { text: "0" }), + chunk("text-delta", { text: " Let me ask about 2002" }), + chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-compare-candidates" }), + chunk("text-delta", { text: "记下了,2020年4月开始实习。" }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + const origin = accounting.calls.find((call) => call.fn === "record_agentic_rectification_turn_origin"); + assert.equal(origin?.args.p_origin, "typed"); + assert.equal(origin?.args.p_content_hash, messageContentHash(message)); + const publicText = JSON.stringify(emitted); + assert.doesNotMatch(publicText, /2002 年发生什么了/); + assert.doesNotMatch(result.answerText, /2002/); + assert.equal(result.answerText, "记下了,2020年4月开始实习。"); +}); diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index 3f6d36c0..6d865578 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -216,6 +216,13 @@ export const receiptHandlers: Partial> = { }), insert_agentic_rectification_tool_receipt: () => ({ receipt_id: "99999999-9999-4999-8999-999999999999" }), insert_agentic_rectification_run_phase: () => ({ phase_id: "99999999-9999-4999-8999-999999999999" }), + record_agentic_rectification_turn_origin: (_fn, args) => ({ + turn_id: args.p_turn_id, + origin: args.p_origin, + client_action_id: args.p_client_action_id, + content_hash: args.p_content_hash, + idempotent: false, + }), transition_agentic_rectification_case_status: () => ({ case_id: CASE_ID, status: "collecting_evidence", idempotent: false }), };