fix: preserve rectification conversation context
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user