From 685f322981458416f56698bd466a3fef4ca1a706 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 23 Jul 2026 14:26:35 +0800 Subject: [PATCH] fix: restore conversational rectification history --- docs/BUG_HISTORY.md | 16 ++ frontend/src/app/page.tsx | 11 +- ...onversational-birth-time-rectification.tsx | 9 +- .../hooks/use-conversational-rectification.ts | 66 +++---- .../conversational-rectification/client.ts | 8 +- .../conversational-rectification/contracts.ts | 30 ++- .../orchestrator.ts | 10 +- .../persistence-contracts.ts | 6 +- .../lib/conversational-rectification/store.ts | 12 +- ...restore_conversational_message_history.sql | 177 ++++++++++++++++++ ...rsational-rectification-controller.test.ts | 69 +++++++ ...conversational-rectification-store.test.ts | 8 +- .../tests/database-local-business.test.ts | 28 +++ 13 files changed, 396 insertions(+), 54 deletions(-) create mode 100644 frontend/supabase/migrations/20260723010000_restore_conversational_message_history.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 74d00f25..aa6799eb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -617,3 +617,19 @@ - 相关记录:BUG-034、BUG-035 - 复发自:无 - 修复版本:待提交 + +## BUG-037 | 生时校正刷新后把真实对话重建成“已记录”模板 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正历史持久化、刷新/重新进入恢复、Agent 一问一答呈现 +- 用户现象:同一轮实时回答时 Agent 会针对经历自然追问,但刷新页面或重新进入生时校正后,旧回复全部变成“已记录这段经历:……”;用户原话也被日期和事件摘要替代,看起来像 Agent 又退回固定模板。 +- 触发条件:已有两轮以上生时校正回答后刷新网页、从首页重新进入,或在当前页面同步一个更新的持久化案例。 +- 根因:数据库已保存每轮 Agent `narrative`,但恢复 RPC 只返回 `latest_turn`;前端为了补齐历史,使用 `evidenceRecap` 机械合成用户气泡和“已记录”助手气泡。实时链路使用内存中的原话与真实 narrative,恢复链路却使用另一套有损数据源,导致刷新前后表现不一致。 +- 修复:为校正 turn 向前新增受约束的 nullable `user_message`,answer 保存事务同时持久化用户原话;新增仅限 `service_role` 的 history load/save/completion wrapper RPC,按轮次返回最近 200 轮 `userMessage + narrative`;客户端响应契约支持恢复历史,首次加载和同案例重新同步均直接渲染原始一问一答。旧记录只在有明确原始 evidence 时回填用户文本;无法恢复的旧轮次只显示真实最新 Agent narrative,不再伪造模板。 +- 验证:控制器回归覆盖首次恢复、旧数据 fallback、同案例重新同步和“不得出现已记录这段经历”;聚焦校正测试 90/90 通过;本地 PostgreSQL 从头应用全部迁移成功,并验证新列与 history RPC 的 `service_role`/`authenticated` 权限边界。测试内容均为虚构经历,不写入真实用户资料。 +- 防复发:任何对话历史必须从持久化的原始 user/assistant turn 恢复;事件摘要只能用于进度和评分,不得反向伪造聊天内容。网页验收必须同时检查实时回答和刷新后的同一历史。 +- 相关记录:BUG-032、BUG-034、BUG-036 +- 复发自:BUG-032 +- 修复版本:待提交 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c73de232..7bb218c3 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -49,7 +49,10 @@ import { } from "@/lib/birth-time-consultation-consent"; import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client"; -import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts"; +import type { + ConversationalRectificationResponse, + ConversationalRectificationTurn, +} from "@/lib/conversational-rectification/contracts"; import { createDurableRectificationQuestionHandoffClient, createRectificationQuestionHandoffCoordinator, @@ -818,7 +821,7 @@ export default function Home() { ); const [rectificationSessionId, setRectificationSessionId] = useState(null); const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); - const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); + const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); @@ -1963,7 +1966,7 @@ export default function Home() { activeSessionIdRef.current = rectificationSession.id; setActiveSessionId(rectificationSession.id); try { - let turn: ConversationalRectificationTurn; + let turn: ConversationalRectificationResponse; if (!resumeTarget) { const durable = await durableRectificationQuestionHandoff.current.load(); turn = durable && durable.status !== "consumed" @@ -2052,7 +2055,7 @@ export default function Home() { void openBirthTimeRectification(null, session); }; - function handleConversationalRectificationTurn(turn: ConversationalRectificationTurn) { + function handleConversationalRectificationTurn(turn: ConversationalRectificationResponse) { const requestIdentity = accountRefreshGuard.current.begin(); setRectificationInitialTurn(turn); synchronizeRectificationQuestion(turn); diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 74fcb751..8150e474 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -9,7 +9,10 @@ import { useConversationalRectification, type ConversationalRectificationController, } from "../hooks/use-conversational-rectification.ts"; -import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts"; +import type { + ConversationalRectificationResponse, + ConversationalRectificationTurn, +} from "../lib/conversational-rectification/contracts.ts"; type SurfaceProps = Readonly<{ controller: ConversationalRectificationController; @@ -249,10 +252,10 @@ export function ConversationalRectificationSurface({ } type ConversationalBirthTimeRectificationProps = Readonly<{ - initialTurn?: ConversationalRectificationTurn | null; + initialTurn?: ConversationalRectificationResponse | null; pendingConsultationQuestion?: string | null; continuationPending?: boolean; - onTurn?: (turn: ConversationalRectificationTurn) => void; + onTurn?: (turn: ConversationalRectificationResponse) => void; onPendingChange?: (pending: boolean) => void; onContinueOriginalQuestion?: (question: string) => void; }>; diff --git a/frontend/src/hooks/use-conversational-rectification.ts b/frontend/src/hooks/use-conversational-rectification.ts index 7deb4e43..743a0732 100644 --- a/frontend/src/hooks/use-conversational-rectification.ts +++ b/frontend/src/hooks/use-conversational-rectification.ts @@ -10,6 +10,7 @@ import { } from "../lib/conversational-rectification/client.ts"; import type { ConversationalRectificationCommand, + ConversationalRectificationResponse, ConversationalRectificationTurn, } from "../lib/conversational-rectification/contracts.ts"; @@ -28,30 +29,27 @@ function assistantText(turn: ConversationalRectificationTurn): string { return turn.narrative.trim(); } -function initialMessages(turn: ConversationalRectificationTurn | null): ConversationalRectificationMessage[] { +function initialMessages(turn: ConversationalRectificationResponse | null): ConversationalRectificationMessage[] { if (!turn) return []; - if (turn.evidenceRecap.length === 0) { - return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }]; + if (turn.messageHistory?.length) { + return turn.messageHistory.flatMap((entry) => [ + ...(entry.userMessage ? [{ + role: "user" as const, + text: entry.userMessage, + renderKey: `user-turn-${entry.turnVersion}`, + }] : []), + { + role: "assistant" as const, + text: entry.narrative.trim(), + renderKey: `assistant-turn-${entry.turnVersion}`, + }, + ]); } - return turn.evidenceRecap.flatMap((entry, index) => { - const user = { - role: "user" as const, - text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`, - renderKey: `user-${entry.id}`, - }; - const assistant = index === turn.evidenceRecap.length - 1 - ? assistantText(turn) - : `已记录这段经历:${entry.dateLabel} · ${entry.summary}。`; - return [user, { - role: "assistant" as const, - text: assistant, - renderKey: `assistant-history-${entry.id}`, - }]; - }); + return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }]; } export type ConversationalRectificationControllerSnapshot = Readonly<{ - turn: ConversationalRectificationTurn | null; + turn: ConversationalRectificationResponse | null; messages?: readonly ConversationalRectificationMessage[]; draft: string; selectedDomain: EvidenceDomain | null; @@ -60,12 +58,12 @@ export type ConversationalRectificationControllerSnapshot = Readonly<{ error: string; }>; -type MutationResult = Promise; +type MutationResult = Promise; export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{ getSnapshot(): ConversationalRectificationControllerSnapshot; subscribe(listener: () => void): () => void; - synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void; + synchronizeInitialTurn(turn: ConversationalRectificationResponse | null): void; setDraft(value: string): void; selectDomain(domain: EvidenceDomain | null): void; beginEvidenceCorrection(evidenceId: string): void; @@ -79,10 +77,10 @@ export type ConversationalRectificationController = ConversationalRectificationC }>; type ControllerInput = Readonly<{ - initialTurn?: ConversationalRectificationTurn | null; - send?: (command: ConversationalRectificationCommand) => Promise; + initialTurn?: ConversationalRectificationResponse | null; + send?: (command: ConversationalRectificationCommand) => Promise; createActionId?: () => string; - onTurn?: (turn: ConversationalRectificationTurn) => void; + onTurn?: (turn: ConversationalRectificationResponse) => void; onPendingChange?: (pending: boolean) => void; }>; @@ -108,7 +106,7 @@ function createLatestControllerInput(initial: ControllerInput) { send(command: ConversationalRectificationCommand) { return (current.send ?? sendConversationalRectificationCommand)(command); }, - onTurn(turn: ConversationalRectificationTurn) { + onTurn(turn: ConversationalRectificationResponse) { current.onTurn?.(turn); }, onPendingChange(pending: boolean) { @@ -164,7 +162,7 @@ export function createConversationalRectificationController( } }; const acceptTurn = ( - turn: ConversationalRectificationTurn, + turn: ConversationalRectificationResponse, clearDraft: boolean, expectedCaseContext: number, userMessage?: string, @@ -203,7 +201,7 @@ export function createConversationalRectificationController( } return turn; }; - const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({ + const recoverLatest = async (turn: ConversationalRectificationResponse) => registry.run({ caseId: turn.caseId, turnVersion: turn.turnVersion, operation: "resume", @@ -264,7 +262,7 @@ export function createConversationalRectificationController( const currentMutation = ( operation: Exclude, payload: unknown, - command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand, + command: (turn: ConversationalRectificationResponse, actionId: string) => ConversationalRectificationCommand, clearDraftOnSuccess = false, userMessage?: string, ): MutationResult => { @@ -296,7 +294,7 @@ export function createConversationalRectificationController( listeners.add(listener); return () => listeners.delete(listener); }, - synchronizeInitialTurn(turn: ConversationalRectificationTurn | null) { + synchronizeInitialTurn(turn: ConversationalRectificationResponse | null) { const current = snapshot.turn; if (turn === null) { if (current === null) return; @@ -340,10 +338,12 @@ export function createConversationalRectificationController( if (turn.turnVersion <= current.turnVersion) return; patch({ turn, - messages: [ - ...(snapshot.messages ?? []), - { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, - ], + messages: turn.messageHistory?.length + ? initialMessages(turn) + : [ + ...(snapshot.messages ?? []), + { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, + ], error: "", selectedDomain: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain) diff --git a/frontend/src/lib/conversational-rectification/client.ts b/frontend/src/lib/conversational-rectification/client.ts index 786ffd88..89838c92 100644 --- a/frontend/src/lib/conversational-rectification/client.ts +++ b/frontend/src/lib/conversational-rectification/client.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { postJson } from "../birth-time-client-transport.ts"; import { conversationalRectificationCommandSchema, - conversationalRectificationTurnSchema, + conversationalRectificationResponseSchema, type ConversationalRectificationCommand, - type ConversationalRectificationTurn, + type ConversationalRectificationResponse, } from "./contracts.ts"; export const CONVERSATIONAL_RECTIFICATION_UNAVAILABLE = "生时校正暂时无法继续,请稍后重试。"; @@ -118,7 +118,7 @@ async function postCommandWithOneReplay(body: string) { export async function sendConversationalRectificationCommand( command: ConversationalRectificationCommand, -): Promise { +): Promise { const request = conversationalRectificationCommandSchema.parse(command); const body = JSON.stringify(request); try { @@ -134,7 +134,7 @@ export async function sendConversationalRectificationCommand( safeServerMessage, ); } - return conversationalRectificationTurnSchema.parse(payload); + return conversationalRectificationResponseSchema.parse(payload); } catch (error) { if (error instanceof ConversationalRectificationRequestError) throw error; throw new ConversationalRectificationRequestError( diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts index 78cbf824..5a84b459 100644 --- a/frontend/src/lib/conversational-rectification/contracts.ts +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -94,7 +94,7 @@ const evidenceRecapSchema = boundedJson( 24_576, ); -export const conversationalRectificationTurnSchema = boundedJson(z.object({ +const conversationalRectificationTurnObjectSchema = z.object({ caseId: caseIdSchema, journeyProtocol: z.literal("conversational-evidence-v3"), status: z.enum(["active", "paused", "confirming", "completed", "abandoned"]), @@ -112,6 +112,32 @@ export const conversationalRectificationTurnSchema = boundedJson(z.object({ "continue_original_question", ])).max(5), pendingConsultationQuestion: boundedNonblankText(500).nullable(), -}).strict(), 65_536); +}).strict(); + +export const conversationalRectificationTurnSchema = boundedJson( + conversationalRectificationTurnObjectSchema, + 65_536, +); export type ConversationalRectificationTurn = z.infer; + +export const conversationalRectificationMessageHistoryEntrySchema = boundedJson(z.object({ + turnVersion: turnVersionSchema, + userMessage: boundedNonblankText(4_000).nullable(), + narrative: boundedNonblankText(12_000), +}).strict(), 20_000); + +export type ConversationalRectificationMessageHistoryEntry = z.infer< + typeof conversationalRectificationMessageHistoryEntrySchema +>; + +export const conversationalRectificationResponseSchema = boundedJson( + conversationalRectificationTurnObjectSchema.extend({ + messageHistory: z.array(conversationalRectificationMessageHistoryEntrySchema).max(200).optional(), + }).strict(), + 3_500_000, +); + +export type ConversationalRectificationResponse = z.infer< + typeof conversationalRectificationResponseSchema +>; diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 5e041a44..9e9b6949 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -3,6 +3,7 @@ import { conversationalRectificationCommandSchema, conversationalRectificationTurnSchema, type ConversationalRectificationCommand, + type ConversationalRectificationResponse, type ConversationalRectificationTurn, } from "./contracts.ts"; import { ConversationalRectificationError } from "./errors.ts"; @@ -188,7 +189,7 @@ function visibleEvidenceSummary(value: string): string { return cleaned || value; } -function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn { +function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationResponse { const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn); if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); const evidenceDomains = new Map( @@ -196,6 +197,7 @@ function publicTurn(value: StoredConversationalRectificationCase): Conversationa ); return { ...parsed.data, + ...(value.messageHistory ? { messageHistory: [...value.messageHistory] } : {}), evidenceRecap: parsed.data.evidenceRecap.map((item) => ({ ...item, summary: visibleEvidenceSummary(item.summary), @@ -1085,6 +1087,7 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, + userMessage: command.answer, turn: current.latestTurn, evidence, validationReceipt: latestReceipt(current), @@ -1157,6 +1160,7 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, + userMessage: command.answer, turn: next.turn, evidence, validationReceipt: next.receipt, @@ -1201,6 +1205,7 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, + userMessage: command.answer, turn: next.turn, evidence, validationReceipt: narrative.validationReceipt, @@ -1230,6 +1235,7 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, + userMessage: command.answer, turn, evidence, validationReceipt: narrative.validationReceipt, @@ -1254,6 +1260,7 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, + userMessage: command.answer, turn: next.turn, evidence, validationReceipt: next.receipt, @@ -1349,6 +1356,7 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, + userMessage: command.answer, turn, evidence, validationReceipt: narrative.validationReceipt, diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts index eb7d3a1c..59bd3a94 100644 --- a/frontend/src/lib/conversational-rectification/persistence-contracts.ts +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -1,5 +1,8 @@ import { z } from "zod"; -import { conversationalRectificationTurnSchema } from "./contracts.ts"; +import { + conversationalRectificationMessageHistoryEntrySchema, + conversationalRectificationTurnSchema, +} from "./contracts.ts"; import { boundedJson } from "./json-bounds.ts"; const uuidSchema = z.string().uuid(); @@ -247,6 +250,7 @@ export const storedCaseRowSchema = boundedJson(z.object({ pending_consultation_question: boundedText(500).nullable(), billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(), latest_turn: conversationalRectificationTurnSchema, + message_history: z.array(conversationalRectificationMessageHistoryEntrySchema).max(200).optional(), declared_birth_input: declaredBirthInputSchema.optional(), private_candidate: privateCandidateSchema.optional(), event_evidence: z.array(lifeEventEvidenceSchema).max(2_000).optional(), diff --git a/frontend/src/lib/conversational-rectification/store.ts b/frontend/src/lib/conversational-rectification/store.ts index 8cb66776..9b3c8246 100644 --- a/frontend/src/lib/conversational-rectification/store.ts +++ b/frontend/src/lib/conversational-rectification/store.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { + type ConversationalRectificationMessageHistoryEntry, conversationalRectificationTurnSchema, type ConversationalRectificationTurn, } from "./contracts.ts"; @@ -57,6 +58,7 @@ export type StoredConversationalRectificationCase = Readonly<{ pendingConsultationQuestion: string | null; billingState: "reserved" | "charged" | "released" | "migration_waived" | null; latestTurn: ConversationalRectificationTurn; + messageHistory?: ReadonlyArray; declaredBirthInput?: DeepReadonly; privateCandidate?: DeepReadonly; eventEvidence?: ReadonlyArray; @@ -104,6 +106,7 @@ export type CreateConversationalRectificationCaseInput = MutationIdentity & Read export type LifeEventEvidenceInput = DeepReadonly; export type SaveConversationalRectificationTurnInput = CommandMutationIdentity & Readonly<{ + userMessage: string; turn: ConversationalRectificationTurnInput; evidence: ReadonlyArray; validationReceipt: ValidationReceiptInput; @@ -190,6 +193,8 @@ function parseStoredCase(data: unknown, allowNull = false): StoredConversational pendingConsultationQuestion: value.pending_consultation_question, billingState: value.billing_state, latestTurn: value.latest_turn, + ...(value.message_history === undefined + ? {} : { messageHistory: value.message_history }), ...(value.declared_birth_input === undefined ? {} : { declaredBirthInput: value.declared_birth_input }), ...(value.private_candidate === undefined @@ -319,7 +324,7 @@ export class ConversationalRectificationStore { userId: string; caseId?: string; }>): Promise { - const loaded = await this.callCaseRpc("load_conversational_rectification_case", { + const loaded = await this.callCaseRpc("load_conversational_rectification_case_with_history", { p_user_id: input.userId, p_case_id: input.caseId ?? null, }, true); @@ -349,11 +354,12 @@ export class ConversationalRectificationStore { input: SaveConversationalRectificationTurnInput, ): Promise { const functionName = input.turn.status === "completed" - ? "complete_conversational_rectification_with_range" - : "save_conversational_rectification_turn"; + ? "complete_conversational_rectification_with_range_and_history" + : "save_conversational_rectification_turn_with_history"; const result = await this.callCaseRpc(functionName, { ...mutationArgs(input), p_command_fingerprint: commandFingerprint(input), + p_user_message: input.userMessage, p_turn: requirePublicTurn(input.turn), p_evidence: requireEvidence(input.evidence), p_validation_receipt: requireValidationReceipt(input.validationReceipt), diff --git a/frontend/supabase/migrations/20260723010000_restore_conversational_message_history.sql b/frontend/supabase/migrations/20260723010000_restore_conversational_message_history.sql new file mode 100644 index 00000000..6b285bb7 --- /dev/null +++ b/frontend/supabase/migrations/20260723010000_restore_conversational_message_history.sql @@ -0,0 +1,177 @@ +-- Preserve the real user/Agent exchange across reloads. The durable public turn +-- remains unchanged; history is returned only by the service-role resume RPC. + +alter table public.birth_time_rectification_turns + add column if not exists user_message text; + +alter table public.birth_time_rectification_turns + drop constraint if exists birth_time_rectification_turns_user_message_check; + +alter table public.birth_time_rectification_turns + add constraint birth_time_rectification_turns_user_message_check check ( + user_message is null or ( + public.conversational_rectification_text_utf16_length(user_message) between 1 and 4000 + and public.conversational_rectification_text_is_nonblank(user_message) + ) + ); + +-- Older answer turns already point to their extracted evidence. Recover the +-- original raw answer where it is unambiguous instead of inventing UI copy. +update public.birth_time_rectification_turns turn_row +set user_message = ( + select event.raw_text + from public.birth_time_rectification_event_evidence event + where event.case_id = turn_row.case_id + and event.source_turn_id = turn_row.id + order by event.created_at, event.id + limit 1 +) +where turn_row.user_message is null + and exists ( + select 1 + from public.birth_time_rectification_event_evidence event + where event.case_id = turn_row.case_id + and event.source_turn_id = turn_row.id + ); + +create or replace function public.load_conversational_rectification_case_with_history( + p_user_id uuid, + p_case_id uuid default null +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_loaded jsonb; + v_case_id uuid; +begin + v_loaded := public.load_conversational_rectification_case(p_user_id, p_case_id); + if v_loaded is null then + return null; + end if; + v_case_id := (v_loaded ->> 'case_id')::uuid; + return v_loaded || pg_catalog.jsonb_build_object( + 'message_history', coalesce(( + select pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_object( + 'turnVersion', turn_row.turn_version, + 'userMessage', turn_row.user_message, + 'narrative', turn_row.narrative + ) order by turn_row.turn_version + ) + from ( + select history.turn_version, history.user_message, history.narrative + from public.birth_time_rectification_turns history + where history.case_id = v_case_id + order by history.turn_version desc + limit 200 + ) turn_row + ), '[]'::jsonb) + ); +end; +$$; + +create or replace function public.save_conversational_rectification_turn_with_history( + p_user_id uuid, + p_case_id uuid, + p_expected_version bigint, + p_action_id uuid, + p_turn jsonb, + p_evidence jsonb, + p_validation_receipt jsonb, + p_private_candidate jsonb, + p_command_fingerprint text, + p_user_message text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_response jsonb; + v_saved_user_message text; +begin + if p_user_message is null + or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000 + or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + v_response := public.save_conversational_rectification_turn( + p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence, + p_validation_receipt, p_private_candidate, p_command_fingerprint + ); + update public.birth_time_rectification_turns + set user_message = coalesce(user_message, p_user_message) + where case_id = p_case_id + and turn_version = p_expected_version + 1 + returning user_message into v_saved_user_message; + if not found or v_saved_user_message is distinct from p_user_message then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + return v_response; +end; +$$; + +create or replace function public.complete_conversational_rectification_with_range_and_history( + p_user_id uuid, + p_case_id uuid, + p_expected_version bigint, + p_action_id uuid, + p_turn jsonb, + p_evidence jsonb, + p_validation_receipt jsonb, + p_private_candidate jsonb, + p_command_fingerprint text, + p_user_message text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_response jsonb; + v_saved_user_message text; +begin + if p_user_message is null + or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000 + or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + v_response := public.complete_conversational_rectification_with_range( + p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence, + p_validation_receipt, p_private_candidate, p_command_fingerprint + ); + update public.birth_time_rectification_turns + set user_message = coalesce(user_message, p_user_message) + where case_id = p_case_id + and turn_version = p_expected_version + 1 + returning user_message into v_saved_user_message; + if not found or v_saved_user_message is distinct from p_user_message then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + return v_response; +end; +$$; + +revoke all on function public.load_conversational_rectification_case_with_history(uuid, uuid) + from public, anon, authenticated; +revoke all on function public.save_conversational_rectification_turn_with_history( + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text +) from public, anon, authenticated; +revoke all on function public.complete_conversational_rectification_with_range_and_history( + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text +) from public, anon, authenticated; + +grant execute on function public.load_conversational_rectification_case_with_history(uuid, uuid) + to service_role; +grant execute on function public.save_conversational_rectification_turn_with_history( + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text +) to service_role; +grant execute on function public.complete_conversational_rectification_with_range_and_history( + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text +) to service_role; diff --git a/frontend/tests/conversational-rectification-controller.test.ts b/frontend/tests/conversational-rectification-controller.test.ts index c969604b..fe2714b8 100644 --- a/frontend/tests/conversational-rectification-controller.test.ts +++ b/frontend/tests/conversational-rectification-controller.test.ts @@ -9,6 +9,7 @@ import { } from "../src/lib/conversational-rectification/client.ts"; import type { ConversationalRectificationCommand, + ConversationalRectificationResponse, ConversationalRectificationTurn, } from "../src/lib/conversational-rectification/contracts.ts"; @@ -64,6 +65,30 @@ function correctableTurn(turnVersion = 2): ConversationalRectificationTurn { }; } +function turnWithMessageHistory(turnVersion = 3): ConversationalRectificationResponse { + return { + ...activeTurn(turnVersion), + narrative: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。", + messageHistory: [ + { + turnVersion: 1, + userMessage: null, + narrative: "我们先从一件时间明确的经历开始。", + }, + { + turnVersion: 2, + userMessage: "2017 年 7 月入职第一家公司,从事数据分析。", + narrative: "这段职业起点已经记下。你当时为什么选择数据分析?", + }, + { + turnVersion: 3, + userMessage: "专业相关,也觉得数据工作更适合我。", + narrative: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。", + }, + ], + }; +} + function idFactory() { const ids = [...actionIds]; return () => ids.shift() ?? assert.fail("unexpected action id allocation"); @@ -135,6 +160,34 @@ test("controller retains alternating user and Agent messages after each answer", ]); }); +test("controller restores the real user and Agent history without synthesizing recap templates", () => { + const controller = createConversationalRectificationController({ + initialTurn: turnWithMessageHistory(), + }); + + const messages = controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })); + assert.deepEqual(messages, [ + { role: "assistant", text: "我们先从一件时间明确的经历开始。" }, + { role: "user", text: "2017 年 7 月入职第一家公司,从事数据分析。" }, + { role: "assistant", text: "这段职业起点已经记下。你当时为什么选择数据分析?" }, + { role: "user", text: "专业相关,也觉得数据工作更适合我。" }, + { role: "assistant", text: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。" }, + ]); + assert.doesNotMatch(messages?.map(({ text }) => text).join("\n") ?? "", /已记录这段经历/); +}); + +test("legacy responses without message history show only the real latest Agent narrative", () => { + const legacy = { + ...correctableTurn(3), + narrative: "你刚才补充的职业经历还缺离职原因,我先继续问这一件事。", + }; + const controller = createConversationalRectificationController({ initialTurn: legacy }); + + assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [ + { role: "assistant", text: legacy.narrative }, + ]); +}); + test("controller preserves the exact draft and stable action id across failures", async () => { const commands: ConversationalRectificationCommand[] = []; const controller = createConversationalRectificationController({ @@ -558,6 +611,22 @@ test("a newer external same-case turn wins over an older in-flight response", as assert.equal(controller.getSnapshot().turn?.turnVersion, 5); }); +test("a newer synchronized response replaces local bubbles with its durable message history", () => { + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(1), + }); + + controller.synchronizeInitialTurn(turnWithMessageHistory(3)); + + assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [ + { role: "assistant", text: "我们先从一件时间明确的经历开始。" }, + { role: "user", text: "2017 年 7 月入职第一家公司,从事数据分析。" }, + { role: "assistant", text: "这段职业起点已经记下。你当时为什么选择数据分析?" }, + { role: "user", text: "专业相关,也觉得数据工作更适合我。" }, + { role: "assistant", text: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。" }, + ]); +}); + test("an external same-version turn is not replaced by a late response", async () => { let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined; const request = new Promise((resolve) => { diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index 54acf52b..0e8abbae 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -201,7 +201,7 @@ test("loads the latest unfinished case by account without a chat identifier", as const loaded = await store.loadCase({ userId }); assert.equal(loaded?.caseId, caseId); - assert.deepEqual(calls, [["load_conversational_rectification_case", { + assert.deepEqual(calls, [["load_conversational_rectification_case_with_history", { p_user_id: userId, p_case_id: null, }]]); @@ -294,6 +294,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard await store.saveTurn({ ...common, + userMessage: "2019 年 7 月换工作", turn: { ...firstTurn, turnVersion: 1 }, evidence, validationReceipt, @@ -340,7 +341,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard }); assert.deepEqual(calls.map(([name]) => name), [ - "save_conversational_rectification_turn", + "save_conversational_rectification_turn_with_history", "pause_conversational_rectification_case", "abandon_conversational_rectification_without_result", "confirm_conversational_rectification_candidate", @@ -382,13 +383,14 @@ test("a completed unverified range uses the non-confirming completion RPC", asyn actionId, expectedVersion: 0, commandFingerprint, + userMessage: "这些经历已经补充完了", turn: completedTurn, evidence: [], validationReceipt, privateCandidate: { resultId, calculationVersion: "rectification-v3.1" }, }); - assert.equal(called, "complete_conversational_rectification_with_range"); + assert.equal(called, "complete_conversational_rectification_with_range_and_history"); assert.equal(result.status, "completed"); assert.equal(result.latestTurn.candidate.status, "pending_validation"); }); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 757f125c..02efb316 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -28,6 +28,34 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.equal(migration.status, 0, migration.stderr); assert.match(migration.stdout, /applied 20260715000000_account_credits\.sql/); assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/); + assert.match(migration.stdout, /applied 20260723010000_restore_conversational_message_history\.sql/); + + assert.equal( + fixture.psql(` + select is_nullable || ':' || data_type + from information_schema.columns + where table_schema = 'public' + and table_name = 'birth_time_rectification_turns' + and column_name = 'user_message' + `), + "YES:text", + ); + assert.equal( + fixture.psql(` + select + has_function_privilege( + 'service_role', + 'public.load_conversational_rectification_case_with_history(uuid, uuid)', + 'execute' + ) || ':' || + has_function_privilege( + 'authenticated', + 'public.load_conversational_rectification_case_with_history(uuid, uuid)', + 'execute' + ) + `), + "true:f", + ); assert.equal( fixture.psql(`