fix: deduplicate rectification question regeneration
This commit is contained in:
@@ -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);
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user