fix: restore conversational rectification history
This commit is contained in:
@@ -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
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -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),
|
||||
|
||||
+177
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user