fix: restore conversational rectification history

This commit is contained in:
Jesse_Chen
2026-07-23 14:26:35 +08:00
parent 08a49aa50f
commit 685f322981
13 changed files with 396 additions and 54 deletions
+16
View File
@@ -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
- 修复版本:待提交
+7 -4
View File
@@ -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<string | null>(null);
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationTurn | null>(null);
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationResponse | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(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);
@@ -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;
}>;
@@ -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<ConversationalRectificationTurn | null>;
type MutationResult = Promise<ConversationalRectificationResponse | null>;
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<ConversationalRectificationTurn>;
initialTurn?: ConversationalRectificationResponse | null;
send?: (command: ConversationalRectificationCommand) => Promise<ConversationalRectificationResponse>;
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<ConversationalRectificationCommand["type"], "start">,
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)
@@ -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<ConversationalRectificationTurn> {
): Promise<ConversationalRectificationResponse> {
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(
@@ -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<typeof conversationalRectificationTurnSchema>;
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
>;
@@ -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,
@@ -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(),
@@ -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<ConversationalRectificationMessageHistoryEntry>;
declaredBirthInput?: DeepReadonly<DeclaredBirthInput>;
privateCandidate?: DeepReadonly<PrivateCandidate>;
eventEvidence?: ReadonlyArray<LifeEventEvidenceInput>;
@@ -104,6 +106,7 @@ export type CreateConversationalRectificationCaseInput = MutationIdentity & Read
export type LifeEventEvidenceInput = DeepReadonly<LifeEventEvidence>;
export type SaveConversationalRectificationTurnInput = CommandMutationIdentity & Readonly<{
userMessage: string;
turn: ConversationalRectificationTurnInput;
evidence: ReadonlyArray<LifeEventEvidenceInput>;
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<LoadedConversationalRectificationCase | null> {
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<StoredConversationalRectificationCase> {
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),
@@ -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;
@@ -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<ConversationalRectificationTurn>((resolve) => {
@@ -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");
});
@@ -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(`