fix: deduplicate rectification question regeneration

This commit is contained in:
Jesse_Chen
2026-07-30 00:52:40 +08:00
parent 0a5b8cd239
commit 29374ded2a
5 changed files with 78 additions and 30 deletions
@@ -13,8 +13,17 @@ import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
import { openingQuestion } from "./opening-question.ts";
import type { RectificationV4Store } from "./store.ts";
export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) {
const regenerationInFlight = new Map<string, Promise<RectificationV4Case | null>>();
export function createRectificationV4CaseService(
store: RectificationV4Store,
options: {
readonly now?: () => Date;
readonly regenerateQuestion?: typeof regenerateQuestionRealization;
} = {},
) {
const now = options.now ?? (() => new Date());
const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization;
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
const [events, turns] = await Promise.all([
@@ -95,32 +104,45 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
readonly actionId: string;
readonly expectedCaseVersion: number;
}) {
const current = await store.loadCase(input.userId, input.caseId);
if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null;
const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId);
const opportunity = validated?.selectedOpportunity;
if (!opportunity) return null;
const [events, turns] = await Promise.all([
store.loadEvents(input.userId, input.caseId),
store.loadTurns(input.userId, input.caseId),
]);
const prompt = await regenerateQuestionRealization({
caseValue: current,
currentPrompt: current.currentQuestion.prompt,
latestAnswer: turns.at(-1)?.answer ?? "",
acceptedEvents: events,
opportunity,
});
const nextQuestion = {
...current.currentQuestion,
id: randomUUID(),
prompt,
};
return response(input.userId, await store.replaceCurrentQuestion({
...input,
question: nextQuestion,
now: now().toISOString(),
}));
const replay = await store.loadActionCase(input.userId, input.actionId);
if (replay) return response(input.userId, replay);
const key = `${input.userId}:${input.actionId}`;
let pending = regenerationInFlight.get(key);
if (!pending) {
pending = (async () => {
const secondReplay = await store.loadActionCase(input.userId, input.actionId);
if (secondReplay) return secondReplay;
const current = await store.loadCase(input.userId, input.caseId);
if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null;
const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId);
const opportunity = validated?.selectedOpportunity;
if (!opportunity) return null;
const [events, turns] = await Promise.all([
store.loadEvents(input.userId, input.caseId),
store.loadTurns(input.userId, input.caseId),
]);
const prompt = await realizeQuestion({
caseValue: current,
currentPrompt: current.currentQuestion.prompt,
latestAnswer: turns.at(-1)?.answer ?? "",
acceptedEvents: events,
opportunity,
});
return store.replaceCurrentQuestion({
...input,
question: { ...current.currentQuestion, id: randomUUID(), prompt },
now: now().toISOString(),
});
})();
regenerationInFlight.set(key, pending);
}
try {
const saved = await pending;
return saved ? response(input.userId, saved) : null;
} finally {
if (regenerationInFlight.get(key) === pending) regenerationInFlight.delete(key);
}
},
async reviseEvent(input: {
@@ -77,6 +77,10 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
.sort((left, right) => right.caseVersion - left.caseVersion || right.createdAt.localeCompare(left.createdAt))[0];
return latest ? validatedDecisions.get(latest.jobId) ?? latest.validatedDecision : null;
},
async loadActionCase(userId, actionId) {
const replay = actionResults.get(`${userId}:${actionId}`);
return replay ? owned(userId, replay.caseId) : null;
},
async createCase(input) {
const replay = actionResults.get(`${input.case.userId}:${input.actionId}`);
if (replay) return owned(input.case.userId, replay.caseId);
@@ -46,6 +46,7 @@ export interface RectificationV4Store {
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
loadTurns(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]>;
loadLatestValidatedDecision(userId: string, caseId: string): Promise<ValidatedDecision | null>;
loadActionCase(userId: string, actionId: string): Promise<RectificationV4Case | null>;
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
replaceCurrentQuestion(input: {
readonly userId: string;
@@ -208,6 +208,12 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
if (error) throw storeError(error);
return data ? validatedDecisionSchema.parse((data as Row).validated_decision_json) : null;
},
async loadActionCase(userId, actionId) {
const { data, error } = await supabase.from("birth_time_rectification_v4_actions")
.select("case_id").eq("user_id", userId).eq("action_id", actionId).maybeSingle();
if (error) throw storeError(error);
return data ? loadCaseById(userId, String((data as Row).case_id)) : null;
},
async createCase(input) {
const id = String(await rpc("create_birth_time_rectification_v5_case", {
p_user_id: input.case.userId,
@@ -151,7 +151,15 @@ test("legacy cases are not hard-switched to V5 even when flags change later", as
test("V5 Agent regenerate rewrites only the current semantic question and replays the same action once", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
let realizationCalls = 0;
const service = createRectificationV4CaseService(store, {
now: fixedNow,
regenerateQuestion: async ({ opportunity }) => {
realizationCalls += 1;
await new Promise((resolve) => setTimeout(resolve, 5));
return opportunity.fallbackPrompt;
},
});
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
@@ -172,13 +180,19 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay
const before = await service.loadCase(userId, created.case.id);
assert.ok(before?.case.currentQuestion);
const actionId = randomUUID();
const regenerated = await service.regenerateQuestion({
const regenerationInput = {
userId,
caseId: created.case.id,
actionId,
expectedCaseVersion: before.case.version,
});
};
const [regenerated, concurrentReplay] = await Promise.all([
service.regenerateQuestion(regenerationInput),
service.regenerateQuestion(regenerationInput),
]);
assert.ok(regenerated?.case.currentQuestion);
assert.equal(concurrentReplay?.case.currentQuestion?.id, regenerated.case.currentQuestion.id);
assert.equal(realizationCalls, 1);
assert.equal(regenerated.case.version, before.case.version + 1);
assert.notEqual(regenerated.case.currentQuestion.id, before.case.currentQuestion.id);
assert.equal(regenerated.case.currentQuestion.domain, before.case.currentQuestion.domain);
@@ -197,6 +211,7 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay
});
assert.equal(replayed?.case.version, regenerated.case.version);
assert.equal(replayed?.case.currentQuestion?.id, regenerated.case.currentQuestion.id);
assert.equal(realizationCalls, 1);
assert.equal(store.jobs.size, 1);
assert.equal(regenerated.case.latestSnapshot?.canConfirmExactMinute ?? false, false);
}));