fix: preserve rectification conversation context

This commit is contained in:
Jesse_Chen
2026-07-25 10:25:48 +08:00
parent 47acaf3f53
commit 58579cade4
5 changed files with 172 additions and 35 deletions
@@ -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],