From 58579cade413573393ec3426e83c9afbdc5663df Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 25 Jul 2026 10:21:51 +0800 Subject: [PATCH] fix: preserve rectification conversation context --- docs/BUG_HISTORY.md | 16 ++++ .../api/birth-time-conversation/handler.ts | 53 +++++++------- .../narrative-agent.ts | 8 ++ .../orchestrator.ts | 73 +++++++++++++++++-- ...ational-rectification-orchestrator.test.ts | 57 +++++++++++++++ 5 files changed, 172 insertions(+), 35 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a26f66de..b7dccc90 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1292,3 +1292,19 @@ - 相关记录:BUG-052、BUG-053、BUG-055、BUG-058、BUG-060 - 复发自:无 - 修复版本:待提交(待生产验收) + +## BUG-068 | 生时校正确认词被当成新事件且 Agent 每轮丢失对话历史 + +- 状态:resolved +- 首次发现:2026-07-25 +- 最近更新:2026-07-25 +- 影响面:生时校正连续问答、事件日期确认、刷新后继续会话 +- 用户现象:Agent 问某个事件是否发生在明确年月,用户回答“是的”后,下一轮仍重复询问相同年月;确认词还可能被保存成一条日期待补充的新事件。 +- 触发条件:用户使用确认词、代词或承接上一问的简短自然语言回答,而当前轮没有再次写出完整事件和绝对日期。 +- 根因:叙事模型每轮只收到最新用户文字和事件账本,没有收到同一 case 的持久化 Assistant/User 消息序列;确定性提取器又把无法独立解析的确认词当成新事件,并用自动澄清 follow-up 覆盖模型的自然追问。 +- 修复:复用现有 turn 与 event evidence 存储,每轮向 Agent 注入同一 case 最近 40 条连续问答;确认词优先采用上一条 Agent 明确提出的日期并修正目标事件,无法落到明确日期时不生成伪事件。模型成功返回时,其 follow-up 不再被提取器的自动澄清覆盖;事件账本继续只负责审计、去重和技术候选计算。 +- 验证:新增回归覆盖“明确年月确认问题 → 是的”,断言保存为目标事件的年月修正、没有独立确认词事件,并断言叙事 prompt 末尾连续包含上一条 Assistant 问题和当前 User 回答。 +- 防复发:任何承接式回答必须以持久化会话历史为第一语境;结构化提取只能规范化可确认事实,不得决定 Agent 的下一句话。 +- 相关记录:BUG-032、BUG-063、BUG-067 +- 复发自:无 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/api/birth-time-conversation/handler.ts b/frontend/src/app/api/birth-time-conversation/handler.ts index bf02042b..4dc8785b 100644 --- a/frontend/src/app/api/birth-time-conversation/handler.ts +++ b/frontend/src/app/api/birth-time-conversation/handler.ts @@ -986,8 +986,33 @@ async function createProductionService( const profileClient = authenticated.context as ProfileClient; const engine = createJyotishBirthTimeJourneyEngine(); const store = createSupabaseConversationalRectificationStore(admin); + const loadConversationMessages = async (userId: string, caseId: string) => { + const { data: ownedCase, error: caseError } = await admin + .from("birth_time_rectification_cases") + .select("id") + .eq("id", caseId) + .eq("user_id", userId) + .maybeSingle(); + if (caseError || !ownedCase) return []; + const [{ data: turns, error: turnsError }, { data: evidence, error: evidenceError }] = await Promise.all([ + admin + .from("birth_time_rectification_turns") + .select("id,turn_version,narrative") + .eq("case_id", caseId) + .order("turn_version", { ascending: true }), + admin + .from("birth_time_rectification_event_evidence") + .select("source_turn_id,raw_text,created_at,id") + .eq("case_id", caseId) + .order("created_at", { ascending: true }) + .order("id", { ascending: true }), + ]); + if (turnsError || evidenceError) return []; + return conversationMessagesFromStoredTurns(turns, evidence); + }; const service = createConversationalRectificationService({ store, + loadConversationMessages, billing: createSupabaseConversationalRectificationBilling(admin), get rectificationPriceCredits() { return priceCredits(); }, allowNewCaseCreation: conversationalRectificationCreationPolicyFromEnvironment( @@ -1073,33 +1098,7 @@ async function createProductionService( narrativeGenerator, asOfDate: () => new Date().toISOString().slice(0, 10), }); - return { - ...service, - async loadConversationMessages(userId, caseId) { - const { data: ownedCase, error: caseError } = await admin - .from("birth_time_rectification_cases") - .select("id") - .eq("id", caseId) - .eq("user_id", userId) - .maybeSingle(); - if (caseError || !ownedCase) return []; - const [{ data: turns, error: turnsError }, { data: evidence, error: evidenceError }] = await Promise.all([ - admin - .from("birth_time_rectification_turns") - .select("id,turn_version,narrative") - .eq("case_id", caseId) - .order("turn_version", { ascending: true }), - admin - .from("birth_time_rectification_event_evidence") - .select("source_turn_id,raw_text,created_at,id") - .eq("case_id", caseId) - .order("created_at", { ascending: true }) - .order("id", { ascending: true }), - ]); - if (turnsError || evidenceError) return []; - return conversationMessagesFromStoredTurns(turns, evidence); - }, - }; + return { ...service, loadConversationMessages }; } function stableRequestId(request: Request): string { diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index 08bf860b..b3669201 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -7,7 +7,13 @@ import { export type RectificationNarrativePhase = "first" | "intermediate" | "final"; +export type RectificationConversationMessage = Readonly<{ + role: "assistant" | "user"; + text: string; +}>; + export type RectificationNarrativeContext = Readonly<{ + recentConversation?: ReadonlyArray; latestUserText?: string; latestEvidence?: ReadonlyArray<{ id?: string; @@ -353,6 +359,7 @@ function grounding(packet: RectificationTechnicalPacket, phase: RectificationNar function narrativeConversationContext(context: RectificationNarrativeContext) { return { + recentConversation: context.recentConversation?.slice(-40), latestUserText: context.latestUserText, latestEvidence: context.latestEvidence?.map(({ id, dateLabel, summary }) => ({ id, @@ -510,6 +517,7 @@ function promptFor( task: "write_grounded_rectification_narrative", phase, conversationContext: narrativeConversationContext(context), + continuity: "recentConversation 是同一会话的真实连续问答。必须直接理解用户对上一条问题的确认、否认、补充或纠正,不得把“是的/对/来年/那次”等回复当成脱离上下文的新事件,也不得重复询问已经确认的信息。", packet: grounding(packet, phase), outputContract: { returnOnlyNarrativeAndEvidenceRequest: true, diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 908ffa56..8c11b75e 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -18,6 +18,7 @@ import { } from "./persistence-contracts.ts"; import { generateRectificationNarrative, + type RectificationConversationMessage, type RectificationNarrativeGenerator, type RectificationNarrativeResult, } from "./narrative-agent.ts"; @@ -84,6 +85,10 @@ export type ConversationalRectificationServicePorts = Readonly<{ buildTechnicalPacket( input: ConversationalRectificationPacketBuildInput, ): Promise; + loadConversationMessages?( + userId: string, + caseId: string, + ): Promise>; narrativeGenerator: RectificationNarrativeGenerator; asOfDate(): string; }>; @@ -126,6 +131,8 @@ const genericUncertaintyPattern = /(?:不知道|不确定)/; const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/; const contextualBareMonthDayPattern = /^\s*(\d{1,2})\s*月\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; const contextualBareDayPattern = /^\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; +const affirmativeAnswerPattern = /^\s*(?:是(?:的)?|对(?:的)?|没错|正确|确认|嗯+|没问题)\s*[。.!!]?\s*$/; +const proposedDatePattern = /((?:19|20)\d{2})\s*年\s*(1[0-2]|0?[1-9])\s*月(?:\s*(3[01]|[12]\d|0?[1-9])\s*(?:日|号))?/g; export function evidencePredatesBirthDate( evidence: Pick, @@ -318,6 +325,7 @@ function evidenceRecap(evidence: ReadonlyArray) { } function narrativeConversationContext(input: Readonly<{ + recentConversation?: ReadonlyArray; latestUserText: string; allEvidence: ReadonlyArray; newEvidence: ReadonlyArray; @@ -325,6 +333,7 @@ function narrativeConversationContext(input: Readonly<{ const activeEvidence = effectiveLifeEventEvidence(input.allEvidence); const activeIds = new Set(activeEvidence.map((item) => item.id)); return { + recentConversation: input.recentConversation, latestUserText: input.latestUserText.trim().slice(0, 4_000), latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({ id: item.id, @@ -542,8 +551,8 @@ function nonScoringTurn(input: { && latestIncomplete.eventSummary === "事件内容待补充" ? { kind: "event_detail" as const, evidenceId: latestIncomplete.id } : null; - const authoredRequest = authoredNarrative?.output.evidenceRequest; - const priorRequest = input.current.latestTurn.evidenceRequest; + const authoredRequest = authoredNarrative?.output.evidenceRequest; + const priorRequest = input.current.latestTurn.evidenceRequest; const evidenceRequest = status === "confirming" && priorRequest === null ? null : authoredRequest @@ -551,7 +560,7 @@ function nonScoringTurn(input: { domains: authoredRequest.domains, datePrecision: authoredRequest.datePrecision, freeTextAllowed: true as const, - followUp: clarificationFollowUp ?? authoredRequest.followUp, + followUp: authoredRequest.followUp, } : priorRequest ? { @@ -786,6 +795,14 @@ export function createConversationalRectificationService( if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") { return command.answer; } + if (followUp.kind === "event_date" && affirmativeAnswerPattern.test(command.answer)) { + const dates = [...current.latestTurn.narrative.matchAll(proposedDatePattern)]; + const proposed = dates.at(-1); + if (proposed) { + const [, year, month, day] = proposed; + return `${year}年${Number(month)}月${day ? `${Number(day)}日` : ""}`; + } + } const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence); const target = followUp.evidenceId ? activeEvidence.find((item) => item.id === followUp.evidenceId) @@ -818,6 +835,33 @@ export function createConversationalRectificationService( return command.answer.replace(match[0], `${sameYear ? anchorYear : anchorYear + 1}年${month}月`); } + async function conversationContext(input: Readonly<{ + userId: string; + current: LoadedConversationalRectificationCase; + latestUserText: string; + allEvidence: ReadonlyArray; + newEvidence: ReadonlyArray; + }>) { + let recentConversation: ReadonlyArray = [{ + role: "assistant", + text: input.current.latestTurn.narrative, + }]; + if (ports.loadConversationMessages) { + try { + const loaded = await ports.loadConversationMessages(input.userId, input.current.caseId); + if (loaded.length > 0) recentConversation = loaded; + } catch { + // Conversation history improves continuity but must not make a turn unavailable. + } + } + return narrativeConversationContext({ + recentConversation: [...recentConversation, { role: "user", text: input.latestUserText }], + latestUserText: input.latestUserText, + allEvidence: input.allEvidence, + newEvidence: input.newEvidence, + }); + } + async function extractedEvidence( command: CommandOf<"answer">, current: LoadedConversationalRectificationCase, @@ -825,6 +869,9 @@ export function createConversationalRectificationService( let extracted: readonly LifeEventEvidence[]; try { const answerForExtraction = contextualizedAnswer(command, current); + if (affirmativeAnswerPattern.test(command.answer) && answerForExtraction === command.answer) { + return []; + } extracted = extractLifeEventEvidence({ rawText: answerForExtraction, sourceTurnId: command.actionId, @@ -1226,7 +1273,9 @@ export function createConversationalRectificationService( phase: "intermediate", packet: gatedPacket, generator: ports.narrativeGenerator, - context: narrativeConversationContext({ + context: await conversationContext({ + userId, + current, latestUserText: command.answer, allEvidence: [...current.eventEvidence, ...evidence], newEvidence: evidence, @@ -1267,7 +1316,9 @@ export function createConversationalRectificationService( phase, packet: gatedPacket, generator: ports.narrativeGenerator, - context: narrativeConversationContext({ + context: await conversationContext({ + userId, + current, latestUserText: command.answer, allEvidence: [...current.eventEvidence, ...evidence], newEvidence: evidence, @@ -1325,7 +1376,9 @@ export function createConversationalRectificationService( phase: "intermediate", packet: gatedPacket, generator: ports.narrativeGenerator, - context: narrativeConversationContext({ + context: await conversationContext({ + userId, + current, latestUserText: command.answer, allEvidence: [...current.eventEvidence, ...evidence], newEvidence: evidence, @@ -1381,7 +1434,9 @@ export function createConversationalRectificationService( phase, packet: gatedPacket, generator: ports.narrativeGenerator, - context: narrativeConversationContext({ + context: await conversationContext({ + userId, + current, latestUserText: command.answer, allEvidence: [...current.eventEvidence, ...evidence], newEvidence: evidence, @@ -1460,7 +1515,9 @@ export function createConversationalRectificationService( phase, packet: gatedPacket, generator: ports.narrativeGenerator, - context: latestEvidence ? narrativeConversationContext({ + context: latestEvidence ? await conversationContext({ + userId, + current, latestUserText: latestEvidence.rawText, allEvidence: current.eventEvidence, newEvidence: [latestEvidence], diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index b89ea737..be47324a 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -125,6 +125,7 @@ function validGenerator( const request = JSON.parse(prompt) as { phase: "first" | "intermediate" | "final"; conversationContext?: { + recentConversation?: Array<{ role: "assistant" | "user"; text: string }>; latestEvidence?: Array<{ dateLabel: string; summary: string }>; eventLedger?: Array<{ id: string; @@ -1178,6 +1179,62 @@ test("a bare month-day answer refines the targeted month without another confirm assert.doesNotMatch(completed.narrative, /是指.*7 月 10|哪一天|哪一年、哪一月/); }); +test("an affirmative reply confirms the date proposed by the previous Agent turn", async () => { + const value = harness({ readyAfterEvidenceCount: 99 }); + await start(value, null); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2020年我去石油化工研究院实习,后来主动辞职", + }); + + const current = value.cases.get(startActionId)?.row; + const target = current?.eventEvidence.at(-1); + const initialEvidenceCount = current?.eventEvidence.length ?? 0; + assert.ok(current); + assert.ok(target); + value.cases.set(startActionId, { + row: { + ...current, + latestTurn: { + ...current.latestTurn, + narrative: "你说实习到10月份然后辞职,这个10月是2020年10月吗?", + evidenceRequest: { + domains: ["career"], + datePrecision: "month_preferred", + freeTextAllowed: true, + followUp: { kind: "event_date", evidenceId: target.id }, + }, + }, + }, + }); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "是的", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, initialEvidenceCount + 1, "confirmation is an auditable correction, not a standalone event"); + assert.deepEqual(stored.at(-1)?.correctsEvidenceIds, [target.id]); + assert.equal(stored.at(-1)?.dateValue, "2020-10"); + assert.doesNotMatch(stored.at(-1)?.eventSummary ?? "", /^是的$/); + + const prompt = JSON.parse(value.narrativePrompts.at(-1) ?? "{}") as { + conversationContext?: { recentConversation?: Array<{ role: string; text: string }> }; + }; + assert.deepEqual(prompt.conversationContext?.recentConversation?.slice(-2), [ + { role: "assistant", text: "你说实习到10月份然后辞职,这个10月是2020年10月吗?" }, + { role: "user", text: "是的" }, + ]); +}); + test("an authored event-detail follow-up survives progress decoration and keeps the prior date", async () => { const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true }); await start(value, null);