fix: preserve rectification conversation context

This commit is contained in:
Jesse_Chen
2026-07-25 10:21:51 +08:00
parent 47acaf3f53
commit 58579cade4
5 changed files with 172 additions and 35 deletions
+16
View File
@@ -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
- 复发自:无
- 修复版本:待提交(本地可测)
@@ -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 {
@@ -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<RectificationConversationMessage>;
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,
@@ -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<ComputedConversationalRectificationPacket>;
loadConversationMessages?(
userId: string,
caseId: string,
): Promise<ReadonlyArray<RectificationConversationMessage>>;
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<LifeEventEvidence, "dateValue" | "datePrecision">,
@@ -318,6 +325,7 @@ function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
}
function narrativeConversationContext(input: Readonly<{
recentConversation?: ReadonlyArray<RectificationConversationMessage>;
latestUserText: string;
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
@@ -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<LifeEventEvidenceInput>;
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
}>) {
let recentConversation: ReadonlyArray<RectificationConversationMessage> = [{
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],
@@ -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);