feat: rebuild birth time rectification workflow
This commit is contained in:
@@ -206,3 +206,29 @@ export function resolveAccountRectificationCase(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const unfinishedV4Statuses = new Set<AccountRectificationCaseState["status"]>([
|
||||
"awaiting_answer", "processing", "range_ready", "paused",
|
||||
]);
|
||||
|
||||
/** V4 cases already carry the calculation spec; creation atomically replaces a stale spec. */
|
||||
export function resolveAccountRectificationV4Case(rows: readonly unknown[]): AccountRectificationCaseState | null {
|
||||
for (const value of rows) {
|
||||
const row = record(value);
|
||||
if (!row || typeof row.id !== "string" || typeof row.version !== "number"
|
||||
|| !Number.isSafeInteger(row.version) || row.version < 0
|
||||
|| typeof row.status !== "string"
|
||||
|| !unfinishedV4Statuses.has(row.status as AccountRectificationCaseState["status"])
|
||||
|| row.accepted_range_start !== null) continue;
|
||||
return Object.freeze({
|
||||
caseId: row.id,
|
||||
journeyProtocol: "rectification-evidence-v4" as const,
|
||||
status: row.status as AccountRectificationCaseState["status"],
|
||||
turnVersion: row.version,
|
||||
isRevision: false,
|
||||
preservesActiveTime: true,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export type BirthTimeConsultationConsentState = Readonly<
|
||||
|
||||
export type AccountRectificationCaseState = Readonly<{
|
||||
caseId: string;
|
||||
journeyProtocol: "conversational-evidence-v3";
|
||||
status: "starting" | "active" | "paused" | "confirming" | "completed" | "abandoned";
|
||||
journeyProtocol: "conversational-evidence-v3" | "rectification-evidence-v4";
|
||||
status: "starting" | "active" | "awaiting_answer" | "processing" | "range_ready" | "paused" | "confirming" | "completed" | "abandoned";
|
||||
turnVersion: number;
|
||||
isRevision: boolean;
|
||||
preservesActiveTime: boolean;
|
||||
@@ -32,6 +32,9 @@ const unfinishedRectificationStatuses = new Set<AccountRectificationCaseState["s
|
||||
"active",
|
||||
"paused",
|
||||
"confirming",
|
||||
"awaiting_answer",
|
||||
"processing",
|
||||
"range_ready",
|
||||
]);
|
||||
|
||||
export function createBirthTimeConsultationConsentState(): BirthTimeConsultationConsentState {
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ServerChartConsultation = Readonly<{
|
||||
birthDate: string;
|
||||
reportedBirthTime: string | null;
|
||||
activeBirthTime: string | null;
|
||||
selectedTimeKind: "reported" | "active";
|
||||
selectedTimeKind: "reported" | "active" | "candidate_range_boundary";
|
||||
birthTimeSource: string;
|
||||
birthTimeStatus: string;
|
||||
placeLabel: string;
|
||||
@@ -62,6 +62,7 @@ export type ServerChartConsultation = Readonly<{
|
||||
type PrepareConsultationRouteInput<Reservation> = Readonly<{
|
||||
userId: string;
|
||||
mode: ConsultationBirthTimeMode;
|
||||
candidateRange?: Readonly<{ start: string; end: string }>;
|
||||
loadProfile: (userId: string) => Promise<unknown>;
|
||||
resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise<unknown>;
|
||||
reserve: () => Promise<Reservation>;
|
||||
@@ -157,6 +158,7 @@ function legacyChinaPlaceLabel(profile: RecordValue): string | null {
|
||||
function serverChartFromProfile(
|
||||
value: unknown,
|
||||
mode: Exclude<ConsultationBirthTimeMode, "general_no_birth_time">,
|
||||
candidateBoundary?: string,
|
||||
): ServerChartConsultation {
|
||||
const profile = record(value);
|
||||
if (!profile) throw new ConsultationProfileTruthError("profile_incomplete");
|
||||
@@ -195,8 +197,14 @@ function serverChartFromProfile(
|
||||
if (!placeLabel) throw new ConsultationProfileTruthError("profile_incomplete");
|
||||
|
||||
let selectedTime: string;
|
||||
let selectedTimeKind: "reported" | "active";
|
||||
if (mode === "verified_chart") {
|
||||
let selectedTimeKind: "reported" | "active" | "candidate_range_boundary";
|
||||
if (candidateBoundary !== undefined) {
|
||||
if (!isBirthClockTime(candidateBoundary)) {
|
||||
throw new ConsultationProfileTruthError("profile_inconsistent");
|
||||
}
|
||||
selectedTime = candidateBoundary;
|
||||
selectedTimeKind = "candidate_range_boundary";
|
||||
} else if (mode === "verified_chart") {
|
||||
if (birthTimeStatus !== "confirmed") {
|
||||
throw new ConsultationProfileTruthError("mode_changed");
|
||||
}
|
||||
@@ -263,20 +271,26 @@ export async function prepareConsultationRoute<Reservation>(
|
||||
try {
|
||||
profile = await input.loadProfile(input.userId);
|
||||
} catch (error) {
|
||||
if (input.mode === "general_no_birth_time") profile = null;
|
||||
if (input.mode === "general_no_birth_time" && !input.candidateRange) profile = null;
|
||||
else if (error instanceof ConsultationProfileTruthError) throw error;
|
||||
else throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
}
|
||||
|
||||
const consultationMode = input.mode === "general_no_birth_time"
|
||||
? persistedChartMode(profile) ?? input.mode
|
||||
: input.mode;
|
||||
const consultationMode = input.candidateRange
|
||||
? "unverified_birth_time"
|
||||
: input.mode === "general_no_birth_time"
|
||||
? persistedChartMode(profile) ?? input.mode
|
||||
: input.mode;
|
||||
let serverChart: ServerChartConsultation | null = null;
|
||||
if (consultationMode !== "general_no_birth_time") {
|
||||
const profileValue = record(profile);
|
||||
const selectedTime = consultationMode === "verified_chart"
|
||||
if (input.candidateRange && (!isBirthClockTime(input.candidateRange.start)
|
||||
|| !isBirthClockTime(input.candidateRange.end))) {
|
||||
throw new ConsultationProfileTruthError("profile_inconsistent");
|
||||
}
|
||||
const selectedTime = input.candidateRange?.start ?? (consultationMode === "verified_chart"
|
||||
? nullableClock(profileValue ?? {}, "active_birth_time")
|
||||
: nullableClock(profileValue ?? {}, "reported_birth_time");
|
||||
: nullableClock(profileValue ?? {}, "reported_birth_time"));
|
||||
try {
|
||||
profile = await (input.resolveTimezoneOffset ?? ((value, time) => (
|
||||
resolveMissingBirthTimezoneOffset(value, { preferredTime: time })
|
||||
@@ -284,7 +298,11 @@ export async function prepareConsultationRoute<Reservation>(
|
||||
} catch {
|
||||
throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
}
|
||||
serverChart = serverChartFromProfile(profile, consultationMode);
|
||||
serverChart = serverChartFromProfile(
|
||||
profile,
|
||||
consultationMode,
|
||||
input.candidateRange?.start,
|
||||
);
|
||||
}
|
||||
const reservation = await input.reserve();
|
||||
return Object.freeze({ consultationMode, serverChart, reservation });
|
||||
|
||||
@@ -5,6 +5,10 @@ import { storedCaseRowSchema } from "./conversational-rectification/persistence-
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
const fingerprintSchema = z.string().regex(/^[0-9a-f]{64}$/);
|
||||
const acceptedRangeSchema = z.object({
|
||||
start: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
end: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
}).strict();
|
||||
|
||||
const handoffProjectionSchema = z.object({
|
||||
caseId: uuidSchema,
|
||||
@@ -29,9 +33,14 @@ const settlementProjectionSchema = z.object({
|
||||
credits: z.number().int().nonnegative().nullable(),
|
||||
}).strict();
|
||||
|
||||
const rectificationV4ExecutionProjectionSchema = executionProjectionSchema.extend({
|
||||
acceptedRange: acceptedRangeSchema,
|
||||
}).strict();
|
||||
|
||||
export type RectificationHandoffProjection = z.infer<typeof handoffProjectionSchema>;
|
||||
export type RectificationHandoffExecution = z.infer<typeof executionProjectionSchema>;
|
||||
export type RectificationHandoffSettlement = z.infer<typeof settlementProjectionSchema>;
|
||||
export type RectificationV4HandoffExecution = z.infer<typeof rectificationV4ExecutionProjectionSchema>;
|
||||
|
||||
export type RectificationHandoffRpcClient = Readonly<{
|
||||
rpc(
|
||||
@@ -60,13 +69,13 @@ function rpcMessage(error: unknown): string {
|
||||
|
||||
function mappedError(error: unknown): RectificationHandoffServiceError {
|
||||
const message = rpcMessage(error);
|
||||
if (message === "conversational_case_not_found") {
|
||||
if (["conversational_case_not_found", "rectification_v4_case_not_found"].includes(message)) {
|
||||
return new RectificationHandoffServiceError("not_found");
|
||||
}
|
||||
if (message === "conversational_stale_turn") {
|
||||
if (["conversational_stale_turn", "stale_rectification_v4_case"].includes(message)) {
|
||||
return new RectificationHandoffServiceError("stale");
|
||||
}
|
||||
if (message === "conversational_action_conflict") {
|
||||
if (["conversational_action_conflict", "rectification_v4_handoff_conflict"].includes(message)) {
|
||||
return new RectificationHandoffServiceError("conflict");
|
||||
}
|
||||
return new RectificationHandoffServiceError("unavailable");
|
||||
@@ -207,3 +216,127 @@ export function createRectificationHandoffService(client: RectificationHandoffRp
|
||||
}
|
||||
|
||||
export type RectificationHandoffService = ReturnType<typeof createRectificationHandoffService>;
|
||||
|
||||
const rectificationV4HandoffProjectionSchema = z.object({
|
||||
protocol: z.literal("rectification-evidence-v4"),
|
||||
caseId: uuidSchema,
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
questionFingerprint: fingerprintSchema,
|
||||
requestId: uuidSchema,
|
||||
status: z.enum(["pending", "claimed", "in_progress", "consumed"]),
|
||||
acceptedRange: acceptedRangeSchema.nullable(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationV4HandoffProjection = z.infer<typeof rectificationV4HandoffProjectionSchema>;
|
||||
|
||||
export function createRectificationV4HandoffService(client: RectificationHandoffRpcClient) {
|
||||
return Object.freeze({
|
||||
async attach(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
caseVersion: number;
|
||||
actionId: string;
|
||||
question: string;
|
||||
}>): Promise<RectificationV4HandoffProjection> {
|
||||
const question = input.question.trim();
|
||||
const parsed = rectificationV4HandoffProjectionSchema.safeParse(await rpc(
|
||||
client,
|
||||
"attach_birth_time_rectification_v4_question",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_expected_version: input.caseVersion,
|
||||
p_action_id: input.actionId,
|
||||
p_question: question,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
|
||||
async load(input: Readonly<{ userId: string; caseId?: string }>) {
|
||||
const value = await rpc(client, "load_birth_time_rectification_v4_handoff", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId ?? null,
|
||||
});
|
||||
if (value === null) return null;
|
||||
const parsed = rectificationV4HandoffProjectionSchema.safeParse(value);
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
|
||||
async claim(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
caseVersion: number;
|
||||
actionId: string;
|
||||
question: string;
|
||||
}>): Promise<RectificationV4HandoffProjection> {
|
||||
const question = input.question.trim();
|
||||
const parsed = rectificationV4HandoffProjectionSchema.safeParse(await rpc(
|
||||
client,
|
||||
"claim_birth_time_rectification_v4_handoff",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_expected_version: input.caseVersion,
|
||||
p_action_id: input.actionId,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
|
||||
async beginExecution(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
caseVersion: number;
|
||||
claimActionId: string;
|
||||
requestId: string;
|
||||
question: string;
|
||||
}>): Promise<RectificationV4HandoffExecution> {
|
||||
const question = input.question.trim();
|
||||
const parsed = rectificationV4ExecutionProjectionSchema.safeParse(await rpc(
|
||||
client,
|
||||
"begin_birth_time_rectification_v4_handoff_execution",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_expected_version: input.caseVersion,
|
||||
p_claim_action_id: input.claimActionId,
|
||||
p_request_id: input.requestId,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
|
||||
async settle(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
claimActionId: string;
|
||||
requestId: string;
|
||||
emitted: boolean;
|
||||
}>): Promise<RectificationHandoffSettlement> {
|
||||
const parsed = settlementProjectionSchema.safeParse(await rpc(
|
||||
client,
|
||||
"settle_birth_time_rectification_v4_handoff",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_claim_action_id: input.claimActionId,
|
||||
p_request_id: input.requestId,
|
||||
p_emitted: input.emitted,
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RectificationV4HandoffService = ReturnType<typeof createRectificationV4HandoffService>;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CandidateCluster, CandidateMinute } from "./contracts.ts";
|
||||
|
||||
function minuteValue(value: string): number {
|
||||
const [hour, minute] = value.split(":").map(Number);
|
||||
return hour! * 60 + minute!;
|
||||
}
|
||||
|
||||
function nextMinute(previous: string, current: string): boolean {
|
||||
return (minuteValue(current) - minuteValue(previous) + 1_440) % 1_440 === 1;
|
||||
}
|
||||
|
||||
export function buildCandidateClusters(
|
||||
candidates: readonly CandidateMinute[],
|
||||
relativeFloor = 0.97,
|
||||
): readonly CandidateCluster[] {
|
||||
if (candidates.length === 0) return [];
|
||||
const sorted = [...candidates].sort((left, right) => minuteValue(left.time) - minuteValue(right.time));
|
||||
const peak = Math.max(...sorted.map((candidate) => candidate.score));
|
||||
const floor = peak >= 0 ? peak * relativeFloor : peak / relativeFloor;
|
||||
const viable = sorted.filter((candidate) => candidate.score >= floor);
|
||||
const groups: CandidateMinute[][] = [];
|
||||
for (const candidate of viable) {
|
||||
const group = groups.at(-1);
|
||||
if (group && nextMinute(group.at(-1)!.time, candidate.time)) group.push(candidate);
|
||||
else groups.push([candidate]);
|
||||
}
|
||||
return groups.map((group) => {
|
||||
const peakScore = Math.max(...group.map((candidate) => candidate.score));
|
||||
const peakCandidate = group.find((candidate) => candidate.score === peakScore)!;
|
||||
return {
|
||||
rank: 0,
|
||||
startTime: group[0]!.time,
|
||||
endTime: group.at(-1)!.time,
|
||||
representativeTime: peakCandidate.time,
|
||||
widthMinutes: group.length,
|
||||
peakScore,
|
||||
scoreMass: group.reduce((total, candidate) => total + Math.max(candidate.score, 0), 0),
|
||||
};
|
||||
}).sort((left, right) => right.peakScore - left.peakScore || right.scoreMass - left.scoreMass)
|
||||
.map((cluster, index) => ({ ...cluster, rank: index + 1 }));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "./contracts.ts";
|
||||
import { rectificationV4AlgorithmVersion } from "./contracts.ts";
|
||||
|
||||
const responseSchema = z.object({
|
||||
result_id: z.string().uuid(),
|
||||
algorithm_version: z.literal(rectificationV4AlgorithmVersion),
|
||||
calculation_spec_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
candidate_scores: z.array(z.object({
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
score: z.number().finite(),
|
||||
supporting_event_ids: z.array(z.string().uuid()),
|
||||
conflicting_event_ids: z.array(z.string().uuid()),
|
||||
}).strict()).min(1).max(1_440),
|
||||
robustness: z.object({
|
||||
neighbor_support_minutes: z.number().int().nonnegative(),
|
||||
leave_one_out_retention_rate: z.number().finite().min(0).max(1),
|
||||
date_sensitivity_retention_rate: z.number().finite().min(0).max(1),
|
||||
}).passthrough(),
|
||||
missing_layers: z.array(z.string()),
|
||||
can_confirm_exact_minute: z.literal(false),
|
||||
}).passthrough();
|
||||
|
||||
export type CandidateEngineResult = Readonly<{
|
||||
resultId: string;
|
||||
calculationSpecHash: string;
|
||||
candidates: readonly CandidateMinute[];
|
||||
robustness: {
|
||||
readonly neighborSupportMinutes: number;
|
||||
readonly leaveOneOutRetentionRate: number;
|
||||
readonly dateSensitivityRetentionRate: number;
|
||||
};
|
||||
missingLayers: readonly string[];
|
||||
}>;
|
||||
|
||||
export interface RectificationV4CandidateEngine {
|
||||
score(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[] }): Promise<CandidateEngineResult>;
|
||||
}
|
||||
|
||||
export function createRectificationV4CandidateEngine(options: {
|
||||
readonly apiBase: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
}): RectificationV4CandidateEngine {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
return {
|
||||
async score({ calculationSpec, events }) {
|
||||
const response = await fetchImpl(`${options.apiBase}/api/active_rectification_events_v4`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
birth_date: calculationSpec.birthDate,
|
||||
start_time: calculationSpec.candidateRange.start,
|
||||
end_time: calculationSpec.candidateRange.end,
|
||||
lat: calculationSpec.latitude,
|
||||
lon: calculationSpec.longitude,
|
||||
tz: calculationSpec.timezoneOffsetHours,
|
||||
events: events.map((event) => ({
|
||||
id: event.eventId,
|
||||
domain: event.domain,
|
||||
event_kind: event.eventKind,
|
||||
date_start: event.dateRange.start,
|
||||
date_end: event.dateRange.end,
|
||||
precision: event.dateRange.precision,
|
||||
summary: event.summary,
|
||||
})),
|
||||
}),
|
||||
signal: AbortSignal.timeout(5 * 60_000),
|
||||
});
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) throw new Error(`rectification_v4_engine_${response.status}`);
|
||||
const parsed = responseSchema.parse(payload);
|
||||
return {
|
||||
resultId: parsed.result_id,
|
||||
calculationSpecHash: parsed.calculation_spec_hash,
|
||||
candidates: parsed.candidate_scores.map((candidate) => ({
|
||||
time: candidate.time,
|
||||
score: candidate.score,
|
||||
supportingEventIds: candidate.supporting_event_ids,
|
||||
conflictingEventIds: candidate.conflicting_event_ids,
|
||||
})),
|
||||
robustness: {
|
||||
neighborSupportMinutes: parsed.robustness.neighbor_support_minutes,
|
||||
leaveOneOutRetentionRate: parsed.robustness.leave_one_out_retention_rate,
|
||||
dateSensitivityRetentionRate: parsed.robustness.date_sensitivity_retention_rate,
|
||||
},
|
||||
missingLayers: parsed.missing_layers,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
CalculationSpec,
|
||||
LifeEventRevision,
|
||||
RectificationV4ApiResponse,
|
||||
RectificationV4Case,
|
||||
} from "./contracts.ts";
|
||||
import { rectificationV4Protocol } from "./contracts.ts";
|
||||
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
|
||||
import { openingQuestion } from "./question-planner.ts";
|
||||
import type { RectificationV4Store } from "./store.ts";
|
||||
|
||||
export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) {
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
|
||||
return {
|
||||
case: caseValue,
|
||||
job: jobId ? await store.loadJob(userId, jobId) : null,
|
||||
events: [...await store.loadEvents(userId, caseValue.id)],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async createCase(input: { readonly userId: string; readonly actionId: string; readonly calculationSpec: CalculationSpec }) {
|
||||
const timestamp = now().toISOString();
|
||||
const caseValue: RectificationV4Case = {
|
||||
id: randomUUID(),
|
||||
userId: input.userId,
|
||||
protocol: rectificationV4Protocol,
|
||||
version: 0,
|
||||
status: "awaiting_answer",
|
||||
phase: "collecting_evidence",
|
||||
calculationSpec: input.calculationSpec,
|
||||
calculationSpecHash: calculationSpecHash(input.calculationSpec),
|
||||
evidenceSetHash: evidenceSetHash([]),
|
||||
currentQuestion: openingQuestion(),
|
||||
latestSnapshot: null,
|
||||
acceptedRange: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
return response(input.userId, await store.createCase({ case: caseValue, actionId: input.actionId }));
|
||||
},
|
||||
|
||||
async loadCase(userId: string, caseId: string) {
|
||||
const found = await store.loadCase(userId, caseId);
|
||||
return found ? response(userId, found) : null;
|
||||
},
|
||||
|
||||
async loadActive(userId: string) {
|
||||
const found = await store.findActiveCase(userId);
|
||||
return found ? response(userId, found) : null;
|
||||
},
|
||||
|
||||
async loadJob(userId: string, jobId: string) {
|
||||
return store.loadJob(userId, jobId);
|
||||
},
|
||||
|
||||
async answer(input: { readonly userId: string; readonly caseId: string; readonly actionId: string; readonly expectedCaseVersion: number; readonly answer: string }) {
|
||||
const current = await store.loadCase(input.userId, input.caseId);
|
||||
if (!current?.currentQuestion) return null;
|
||||
const saved = await store.submitAnswer({
|
||||
...input,
|
||||
question: current.currentQuestion,
|
||||
jobId: randomUUID(),
|
||||
turnId: randomUUID(),
|
||||
now: now().toISOString(),
|
||||
});
|
||||
return response(input.userId, saved.case, saved.job.id);
|
||||
},
|
||||
|
||||
async reviseEvent(input: {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly revision: LifeEventRevision;
|
||||
}) {
|
||||
const saved = await store.reviseEvent({ ...input, jobId: randomUUID(), now: now().toISOString() });
|
||||
return response(input.userId, saved.case, saved.job.id);
|
||||
},
|
||||
|
||||
async transition(input: {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly kind: "pause" | "resume" | "abandon";
|
||||
}) {
|
||||
const status = input.kind === "pause" ? "paused" : input.kind === "abandon" ? "abandoned" : "awaiting_answer";
|
||||
const phase = input.kind === "abandon" ? "complete" : "collecting_evidence";
|
||||
return response(input.userId, await store.transitionCase({ ...input, status, phase, now: now().toISOString() }));
|
||||
},
|
||||
|
||||
async acceptRange(input: {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly startTime: string;
|
||||
readonly endTime: string;
|
||||
}) {
|
||||
const current = await store.loadCase(input.userId, input.caseId);
|
||||
const primary = current?.latestSnapshot?.clusters[0];
|
||||
if (!current || !current.latestSnapshot?.canAcceptRange || !primary
|
||||
|| primary.startTime !== input.startTime || primary.endTime !== input.endTime) return null;
|
||||
return response(input.userId, await store.transitionCase({
|
||||
...input,
|
||||
status: "range_ready",
|
||||
phase: "complete",
|
||||
acceptedRange: { start: input.startTime, end: input.endTime },
|
||||
now: now().toISOString(),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
rectificationV4ApiResponseSchema,
|
||||
rectificationV4HandoffSchema,
|
||||
rectificationV4JobSchema,
|
||||
type RectificationV4ApiResponse,
|
||||
type RectificationV4Handoff,
|
||||
type RectificationV4Job,
|
||||
} from "./contracts";
|
||||
|
||||
const errorSchema = z.object({
|
||||
error: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string): string {
|
||||
const parsed = errorSchema.safeParse(payload);
|
||||
return parsed.success ? parsed.data.message || parsed.data.error || fallback : fallback;
|
||||
}
|
||||
|
||||
export class RectificationV4RequestError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function json<T>(response: Response, schema: z.ZodType<T>): Promise<T> {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new RectificationV4RequestError(
|
||||
response.status,
|
||||
errorMessage(payload, "暂时无法处理,请稍后再试。"),
|
||||
);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
function post(path: string, body: unknown): Promise<RectificationV4ApiResponse> {
|
||||
return fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then((response) => json(response, rectificationV4ApiResponseSchema));
|
||||
}
|
||||
|
||||
export async function loadActiveRectificationV4(): Promise<RectificationV4ApiResponse | null> {
|
||||
const response = await fetch("/api/rectification/v4/cases/active", { cache: "no-store" });
|
||||
if (response.status === 204) return null;
|
||||
return json(response, rectificationV4ApiResponseSchema);
|
||||
}
|
||||
|
||||
export function createRectificationV4(): Promise<RectificationV4ApiResponse> {
|
||||
return post("/api/rectification/v4/cases", { actionId: globalThis.crypto.randomUUID() });
|
||||
}
|
||||
|
||||
export async function loadRectificationV4(caseId: string): Promise<RectificationV4ApiResponse> {
|
||||
return json(await fetch(`/api/rectification/v4/cases/${caseId}`, { cache: "no-store" }), rectificationV4ApiResponseSchema);
|
||||
}
|
||||
|
||||
export function answerRectificationV4(caseId: string, expectedCaseVersion: number, answer: string) {
|
||||
return post(`/api/rectification/v4/cases/${caseId}/answers`, {
|
||||
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, answer,
|
||||
});
|
||||
}
|
||||
|
||||
export function transitionRectificationV4(
|
||||
caseId: string,
|
||||
expectedCaseVersion: number,
|
||||
action: "pause" | "resume" | "abandon",
|
||||
) {
|
||||
return post(`/api/rectification/v4/cases/${caseId}/${action}`, {
|
||||
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function acceptRectificationV4Range(
|
||||
caseId: string,
|
||||
expectedCaseVersion: number,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
) {
|
||||
return post(`/api/rectification/v4/cases/${caseId}/accept-range`, {
|
||||
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, startTime, endTime,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadRectificationV4Job(jobId: string): Promise<RectificationV4Job> {
|
||||
const schema = z.object({ job: rectificationV4JobSchema }).strict();
|
||||
return (await json(await fetch(`/api/rectification/v4/jobs/${jobId}`, { cache: "no-store" }), schema)).job;
|
||||
}
|
||||
|
||||
|
||||
const handoffClaimActions = new Map<string, string>();
|
||||
|
||||
async function handoffPayload(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return null;
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
async function handoffPost(body: Readonly<Record<string, unknown>>): Promise<RectificationV4Handoff> {
|
||||
const serialized = JSON.stringify(body);
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch("/api/rectification/v4/handoff", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: serialized,
|
||||
});
|
||||
const payload = await handoffPayload(response);
|
||||
if (!response.ok) {
|
||||
throw new RectificationV4RequestError(
|
||||
response.status,
|
||||
errorMessage(payload, "暂时无法保存或继续原问题,请稍后重试。"),
|
||||
);
|
||||
}
|
||||
return rectificationV4HandoffSchema.parse(payload);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error instanceof RectificationV4RequestError || attempt > 0) throw error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function loadRectificationV4Handoff(caseId?: string): Promise<RectificationV4Handoff | null> {
|
||||
const query = caseId ? `?caseId=${encodeURIComponent(caseId)}` : "";
|
||||
const response = await fetch(`/api/rectification/v4/handoff${query}`, { cache: "no-store" });
|
||||
const payload = await handoffPayload(response);
|
||||
if (response.status === 204) return null;
|
||||
if (!response.ok) {
|
||||
throw new RectificationV4RequestError(
|
||||
response.status,
|
||||
errorMessage(payload, "暂时无法读取原问题,请稍后再试。"),
|
||||
);
|
||||
}
|
||||
return rectificationV4HandoffSchema.parse(payload);
|
||||
}
|
||||
|
||||
export function attachRectificationV4Question(input: Readonly<{
|
||||
caseId: string;
|
||||
caseVersion: number;
|
||||
question: string;
|
||||
actionId: string;
|
||||
}>): Promise<RectificationV4Handoff> {
|
||||
return handoffPost({ type: "attach", ...input, question: input.question.trim() });
|
||||
}
|
||||
|
||||
export async function claimRectificationV4Handoff(input: Readonly<{
|
||||
caseId: string;
|
||||
caseVersion: number;
|
||||
question: string;
|
||||
}>): Promise<RectificationV4Handoff & Readonly<{ claimActionId: string }>> {
|
||||
const identity = JSON.stringify([input.caseId, input.caseVersion, input.question.trim()]);
|
||||
const actionId = handoffClaimActions.get(identity) ?? globalThis.crypto.randomUUID();
|
||||
handoffClaimActions.set(identity, actionId);
|
||||
const result = await handoffPost({ type: "claim", ...input, actionId, question: input.question.trim() });
|
||||
if (result.status !== "claimed") handoffClaimActions.delete(identity);
|
||||
return Object.freeze({ ...result, claimActionId: actionId });
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const rectificationV4Protocol = "rectification-evidence-v4" as const;
|
||||
export const rectificationV4AlgorithmVersion = "rectification-v4-range-scoring-1" as const;
|
||||
|
||||
export const rectificationV4CaseStatusSchema = z.enum([
|
||||
"awaiting_answer",
|
||||
"processing",
|
||||
"range_ready",
|
||||
"paused",
|
||||
"abandoned",
|
||||
]);
|
||||
export type RectificationV4CaseStatus = z.infer<typeof rectificationV4CaseStatusSchema>;
|
||||
|
||||
export const rectificationV4PhaseSchema = z.enum([
|
||||
"collecting_evidence",
|
||||
"extracting_evidence",
|
||||
"scoring_candidates",
|
||||
"checking_robustness",
|
||||
"planning_question",
|
||||
"complete",
|
||||
]);
|
||||
export type RectificationV4Phase = z.infer<typeof rectificationV4PhaseSchema>;
|
||||
|
||||
export const evidenceDomainSchema = z.enum([
|
||||
"education",
|
||||
"relocation",
|
||||
"relationship",
|
||||
"career",
|
||||
"finance",
|
||||
"health_pressure",
|
||||
"family",
|
||||
"other",
|
||||
]);
|
||||
export type EvidenceDomain = z.infer<typeof evidenceDomainSchema>;
|
||||
|
||||
export const eventKindSchema = z.enum([
|
||||
"education_milestone",
|
||||
"relocation",
|
||||
"relationship_start",
|
||||
"relationship_end",
|
||||
"career_change",
|
||||
"finance_change",
|
||||
"health_event",
|
||||
"family_event",
|
||||
"other",
|
||||
]);
|
||||
export type EventKind = z.infer<typeof eventKindSchema>;
|
||||
|
||||
export const datePrecisionSchema = z.enum(["day", "month", "quarter", "year", "range"]);
|
||||
export type DatePrecision = z.infer<typeof datePrecisionSchema>;
|
||||
|
||||
export const calendarDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
export const clockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
||||
|
||||
export const eventDateRangeSchema = z.object({
|
||||
start: calendarDateSchema,
|
||||
end: calendarDateSchema,
|
||||
precision: datePrecisionSchema,
|
||||
label: z.string().trim().min(1).max(80),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.start > value.end) {
|
||||
context.addIssue({ code: "custom", message: "event date range start must not exceed end" });
|
||||
}
|
||||
});
|
||||
export type EventDateRange = z.infer<typeof eventDateRangeSchema>;
|
||||
|
||||
export const scoreabilitySchema = z.enum(["scoreable", "context_only"]);
|
||||
export type Scoreability = z.infer<typeof scoreabilitySchema>;
|
||||
|
||||
export const lifeEventRevisionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
eventId: z.string().uuid(),
|
||||
revision: z.number().int().positive(),
|
||||
domain: evidenceDomainSchema,
|
||||
eventKind: eventKindSchema,
|
||||
summary: z.string().trim().min(1).max(1_000),
|
||||
rawText: z.string().trim().min(1).max(4_000),
|
||||
dateRange: eventDateRangeSchema,
|
||||
scoreability: scoreabilitySchema,
|
||||
supersedesRevisionId: z.string().uuid().nullable(),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type LifeEventRevision = z.infer<typeof lifeEventRevisionSchema>;
|
||||
|
||||
export const calculationSpecSchema = z.object({
|
||||
version: z.literal("rectification-calculation-spec-v4"),
|
||||
birthDate: calendarDateSchema,
|
||||
candidateRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(),
|
||||
latitude: z.number().finite().min(-90).max(90),
|
||||
longitude: z.number().finite().min(-180).max(180),
|
||||
timezoneOffsetHours: z.number().finite().min(-14).max(14),
|
||||
ayanamsa: z.literal("lahiri"),
|
||||
nodeMode: z.literal("mean"),
|
||||
minuteStep: z.literal(1),
|
||||
}).strict();
|
||||
export type CalculationSpec = z.infer<typeof calculationSpecSchema>;
|
||||
|
||||
export const candidateMinuteSchema = z.object({
|
||||
time: clockTimeSchema,
|
||||
score: z.number().finite(),
|
||||
supportingEventIds: z.array(z.string().uuid()),
|
||||
conflictingEventIds: z.array(z.string().uuid()),
|
||||
}).strict();
|
||||
export type CandidateMinute = z.infer<typeof candidateMinuteSchema>;
|
||||
|
||||
export const candidateClusterSchema = z.object({
|
||||
rank: z.number().int().positive(),
|
||||
startTime: clockTimeSchema,
|
||||
endTime: clockTimeSchema,
|
||||
representativeTime: clockTimeSchema,
|
||||
widthMinutes: z.number().int().positive(),
|
||||
peakScore: z.number().finite(),
|
||||
scoreMass: z.number().finite().nonnegative(),
|
||||
}).strict();
|
||||
export type CandidateCluster = z.infer<typeof candidateClusterSchema>;
|
||||
|
||||
export const robustnessSchema = z.object({
|
||||
neighborSupportMinutes: z.number().int().nonnegative(),
|
||||
leaveOneOutRetentionRate: z.number().finite().min(0).max(1),
|
||||
dateSensitivityRetentionRate: z.number().finite().min(0).max(1),
|
||||
calculationSpecHashMatched: z.boolean(),
|
||||
}).strict();
|
||||
export type Robustness = z.infer<typeof robustnessSchema>;
|
||||
|
||||
export const candidateSnapshotSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
caseId: z.string().uuid(),
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
algorithmVersion: z.literal(rectificationV4AlgorithmVersion),
|
||||
candidates: z.array(candidateMinuteSchema).min(1).max(1_440),
|
||||
clusters: z.array(candidateClusterSchema).max(20),
|
||||
robustness: robustnessSchema,
|
||||
canConfirmExactMinute: z.literal(false),
|
||||
canAcceptRange: z.boolean(),
|
||||
gateReasons: z.array(z.string().trim().min(1).max(120)).max(20),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type CandidateSnapshot = z.infer<typeof candidateSnapshotSchema>;
|
||||
|
||||
export const rectificationV4QuestionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
domain: evidenceDomainSchema,
|
||||
targetEventId: z.string().uuid().nullable(),
|
||||
prompt: z.string().trim().min(1).max(1_000),
|
||||
recallCost: z.enum(["low", "medium", "high"]),
|
||||
reason: z.string().trim().min(1).max(240),
|
||||
}).strict();
|
||||
export type RectificationV4Question = z.infer<typeof rectificationV4QuestionSchema>;
|
||||
|
||||
export const rectificationV4CaseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
protocol: z.literal(rectificationV4Protocol),
|
||||
version: z.number().int().nonnegative(),
|
||||
status: rectificationV4CaseStatusSchema,
|
||||
phase: rectificationV4PhaseSchema,
|
||||
calculationSpec: calculationSpecSchema,
|
||||
calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
currentQuestion: rectificationV4QuestionSchema.nullable(),
|
||||
latestSnapshot: candidateSnapshotSchema.nullable(),
|
||||
acceptedRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict().nullable(),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
updatedAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type RectificationV4Case = z.infer<typeof rectificationV4CaseSchema>;
|
||||
|
||||
export const createCaseRequestSchema = z.object({
|
||||
actionId: z.string().uuid(),
|
||||
uncertaintyMinutes: z.number().int().min(5).max(720).optional(),
|
||||
}).strict();
|
||||
|
||||
export const answerRequestSchema = z.object({
|
||||
actionId: z.string().uuid(),
|
||||
expectedCaseVersion: z.number().int().nonnegative(),
|
||||
answer: z.string().trim().min(1).max(4_000),
|
||||
}).strict();
|
||||
|
||||
export const reviseEventRequestSchema = z.object({
|
||||
actionId: z.string().uuid(),
|
||||
expectedCaseVersion: z.number().int().nonnegative(),
|
||||
domain: evidenceDomainSchema,
|
||||
eventKind: eventKindSchema,
|
||||
summary: z.string().trim().min(1).max(1_000),
|
||||
rawText: z.string().trim().min(1).max(4_000),
|
||||
dateRange: eventDateRangeSchema,
|
||||
scoreability: scoreabilitySchema.optional(),
|
||||
}).strict();
|
||||
|
||||
export const caseActionRequestSchema = z.object({
|
||||
actionId: z.string().uuid(),
|
||||
expectedCaseVersion: z.number().int().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
export const acceptRangeRequestSchema = caseActionRequestSchema.extend({
|
||||
startTime: clockTimeSchema,
|
||||
endTime: clockTimeSchema,
|
||||
}).strict();
|
||||
|
||||
export const rectificationV4JobSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
caseId: z.string().uuid(),
|
||||
status: z.enum(["pending", "processing", "completed", "failed", "stale"]),
|
||||
phase: rectificationV4PhaseSchema,
|
||||
expectedCaseVersion: z.number().int().nonnegative(),
|
||||
evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
errorCode: z.string().trim().min(1).max(120).nullable(),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
updatedAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type RectificationV4Job = z.infer<typeof rectificationV4JobSchema>;
|
||||
|
||||
export const rectificationV4ApiResponseSchema = z.object({
|
||||
case: rectificationV4CaseSchema,
|
||||
job: rectificationV4JobSchema.nullable(),
|
||||
events: z.array(lifeEventRevisionSchema),
|
||||
}).strict();
|
||||
export type RectificationV4ApiResponse = z.infer<typeof rectificationV4ApiResponseSchema>;
|
||||
|
||||
export const rectificationV4HandoffStatusSchema = z.enum([
|
||||
"pending",
|
||||
"claimed",
|
||||
"in_progress",
|
||||
"consumed",
|
||||
]);
|
||||
|
||||
export const rectificationV4HandoffSchema = z.object({
|
||||
protocol: z.literal(rectificationV4Protocol),
|
||||
caseId: z.string().uuid(),
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
questionFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
requestId: z.string().uuid(),
|
||||
status: rectificationV4HandoffStatusSchema,
|
||||
acceptedRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict().nullable(),
|
||||
}).strict();
|
||||
export type RectificationV4Handoff = z.infer<typeof rectificationV4HandoffSchema>;
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DatePrecision, EventDateRange } from "./contracts.ts";
|
||||
|
||||
const dayMs = 86_400_000;
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const value = new Date(Date.UTC(year, month - 1, day));
|
||||
if (value.getUTCFullYear() !== year || value.getUTCMonth() !== month - 1 || value.getUTCDate() !== day) {
|
||||
throw new Error("invalid_calendar_date");
|
||||
}
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function monthEnd(year: number, month: number): string {
|
||||
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function dateRangeFromDeclared(value: string, precision: Exclude<DatePrecision, "range">): EventDateRange {
|
||||
if (precision === "day") {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("invalid_day");
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const date = isoDate(year!, month!, day!);
|
||||
return { start: date, end: date, precision, label: value };
|
||||
}
|
||||
if (precision === "month") {
|
||||
if (!/^\d{4}-\d{2}$/.test(value)) throw new Error("invalid_month");
|
||||
const [year, month] = value.split("-").map(Number);
|
||||
const start = isoDate(year!, month!, 1);
|
||||
return { start, end: monthEnd(year!, month!), precision, label: value };
|
||||
}
|
||||
if (precision === "quarter") {
|
||||
const matched = /^(\d{4})-Q([1-4])$/.exec(value);
|
||||
if (!matched) throw new Error("invalid_quarter");
|
||||
const year = Number(matched[1]);
|
||||
const firstMonth = (Number(matched[2]) - 1) * 3 + 1;
|
||||
return {
|
||||
start: isoDate(year, firstMonth, 1),
|
||||
end: monthEnd(year, firstMonth + 2),
|
||||
precision,
|
||||
label: value,
|
||||
};
|
||||
}
|
||||
if (!/^\d{4}$/.test(value)) throw new Error("invalid_year");
|
||||
const year = Number(value);
|
||||
return { start: isoDate(year, 1, 1), end: isoDate(year, 12, 31), precision, label: value };
|
||||
}
|
||||
|
||||
export function explicitDateRange(start: string, end: string, label = `${start}–${end}`): EventDateRange {
|
||||
const normalizedStart = dateRangeFromDeclared(start, "day").start;
|
||||
const normalizedEnd = dateRangeFromDeclared(end, "day").end;
|
||||
if (normalizedStart > normalizedEnd) throw new Error("invalid_date_range");
|
||||
return { start: normalizedStart, end: normalizedEnd, precision: "range", label };
|
||||
}
|
||||
|
||||
export function sampledDates(range: EventDateRange): readonly string[] {
|
||||
if (range.start === range.end) return [range.start];
|
||||
const start = Date.parse(`${range.start}T00:00:00Z`);
|
||||
const end = Date.parse(`${range.end}T00:00:00Z`);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) throw new Error("invalid_date_range");
|
||||
|
||||
const values = new Set<string>([range.start, range.end]);
|
||||
for (let cursor = start; cursor <= end; cursor += 31 * dayMs) {
|
||||
const current = new Date(cursor);
|
||||
values.add(new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), 1)).toISOString().slice(0, 10));
|
||||
}
|
||||
return [...values].filter((value) => value >= range.start && value <= range.end).sort();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CandidateCluster, Robustness } from "./contracts.ts";
|
||||
|
||||
export type DecisionGateResult = Readonly<{
|
||||
canConfirmExactMinute: false;
|
||||
canAcceptRange: boolean;
|
||||
reasons: readonly string[];
|
||||
}>;
|
||||
|
||||
export function evaluateDecisionGate(input: {
|
||||
readonly clusters: readonly CandidateCluster[];
|
||||
readonly robustness: Robustness;
|
||||
readonly scoreableEventCount: number;
|
||||
readonly scoreableDomainCount: number;
|
||||
}): DecisionGateResult {
|
||||
const reasons: string[] = [];
|
||||
const primary = input.clusters[0];
|
||||
if (!primary) reasons.push("no_primary_candidate_cluster");
|
||||
if (input.scoreableEventCount < 5) reasons.push("insufficient_scoreable_events");
|
||||
if (input.scoreableDomainCount < 3) reasons.push("insufficient_scoreable_domains");
|
||||
if ((primary?.widthMinutes ?? 0) < 2) reasons.push("single_minute_cluster_not_acceptable");
|
||||
if ((primary?.widthMinutes ?? Number.POSITIVE_INFINITY) > 15) reasons.push("primary_cluster_too_wide");
|
||||
if (input.robustness.neighborSupportMinutes < 2) reasons.push("neighbor_support_not_passed");
|
||||
if (input.robustness.leaveOneOutRetentionRate < 0.8) reasons.push("leave_one_out_not_stable");
|
||||
if (input.robustness.dateSensitivityRetentionRate < 0.8) reasons.push("date_range_sensitivity_not_stable");
|
||||
if (!input.robustness.calculationSpecHashMatched) reasons.push("calculation_spec_changed");
|
||||
return { canConfirmExactMinute: false, canAcceptRange: reasons.length === 0, reasons };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { EvidenceDomain, EventKind, LifeEventRevision, Scoreability } from "./contracts.ts";
|
||||
|
||||
export type DomainScorerPolicy = Readonly<{
|
||||
domain: EvidenceDomain;
|
||||
defaultScoreability: Scoreability;
|
||||
supportedKinds: readonly EventKind[];
|
||||
techniqueLayers: readonly string[];
|
||||
}>;
|
||||
|
||||
export const domainScorerRegistry: Readonly<Record<EvidenceDomain, DomainScorerPolicy>> = {
|
||||
education: { domain: "education", defaultScoreability: "scoreable", supportedKinds: ["education_milestone"], techniqueLayers: ["D24", "vimshottari", "narayana"] },
|
||||
relocation: { domain: "relocation", defaultScoreability: "scoreable", supportedKinds: ["relocation"], techniqueLayers: ["D4", "vimshottari", "narayana"] },
|
||||
relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_end"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] },
|
||||
career: { domain: "career", defaultScoreability: "scoreable", supportedKinds: ["career_change"], techniqueLayers: ["D10", "A10", "vimshottari", "narayana"] },
|
||||
finance: { domain: "finance", defaultScoreability: "scoreable", supportedKinds: ["finance_change"], techniqueLayers: ["D2", "D11", "vimshottari", "narayana"] },
|
||||
health_pressure: { domain: "health_pressure", defaultScoreability: "scoreable", supportedKinds: ["health_event"], techniqueLayers: ["D30", "vimshottari", "narayana"] },
|
||||
family: { domain: "family", defaultScoreability: "context_only", supportedKinds: ["family_event"], techniqueLayers: [] },
|
||||
other: { domain: "other", defaultScoreability: "context_only", supportedKinds: ["other"], techniqueLayers: [] },
|
||||
};
|
||||
|
||||
export function scoreabilityFor(domain: EvidenceDomain): Scoreability {
|
||||
return domainScorerRegistry[domain].defaultScoreability;
|
||||
}
|
||||
|
||||
export function assertScorerSupports(event: Pick<LifeEventRevision, "domain" | "eventKind" | "scoreability">): void {
|
||||
const policy = domainScorerRegistry[event.domain];
|
||||
if (event.scoreability === "scoreable" && !policy.supportedKinds.includes(event.eventKind)) {
|
||||
throw new Error("unsupported_event_kind_for_domain");
|
||||
}
|
||||
if (event.scoreability === "scoreable" && policy.techniqueLayers.length === 0) {
|
||||
throw new Error("domain_not_validated_for_scoring");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { LifeEventRevision } from "./contracts.ts";
|
||||
import { assertScorerSupports, scoreabilityFor } from "./domain-scorers.ts";
|
||||
|
||||
export type NewEventRevision = Omit<LifeEventRevision, "id" | "revision" | "supersedesRevisionId" | "createdAt" | "scoreability"> & {
|
||||
readonly scoreability?: LifeEventRevision["scoreability"];
|
||||
};
|
||||
|
||||
export function latestEventRevisions(revisions: readonly LifeEventRevision[]): readonly LifeEventRevision[] {
|
||||
const latest = new Map<string, LifeEventRevision>();
|
||||
for (const revision of revisions) {
|
||||
const current = latest.get(revision.eventId);
|
||||
if (!current || revision.revision > current.revision) latest.set(revision.eventId, revision);
|
||||
}
|
||||
return [...latest.values()].sort((left, right) => left.eventId.localeCompare(right.eventId));
|
||||
}
|
||||
|
||||
export function appendEventRevision(
|
||||
revisions: readonly LifeEventRevision[],
|
||||
input: NewEventRevision,
|
||||
options: { readonly now?: Date; readonly id?: string } = {},
|
||||
): LifeEventRevision {
|
||||
const prior = revisions.filter((value) => value.eventId === input.eventId)
|
||||
.sort((left, right) => right.revision - left.revision)[0] ?? null;
|
||||
const revision: LifeEventRevision = {
|
||||
...input,
|
||||
id: options.id ?? randomUUID(),
|
||||
revision: (prior?.revision ?? 0) + 1,
|
||||
scoreability: input.scoreability ?? scoreabilityFor(input.domain),
|
||||
supersedesRevisionId: prior?.id ?? null,
|
||||
createdAt: (options.now ?? new Date()).toISOString(),
|
||||
};
|
||||
assertScorerSupports(revision);
|
||||
return revision;
|
||||
}
|
||||
|
||||
export function scoreableEvents(revisions: readonly LifeEventRevision[]): readonly LifeEventRevision[] {
|
||||
return latestEventRevisions(revisions).filter((event) => event.scoreability === "scoreable");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { extractLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts";
|
||||
import type { EventKind, EvidenceDomain, LifeEventRevision } from "./contracts.ts";
|
||||
import { dateRangeFromDeclared } from "./date-range.ts";
|
||||
import { appendEventRevision, latestEventRevisions } from "./evidence-ledger.ts";
|
||||
|
||||
function eventKind(domain: EvidenceDomain, summary: string): EventKind {
|
||||
if (domain === "relationship") {
|
||||
return /分手|离婚|结束|断联|分开|破裂/.test(summary) ? "relationship_end" : "relationship_start";
|
||||
}
|
||||
switch (domain) {
|
||||
case "education": return "education_milestone";
|
||||
case "relocation": return "relocation";
|
||||
case "career": return "career_change";
|
||||
case "finance": return "finance_change";
|
||||
case "health_pressure": return "health_event";
|
||||
case "family": return "family_event";
|
||||
case "other": return "other";
|
||||
}
|
||||
}
|
||||
|
||||
export function extractV4EventRevisions(input: {
|
||||
readonly answer: string;
|
||||
readonly sourceTurnId: string;
|
||||
readonly asOfDate: string;
|
||||
readonly existing: readonly LifeEventRevision[];
|
||||
readonly targetEventId?: string | null;
|
||||
readonly now?: Date;
|
||||
}): readonly LifeEventRevision[] {
|
||||
const extracted = extractLifeEventEvidence({
|
||||
rawText: input.answer,
|
||||
sourceTurnId: input.sourceTurnId,
|
||||
asOfDate: input.asOfDate,
|
||||
});
|
||||
const target = input.targetEventId
|
||||
? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null
|
||||
: null;
|
||||
if (input.targetEventId) {
|
||||
if (!target) throw new Error("rectification_v4_target_event_not_found");
|
||||
const event = extracted.find((value) => value.dateValue && value.datePrecision !== "unknown");
|
||||
if (!event?.dateValue || event.datePrecision === "unknown") return [];
|
||||
const dateRange = dateRangeFromDeclared(event.dateValue, event.datePrecision);
|
||||
if (dateRange.start > input.asOfDate) return [];
|
||||
return [appendEventRevision(input.existing, {
|
||||
eventId: target.eventId,
|
||||
domain: target.domain,
|
||||
eventKind: target.eventKind,
|
||||
summary: target.summary,
|
||||
rawText: input.answer,
|
||||
dateRange,
|
||||
scoreability: target.scoreability,
|
||||
}, { id: event.id, now: input.now })];
|
||||
}
|
||||
return extracted.flatMap((event) => {
|
||||
if (!event.dateValue || event.datePrecision === "unknown") return [];
|
||||
const domain = event.domain as EvidenceDomain;
|
||||
return [appendEventRevision(input.existing, {
|
||||
eventId: event.id,
|
||||
domain,
|
||||
eventKind: eventKind(domain, event.eventSummary),
|
||||
summary: event.eventSummary,
|
||||
rawText: event.rawText,
|
||||
dateRange: dateRangeFromDeclared(event.dateValue, event.datePrecision),
|
||||
}, { id: event.id, now: input.now })];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CalculationSpec, LifeEventRevision } from "./contracts.ts";
|
||||
import { latestEventRevisions } from "./evidence-ledger.ts";
|
||||
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonical(item)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hash(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
|
||||
}
|
||||
|
||||
export function calculationSpecHash(spec: CalculationSpec): string {
|
||||
return hash(spec);
|
||||
}
|
||||
|
||||
export function evidenceSetHash(revisions: readonly LifeEventRevision[]): string {
|
||||
return hash(latestEventRevisions(revisions).map((event) => ({
|
||||
eventId: event.eventId,
|
||||
revision: event.revision,
|
||||
domain: event.domain,
|
||||
eventKind: event.eventKind,
|
||||
dateRange: event.dateRange,
|
||||
scoreability: event.scoreability,
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
RectificationHandoffServiceError,
|
||||
type RectificationV4HandoffService,
|
||||
} from "../rectification-handoff-service.ts";
|
||||
|
||||
const identity = {
|
||||
caseId: z.string().uuid(),
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
actionId: z.string().uuid(),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
} as const;
|
||||
|
||||
const commandSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("attach"), ...identity }).strict(),
|
||||
z.object({ type: z.literal("claim"), ...identity }).strict(),
|
||||
]);
|
||||
|
||||
export type RectificationV4HandoffRouteDependencies = Readonly<{
|
||||
authenticate(): Promise<Readonly<{ userId: string }> | null>;
|
||||
service(): RectificationV4HandoffService;
|
||||
}>;
|
||||
|
||||
function failure(error: unknown) {
|
||||
if (error instanceof RectificationHandoffServiceError) {
|
||||
if (error.code === "not_found") {
|
||||
return Response.json({ code: "handoff_not_found", message: "没有找到可继续的原问题。" }, { status: 404 });
|
||||
}
|
||||
if (error.code === "stale") {
|
||||
return Response.json({ code: "stale_case", message: "校正结果已经更新,请刷新后重试。" }, { status: 409 });
|
||||
}
|
||||
if (error.code === "conflict") {
|
||||
return Response.json({ code: "handoff_conflict", message: "原问题状态已经变化,请刷新后查看。" }, { status: 409 });
|
||||
}
|
||||
}
|
||||
return Response.json({ code: "handoff_unavailable", message: "暂时无法保存或继续原问题,请稍后重试。" }, { status: 503 });
|
||||
}
|
||||
|
||||
export function createRectificationV4HandoffHandlers(
|
||||
dependencies: RectificationV4HandoffRouteDependencies,
|
||||
) {
|
||||
return Object.freeze({
|
||||
async get(request: Request) {
|
||||
const authenticated = await dependencies.authenticate();
|
||||
if (!authenticated) {
|
||||
return Response.json({ code: "authentication_required", message: "登录后才能继续原问题。" }, { status: 401 });
|
||||
}
|
||||
const caseId = new URL(request.url).searchParams.get("caseId") ?? undefined;
|
||||
if (caseId && !z.string().uuid().safeParse(caseId).success) {
|
||||
return Response.json({ code: "invalid_case", message: "生时校正记录格式不正确。" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const handoff = await dependencies.service().load({ userId: authenticated.userId, caseId });
|
||||
return handoff ? Response.json(handoff) : new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
},
|
||||
|
||||
async post(request: Request) {
|
||||
const authenticated = await dependencies.authenticate();
|
||||
if (!authenticated) {
|
||||
return Response.json({ code: "authentication_required", message: "登录后才能保存或继续原问题。" }, { status: 401 });
|
||||
}
|
||||
const parsed = commandSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return Response.json({ code: "invalid_command", message: "原问题交接请求格式不正确。" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const service = dependencies.service();
|
||||
const input = { userId: authenticated.userId, ...parsed.data };
|
||||
return Response.json(parsed.data.type === "attach"
|
||||
? await service.attach(input)
|
||||
: await service.claim(input));
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type {
|
||||
LifeEventRevision,
|
||||
RectificationV4Case,
|
||||
RectificationV4Job,
|
||||
} from "./contracts.ts";
|
||||
import type {
|
||||
ClaimedRectificationV4Job,
|
||||
CompleteRectificationV4JobInput,
|
||||
RectificationV4Store,
|
||||
RectificationV4Turn,
|
||||
} from "./store.ts";
|
||||
import { RectificationV4StoreError } from "./store.ts";
|
||||
import { evidenceSetHash } from "./fingerprints.ts";
|
||||
|
||||
export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
readonly cases: Map<string, RectificationV4Case>;
|
||||
readonly jobs: Map<string, RectificationV4Job & { workerId: string | null; turnId: string }>;
|
||||
} {
|
||||
const cases = new Map<string, RectificationV4Case>();
|
||||
const events = new Map<string, LifeEventRevision[]>();
|
||||
const turns = new Map<string, RectificationV4Turn>();
|
||||
const jobs = new Map<string, RectificationV4Job & { workerId: string | null; turnId: string }>();
|
||||
const actionResults = new Map<string, { caseId: string; jobId: string | null }>();
|
||||
|
||||
function owned(userId: string, caseId: string): RectificationV4Case {
|
||||
const value = cases.get(caseId);
|
||||
if (!value || value.userId !== userId) throw new RectificationV4StoreError("not_found");
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
cases,
|
||||
jobs,
|
||||
async findActiveCase(userId) {
|
||||
return [...cases.values()].find((value) => value.userId === userId
|
||||
&& value.status !== "abandoned" && value.acceptedRange === null) ?? null;
|
||||
},
|
||||
async loadCase(userId, caseId) {
|
||||
const value = cases.get(caseId);
|
||||
return value?.userId === userId ? value : null;
|
||||
},
|
||||
async loadEvents(userId, caseId) {
|
||||
owned(userId, caseId);
|
||||
return events.get(caseId) ?? [];
|
||||
},
|
||||
async createCase(input) {
|
||||
const replay = actionResults.get(`${input.case.userId}:${input.actionId}`);
|
||||
if (replay) return owned(input.case.userId, replay.caseId);
|
||||
const active = [...cases.values()].find((value) => value.userId === input.case.userId
|
||||
&& value.status !== "abandoned" && value.acceptedRange === null);
|
||||
if (active?.calculationSpecHash === input.case.calculationSpecHash) {
|
||||
actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: active.id, jobId: null });
|
||||
return active;
|
||||
}
|
||||
if (active) {
|
||||
cases.set(active.id, { ...active, status: "abandoned", phase: "complete", currentQuestion: null, updatedAt: input.case.createdAt });
|
||||
for (const [jobId, job] of jobs) {
|
||||
if (job.caseId === active.id && ["pending", "processing"].includes(job.status)) {
|
||||
jobs.set(jobId, { ...job, status: "stale", updatedAt: input.case.createdAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
cases.set(input.case.id, input.case);
|
||||
events.set(input.case.id, []);
|
||||
actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: input.case.id, jobId: null });
|
||||
return input.case;
|
||||
},
|
||||
async submitAnswer(input) {
|
||||
const key = `${input.userId}:${input.actionId}`;
|
||||
const replay = actionResults.get(key);
|
||||
if (replay?.jobId) return { case: owned(input.userId, replay.caseId), job: jobs.get(replay.jobId)! };
|
||||
const current = owned(input.userId, input.caseId);
|
||||
if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
|
||||
if (!["awaiting_answer", "range_ready"].includes(current.status)) throw new RectificationV4StoreError("invalid_state");
|
||||
const version = current.version + 1;
|
||||
const updated: RectificationV4Case = {
|
||||
...current, version, status: "processing", phase: "extracting_evidence", currentQuestion: null, updatedAt: input.now,
|
||||
};
|
||||
const turn: RectificationV4Turn = {
|
||||
id: input.turnId,
|
||||
caseId: input.caseId,
|
||||
caseVersion: version,
|
||||
questionId: input.question.id,
|
||||
questionDomain: input.question.domain,
|
||||
questionTargetEventId: input.question.targetEventId,
|
||||
question: input.question.prompt,
|
||||
answer: input.answer,
|
||||
actionId: input.actionId,
|
||||
createdAt: input.now,
|
||||
};
|
||||
const job: RectificationV4Job & { workerId: string | null; turnId: string } = {
|
||||
id: input.jobId,
|
||||
caseId: input.caseId,
|
||||
status: "pending",
|
||||
phase: "extracting_evidence",
|
||||
expectedCaseVersion: version,
|
||||
evidenceSetHash: current.evidenceSetHash,
|
||||
calculationSpecHash: current.calculationSpecHash,
|
||||
errorCode: null,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
workerId: null,
|
||||
turnId: turn.id,
|
||||
};
|
||||
cases.set(current.id, updated);
|
||||
turns.set(turn.id, turn);
|
||||
jobs.set(job.id, job);
|
||||
actionResults.set(key, { caseId: current.id, jobId: job.id });
|
||||
return { case: updated, job };
|
||||
},
|
||||
async reviseEvent(input) {
|
||||
const key = `${input.userId}:${input.actionId}`;
|
||||
const replay = actionResults.get(key);
|
||||
if (replay?.jobId) return { case: owned(input.userId, replay.caseId), job: jobs.get(replay.jobId)! };
|
||||
const current = owned(input.userId, input.caseId);
|
||||
if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
|
||||
const nextEvents = [...(events.get(current.id) ?? []), input.revision];
|
||||
events.set(current.id, nextEvents);
|
||||
const version = current.version + 1;
|
||||
const updated = {
|
||||
...current, version, status: "processing" as const, phase: "scoring_candidates" as const,
|
||||
evidenceSetHash: evidenceSetHash(nextEvents), currentQuestion: null, updatedAt: input.now,
|
||||
};
|
||||
const turn: RectificationV4Turn = {
|
||||
id: input.revision.id, caseId: current.id, caseVersion: version, questionId: null, questionDomain: null,
|
||||
questionTargetEventId: null, question: "修订事件", answer: "", actionId: input.actionId, createdAt: input.now,
|
||||
};
|
||||
const job = {
|
||||
id: input.jobId, caseId: current.id, status: "pending" as const, phase: "scoring_candidates" as const,
|
||||
expectedCaseVersion: version, evidenceSetHash: updated.evidenceSetHash,
|
||||
calculationSpecHash: current.calculationSpecHash, errorCode: null, createdAt: input.now, updatedAt: input.now,
|
||||
workerId: null, turnId: turn.id,
|
||||
};
|
||||
cases.set(current.id, updated);
|
||||
turns.set(turn.id, turn);
|
||||
jobs.set(job.id, job);
|
||||
actionResults.set(key, { caseId: current.id, jobId: job.id });
|
||||
return { case: updated, job };
|
||||
},
|
||||
async transitionCase(input) {
|
||||
const key = `${input.userId}:${input.actionId}`;
|
||||
const replay = actionResults.get(key);
|
||||
if (replay) return owned(input.userId, replay.caseId);
|
||||
const current = owned(input.userId, input.caseId);
|
||||
if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
|
||||
const updated = {
|
||||
...current,
|
||||
version: current.version + 1,
|
||||
status: input.status,
|
||||
phase: input.phase,
|
||||
acceptedRange: input.acceptedRange === undefined ? current.acceptedRange : input.acceptedRange,
|
||||
updatedAt: input.now,
|
||||
};
|
||||
cases.set(current.id, updated);
|
||||
actionResults.set(key, { caseId: current.id, jobId: null });
|
||||
return updated;
|
||||
},
|
||||
async loadJob(userId, jobId) {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job) return null;
|
||||
owned(userId, job.caseId);
|
||||
return job;
|
||||
},
|
||||
async updateJobPhase(input) {
|
||||
const job = jobs.get(input.jobId);
|
||||
if (!job || job.workerId !== input.workerId || job.status !== "processing") throw new RectificationV4StoreError("lease_lost");
|
||||
jobs.set(job.id, { ...job, phase: input.phase, updatedAt: input.now });
|
||||
const current = cases.get(job.caseId)!;
|
||||
cases.set(current.id, { ...current, phase: input.phase, updatedAt: input.now });
|
||||
},
|
||||
async claimNextJob(workerId, now): Promise<ClaimedRectificationV4Job | null> {
|
||||
const job = [...jobs.values()].find((value) => value.status === "pending");
|
||||
if (!job) return null;
|
||||
const claimed = { ...job, status: "processing" as const, workerId, updatedAt: now };
|
||||
jobs.set(job.id, claimed);
|
||||
const caseValue = cases.get(job.caseId)!;
|
||||
return {
|
||||
job: claimed,
|
||||
case: caseValue,
|
||||
turn: turns.get(job.turnId)!,
|
||||
events: events.get(job.caseId) ?? [],
|
||||
attemptedRefinementEventIds: [...new Set(
|
||||
[...turns.values()]
|
||||
.filter((turn) => turn.caseId === job.caseId && turn.questionTargetEventId)
|
||||
.map((turn) => turn.questionTargetEventId!),
|
||||
)],
|
||||
};
|
||||
},
|
||||
async completeJob(input: CompleteRectificationV4JobInput, now) {
|
||||
const job = jobs.get(input.jobId);
|
||||
if (!job || job.workerId !== input.workerId || job.status !== "processing") throw new RectificationV4StoreError("lease_lost");
|
||||
const current = cases.get(job.caseId)!;
|
||||
if (current.version !== input.expectedCaseVersion
|
||||
|| current.evidenceSetHash !== input.inputEvidenceSetHash
|
||||
|| current.calculationSpecHash !== input.calculationSpecHash) throw new RectificationV4StoreError("stale_job");
|
||||
const nextEvents = [...(events.get(current.id) ?? []), ...input.newEventRevisions];
|
||||
events.set(current.id, nextEvents);
|
||||
const updated: RectificationV4Case = {
|
||||
...current,
|
||||
version: current.version + 1,
|
||||
evidenceSetHash: input.outputEvidenceSetHash,
|
||||
latestSnapshot: input.snapshot,
|
||||
currentQuestion: input.nextQuestion,
|
||||
status: input.status,
|
||||
phase: input.phase,
|
||||
updatedAt: now,
|
||||
};
|
||||
cases.set(current.id, updated);
|
||||
jobs.set(job.id, { ...job, status: "completed", phase: input.phase, updatedAt: now });
|
||||
return updated;
|
||||
},
|
||||
async failJob(input) {
|
||||
const job = jobs.get(input.jobId);
|
||||
if (!job || job.workerId !== input.workerId) throw new RectificationV4StoreError("lease_lost");
|
||||
jobs.set(job.id, { ...job, status: "failed", errorCode: input.errorCode, updatedAt: input.now });
|
||||
const current = cases.get(job.caseId);
|
||||
if (current?.version === input.expectedCaseVersion) {
|
||||
cases.set(current.id, {
|
||||
...current, status: "awaiting_answer", phase: "collecting_evidence",
|
||||
currentQuestion: input.restoreQuestion, updatedAt: input.now,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { EvidenceDomain, LifeEventRevision, RectificationV4Question } from "./contracts.ts";
|
||||
import { scoreableEvents } from "./evidence-ledger.ts";
|
||||
|
||||
const domainOrder: readonly EvidenceDomain[] = [
|
||||
"education", "relocation", "relationship", "career", "finance", "health_pressure", "family",
|
||||
];
|
||||
const recallCost: Readonly<Record<EvidenceDomain, number>> = {
|
||||
education: 1, relocation: 1, relationship: 1, career: 1, finance: 2, health_pressure: 2, family: 2, other: 3,
|
||||
};
|
||||
const prompts: Readonly<Record<EvidenceDomain, string>> = {
|
||||
education: "请说一件你记得最清楚的升学、复读、转学或毕业事件,并给出尽可能准确的年月。",
|
||||
relocation: "请说一次影响较大的搬家或长期迁居,并给出尽可能准确的年月。",
|
||||
relationship: "请说一段重要关系明确开始或结束的时间;开始和结束请分开说。",
|
||||
career: "请说一次明确的入职、离职、转行或职责突变,并给出尽可能准确的年月。",
|
||||
finance: "请说一次明显的收入、负债或资产变化,并给出尽可能准确的年月。",
|
||||
health_pressure: "请说一次明确的疾病、手术、事故或长期压力起点,并给出尽可能准确的年月。",
|
||||
family: "请说一件对你影响很大的家庭事件和时间;这一类先作为背景,不直接参与评分。",
|
||||
other: "请再补充一件日期明确、对人生方向影响较大的事件;如果暂时想不到,也可以回复“暂停”。",
|
||||
};
|
||||
|
||||
function refinementQuestion(event: LifeEventRevision, id?: string): RectificationV4Question {
|
||||
return {
|
||||
id: id ?? randomUUID(),
|
||||
domain: event.domain,
|
||||
targetEventId: event.eventId,
|
||||
prompt: `你之前提到“${event.summary.slice(0, 120)}”,目前时间是${event.dateRange.label}。如果记得,请补充更具体的日期;不记得可以回复“跳过”。`,
|
||||
recallCost: "medium",
|
||||
reason: "缩小已有事件的日期范围,用于检验候选时间对日期误差是否稳定。",
|
||||
};
|
||||
}
|
||||
|
||||
export function planNextQuestion(input: {
|
||||
readonly askedDomains: readonly EvidenceDomain[];
|
||||
readonly coveredDomains: readonly EvidenceDomain[];
|
||||
readonly candidateSplitByDomain?: Readonly<Partial<Record<EvidenceDomain, number>>>;
|
||||
readonly events?: readonly LifeEventRevision[];
|
||||
readonly attemptedRefinementEventIds?: readonly string[];
|
||||
readonly id?: string;
|
||||
}): RectificationV4Question {
|
||||
const asked = new Set(input.askedDomains);
|
||||
const covered = new Set(input.coveredDomains);
|
||||
const candidates = domainOrder.filter((domain) => !asked.has(domain));
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((left, right) => {
|
||||
const leftValue = (input.candidateSplitByDomain?.[left] ?? 0) + (covered.has(left) ? 0 : 1) - recallCost[left] * 0.1;
|
||||
const rightValue = (input.candidateSplitByDomain?.[right] ?? 0) + (covered.has(right) ? 0 : 1) - recallCost[right] * 0.1;
|
||||
return rightValue - leftValue || domainOrder.indexOf(left) - domainOrder.indexOf(right);
|
||||
});
|
||||
const domain = candidates[0]!;
|
||||
const cost = recallCost[domain] === 1 ? "low" : recallCost[domain] === 2 ? "medium" : "high";
|
||||
return {
|
||||
id: input.id ?? randomUUID(),
|
||||
domain,
|
||||
targetEventId: null,
|
||||
prompt: prompts[domain],
|
||||
recallCost: cost,
|
||||
reason: input.candidateSplitByDomain?.[domain]
|
||||
? "该领域最能区分当前候选时间,同时回忆成本较低。"
|
||||
: "先收集高回忆率、可核对日期的人生事件。",
|
||||
};
|
||||
}
|
||||
|
||||
const attempted = new Set(input.attemptedRefinementEventIds ?? []);
|
||||
const target = scoreableEvents(input.events ?? [])
|
||||
.filter((event) => event.dateRange.precision !== "day" && !attempted.has(event.eventId))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.eventId.localeCompare(right.eventId))[0];
|
||||
if (target) return refinementQuestion(target, input.id);
|
||||
|
||||
return {
|
||||
id: input.id ?? randomUUID(),
|
||||
domain: "other",
|
||||
targetEventId: null,
|
||||
prompt: prompts.other,
|
||||
recallCost: "high",
|
||||
reason: "现有事件仍不足以通过稳定性门槛,需要新的明确日期证据,或由用户主动暂停。",
|
||||
};
|
||||
}
|
||||
|
||||
export function openingQuestion(id?: string): RectificationV4Question {
|
||||
return planNextQuestion({ askedDomains: [], coveredDomains: [], id });
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
CandidateSnapshot,
|
||||
LifeEventRevision,
|
||||
RectificationV4Case,
|
||||
RectificationV4Job,
|
||||
RectificationV4Phase,
|
||||
RectificationV4Question,
|
||||
} from "./contracts.ts";
|
||||
|
||||
export type RectificationV4Turn = Readonly<{
|
||||
id: string;
|
||||
caseId: string;
|
||||
caseVersion: number;
|
||||
questionId: string | null;
|
||||
questionDomain: LifeEventRevision["domain"] | null;
|
||||
questionTargetEventId: string | null;
|
||||
question: string;
|
||||
answer: string;
|
||||
actionId: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
|
||||
export type ClaimedRectificationV4Job = Readonly<{
|
||||
job: RectificationV4Job;
|
||||
case: RectificationV4Case;
|
||||
turn: RectificationV4Turn;
|
||||
events: readonly LifeEventRevision[];
|
||||
attemptedRefinementEventIds: readonly string[];
|
||||
}>;
|
||||
|
||||
export type CompleteRectificationV4JobInput = Readonly<{
|
||||
workerId: string;
|
||||
jobId: string;
|
||||
expectedCaseVersion: number;
|
||||
inputEvidenceSetHash: string;
|
||||
outputEvidenceSetHash: string;
|
||||
calculationSpecHash: string;
|
||||
newEventRevisions: readonly LifeEventRevision[];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
nextQuestion: RectificationV4Question | null;
|
||||
status: RectificationV4Case["status"];
|
||||
phase: RectificationV4Phase;
|
||||
}>;
|
||||
|
||||
export interface RectificationV4Store {
|
||||
findActiveCase(userId: string): Promise<RectificationV4Case | null>;
|
||||
loadCase(userId: string, caseId: string): Promise<RectificationV4Case | null>;
|
||||
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
|
||||
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
|
||||
submitAnswer(input: {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly answer: string;
|
||||
readonly question: RectificationV4Question;
|
||||
readonly jobId: string;
|
||||
readonly turnId: string;
|
||||
readonly now: string;
|
||||
}): Promise<{ readonly case: RectificationV4Case; readonly job: RectificationV4Job }>;
|
||||
reviseEvent(input: {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly revision: LifeEventRevision;
|
||||
readonly jobId: string;
|
||||
readonly now: string;
|
||||
}): Promise<{ readonly case: RectificationV4Case; readonly job: RectificationV4Job }>;
|
||||
transitionCase(input: {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly status: RectificationV4Case["status"];
|
||||
readonly phase: RectificationV4Phase;
|
||||
readonly acceptedRange?: { readonly start: string; readonly end: string } | null;
|
||||
readonly now: string;
|
||||
}): Promise<RectificationV4Case>;
|
||||
loadJob(userId: string, jobId: string): Promise<RectificationV4Job | null>;
|
||||
updateJobPhase(input: { readonly workerId: string; readonly jobId: string; readonly phase: RectificationV4Phase; readonly now: string }): Promise<void>;
|
||||
claimNextJob(workerId: string, now: string): Promise<ClaimedRectificationV4Job | null>;
|
||||
completeJob(input: CompleteRectificationV4JobInput, now: string): Promise<RectificationV4Case>;
|
||||
failJob(input: {
|
||||
readonly workerId: string;
|
||||
readonly jobId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly errorCode: string;
|
||||
readonly restoreQuestion: RectificationV4Question | null;
|
||||
readonly now: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export class RectificationV4StoreError extends Error {
|
||||
readonly name = "RectificationV4StoreError";
|
||||
constructor(readonly code: "not_found" | "stale_version" | "invalid_state" | "stale_job" | "lease_lost") {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
candidateSnapshotSchema,
|
||||
lifeEventRevisionSchema,
|
||||
rectificationV4CaseSchema,
|
||||
rectificationV4JobSchema,
|
||||
type CandidateSnapshot,
|
||||
type LifeEventRevision,
|
||||
type RectificationV4Case,
|
||||
type RectificationV4Job,
|
||||
} from "./contracts.ts";
|
||||
import type {
|
||||
ClaimedRectificationV4Job,
|
||||
CompleteRectificationV4JobInput,
|
||||
RectificationV4Store,
|
||||
RectificationV4Turn,
|
||||
} from "./store.ts";
|
||||
import { RectificationV4StoreError } from "./store.ts";
|
||||
import { evidenceSetHash } from "./fingerprints.ts";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function timestamp(value: unknown): string {
|
||||
return value instanceof Date ? value.toISOString() : String(value);
|
||||
}
|
||||
|
||||
function date(value: unknown): string {
|
||||
return value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
|
||||
}
|
||||
|
||||
function storeError(error: unknown): RectificationV4StoreError {
|
||||
const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
|
||||
if (message.includes("not_found")) return new RectificationV4StoreError("not_found");
|
||||
if (message.includes("stale_rectification_v4_case")) return new RectificationV4StoreError("stale_version");
|
||||
if (message.includes("stale_rectification_v4_job")) return new RectificationV4StoreError("stale_job");
|
||||
if (message.includes("lease_lost")) return new RectificationV4StoreError("lease_lost");
|
||||
return new RectificationV4StoreError("invalid_state");
|
||||
}
|
||||
|
||||
function snapshot(row: Row | null): CandidateSnapshot | null {
|
||||
if (!row) return null;
|
||||
return candidateSnapshotSchema.parse({
|
||||
id: row.id,
|
||||
caseId: row.case_id,
|
||||
caseVersion: Number(row.case_version),
|
||||
evidenceSetHash: row.evidence_set_hash,
|
||||
calculationSpecHash: row.calculation_spec_hash,
|
||||
algorithmVersion: row.algorithm_version,
|
||||
candidates: row.candidates,
|
||||
clusters: row.clusters,
|
||||
robustness: row.robustness,
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: row.can_accept_range,
|
||||
gateReasons: row.gate_reasons,
|
||||
createdAt: timestamp(row.created_at),
|
||||
});
|
||||
}
|
||||
|
||||
function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): RectificationV4Case {
|
||||
return rectificationV4CaseSchema.parse({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
protocol: row.protocol,
|
||||
version: Number(row.version),
|
||||
status: row.status,
|
||||
phase: row.phase,
|
||||
calculationSpec: row.calculation_spec,
|
||||
calculationSpecHash: row.calculation_spec_hash,
|
||||
evidenceSetHash: row.evidence_set_hash,
|
||||
currentQuestion: row.current_question,
|
||||
latestSnapshot,
|
||||
acceptedRange: row.accepted_range_start && row.accepted_range_end
|
||||
? { start: row.accepted_range_start, end: row.accepted_range_end }
|
||||
: null,
|
||||
createdAt: timestamp(row.created_at),
|
||||
updatedAt: timestamp(row.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
function eventRevision(row: Row): LifeEventRevision {
|
||||
return lifeEventRevisionSchema.parse({
|
||||
id: row.id,
|
||||
eventId: row.event_id,
|
||||
revision: Number(row.revision),
|
||||
domain: row.domain,
|
||||
eventKind: row.event_kind,
|
||||
summary: row.summary,
|
||||
rawText: row.raw_text,
|
||||
dateRange: {
|
||||
start: date(row.date_start),
|
||||
end: date(row.date_end),
|
||||
precision: row.date_precision,
|
||||
label: row.date_label,
|
||||
},
|
||||
scoreability: row.scoreability,
|
||||
supersedesRevisionId: row.supersedes_revision_id,
|
||||
createdAt: timestamp(row.created_at),
|
||||
});
|
||||
}
|
||||
|
||||
function jobValue(row: Row): RectificationV4Job {
|
||||
return rectificationV4JobSchema.parse({
|
||||
id: row.id,
|
||||
caseId: row.case_id,
|
||||
status: row.status,
|
||||
phase: row.phase,
|
||||
expectedCaseVersion: Number(row.expected_case_version),
|
||||
evidenceSetHash: row.evidence_set_hash,
|
||||
calculationSpecHash: row.calculation_spec_hash,
|
||||
errorCode: row.error_code,
|
||||
createdAt: timestamp(row.created_at),
|
||||
updatedAt: timestamp(row.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
function turnValue(row: Row): RectificationV4Turn {
|
||||
return {
|
||||
id: String(row.id),
|
||||
caseId: String(row.case_id),
|
||||
caseVersion: Number(row.case_version),
|
||||
questionId: row.question_id ? String(row.question_id) : null,
|
||||
questionDomain: row.question_domain as RectificationV4Turn["questionDomain"],
|
||||
questionTargetEventId: row.question_target_event_id ? String(row.question_target_event_id) : null,
|
||||
question: String(row.question),
|
||||
answer: String(row.answer),
|
||||
actionId: String(row.action_id),
|
||||
createdAt: timestamp(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRectificationV4SupabaseStore(supabase: SupabaseClient): RectificationV4Store {
|
||||
async function rowById(table: string, id: string): Promise<Row | null> {
|
||||
const { data, error } = await supabase.from(table).select("*").eq("id", id).maybeSingle();
|
||||
if (error) throw storeError(error);
|
||||
return data as Row | null;
|
||||
}
|
||||
|
||||
async function loadCaseById(userId: string, caseId: string): Promise<RectificationV4Case | null> {
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_cases")
|
||||
.select("*").eq("id", caseId).eq("user_id", userId).maybeSingle();
|
||||
if (error) throw storeError(error);
|
||||
if (!data) return null;
|
||||
const row = data as Row;
|
||||
const latest = row.latest_snapshot_id
|
||||
? snapshot(await rowById("birth_time_rectification_v4_candidate_snapshots", String(row.latest_snapshot_id)))
|
||||
: null;
|
||||
return caseValue(row, latest);
|
||||
}
|
||||
|
||||
async function loadJobRow(jobId: string): Promise<Row | null> {
|
||||
return rowById("birth_time_rectification_v4_jobs", jobId);
|
||||
}
|
||||
|
||||
async function loadEventsByCase(userId: string, caseId: string): Promise<readonly LifeEventRevision[]> {
|
||||
if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found");
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_event_revisions")
|
||||
.select("*").eq("case_id", caseId).eq("user_id", userId)
|
||||
.order("created_at", { ascending: true });
|
||||
if (error) throw storeError(error);
|
||||
return ((data ?? []) as Row[]).map(eventRevision);
|
||||
}
|
||||
|
||||
async function rpc(name: string, args: Row): Promise<unknown> {
|
||||
const { data, error } = await supabase.rpc(name, args);
|
||||
if (error) throw storeError(error);
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
async findActiveCase(userId) {
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_cases")
|
||||
.select("id").eq("user_id", userId).neq("status", "abandoned").is("accepted_range_start", null)
|
||||
.order("created_at", { ascending: false }).limit(1).maybeSingle();
|
||||
if (error) throw storeError(error);
|
||||
return data ? loadCaseById(userId, String((data as Row).id)) : null;
|
||||
},
|
||||
loadCase: loadCaseById,
|
||||
loadEvents: loadEventsByCase,
|
||||
async createCase(input) {
|
||||
const id = String(await rpc("create_birth_time_rectification_v4_case", {
|
||||
p_user_id: input.case.userId,
|
||||
p_case_id: input.case.id,
|
||||
p_action_id: input.actionId,
|
||||
p_status: input.case.status,
|
||||
p_phase: input.case.phase,
|
||||
p_calculation_spec: input.case.calculationSpec,
|
||||
p_calculation_spec_hash: input.case.calculationSpecHash,
|
||||
p_evidence_set_hash: input.case.evidenceSetHash,
|
||||
p_current_question: input.case.currentQuestion,
|
||||
p_now: input.case.createdAt,
|
||||
}));
|
||||
const value = await loadCaseById(input.case.userId, id);
|
||||
if (!value) throw new RectificationV4StoreError("not_found");
|
||||
return value;
|
||||
},
|
||||
async submitAnswer(input) {
|
||||
const jobId = String(await rpc("submit_birth_time_rectification_v4_answer", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_action_id: input.actionId,
|
||||
p_expected_version: input.expectedCaseVersion,
|
||||
p_turn_id: input.turnId,
|
||||
p_question_id: input.question.id,
|
||||
p_question_domain: input.question.domain,
|
||||
p_question_target_event_id: input.question.targetEventId,
|
||||
p_question: input.question.prompt,
|
||||
p_answer: input.answer,
|
||||
p_job_id: input.jobId,
|
||||
p_now: input.now,
|
||||
}));
|
||||
const [caseResult, jobRow] = await Promise.all([loadCaseById(input.userId, input.caseId), loadJobRow(jobId)]);
|
||||
if (!caseResult || !jobRow) throw new RectificationV4StoreError("not_found");
|
||||
return { case: caseResult, job: jobValue(jobRow) };
|
||||
},
|
||||
async reviseEvent(input) {
|
||||
const current = await loadEventsByCase(input.userId, input.caseId);
|
||||
const outputHash = evidenceSetHash([...current, input.revision]);
|
||||
const jobId = String(await rpc("revise_birth_time_rectification_v4_event", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_action_id: input.actionId,
|
||||
p_expected_version: input.expectedCaseVersion,
|
||||
p_revision: input.revision,
|
||||
p_output_evidence_set_hash: outputHash,
|
||||
p_turn_id: input.revision.id,
|
||||
p_job_id: input.jobId,
|
||||
p_now: input.now,
|
||||
}));
|
||||
const [caseResult, jobRow] = await Promise.all([loadCaseById(input.userId, input.caseId), loadJobRow(jobId)]);
|
||||
if (!caseResult || !jobRow) throw new RectificationV4StoreError("not_found");
|
||||
return { case: caseResult, job: jobValue(jobRow) };
|
||||
},
|
||||
async transitionCase(input) {
|
||||
const id = String(await rpc("transition_birth_time_rectification_v4_case", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_action_id: input.actionId,
|
||||
p_expected_version: input.expectedCaseVersion,
|
||||
p_status: input.status,
|
||||
p_phase: input.phase,
|
||||
p_accepted_range_start: input.acceptedRange?.start ?? null,
|
||||
p_accepted_range_end: input.acceptedRange?.end ?? null,
|
||||
p_now: input.now,
|
||||
}));
|
||||
const value = await loadCaseById(input.userId, id);
|
||||
if (!value) throw new RectificationV4StoreError("not_found");
|
||||
return value;
|
||||
},
|
||||
async loadJob(userId, jobId) {
|
||||
const row = await loadJobRow(jobId);
|
||||
if (!row || row.user_id !== userId) return null;
|
||||
return jobValue(row);
|
||||
},
|
||||
async updateJobPhase(input) {
|
||||
await rpc("update_birth_time_rectification_v4_job_phase", {
|
||||
p_worker_id: input.workerId,
|
||||
p_job_id: input.jobId,
|
||||
p_phase: input.phase,
|
||||
p_now: input.now,
|
||||
});
|
||||
},
|
||||
async claimNextJob(workerId, now): Promise<ClaimedRectificationV4Job | null> {
|
||||
const claimed = await rpc("claim_next_birth_time_rectification_v4_job", { p_worker_id: workerId, p_now: now });
|
||||
if (!claimed) return null;
|
||||
const jobRow = await loadJobRow(String(claimed));
|
||||
if (!jobRow) throw new RectificationV4StoreError("not_found");
|
||||
const userId = String(jobRow.user_id);
|
||||
const caseId = String(jobRow.case_id);
|
||||
const [caseResult, turnRow, events, turnRows] = await Promise.all([
|
||||
loadCaseById(userId, caseId),
|
||||
rowById("birth_time_rectification_v4_turns", String(jobRow.turn_id)),
|
||||
loadEventsByCase(userId, caseId),
|
||||
supabase.from("birth_time_rectification_v4_turns")
|
||||
.select("question_target_event_id").eq("case_id", caseId),
|
||||
]);
|
||||
if (!caseResult || !turnRow) throw new RectificationV4StoreError("not_found");
|
||||
if (turnRows.error) throw storeError(turnRows.error);
|
||||
return {
|
||||
job: jobValue(jobRow),
|
||||
case: caseResult,
|
||||
turn: turnValue(turnRow),
|
||||
events,
|
||||
attemptedRefinementEventIds: [...new Set(
|
||||
((turnRows.data ?? []) as Row[])
|
||||
.flatMap((row) => row.question_target_event_id ? [String(row.question_target_event_id)] : []),
|
||||
)],
|
||||
};
|
||||
},
|
||||
async completeJob(input: CompleteRectificationV4JobInput, now) {
|
||||
const jobRow = await loadJobRow(input.jobId);
|
||||
if (!jobRow) throw new RectificationV4StoreError("not_found");
|
||||
await rpc("complete_birth_time_rectification_v4_job", {
|
||||
p_worker_id: input.workerId,
|
||||
p_job_id: input.jobId,
|
||||
p_expected_case_version: input.expectedCaseVersion,
|
||||
p_input_evidence_set_hash: input.inputEvidenceSetHash,
|
||||
p_output_evidence_set_hash: input.outputEvidenceSetHash,
|
||||
p_calculation_spec_hash: input.calculationSpecHash,
|
||||
p_event_revisions: input.newEventRevisions,
|
||||
p_snapshot: input.snapshot,
|
||||
p_next_question: input.nextQuestion,
|
||||
p_status: input.status,
|
||||
p_phase: input.phase,
|
||||
p_now: now,
|
||||
});
|
||||
const value = await loadCaseById(String(jobRow.user_id), String(jobRow.case_id));
|
||||
if (!value) throw new RectificationV4StoreError("not_found");
|
||||
return value;
|
||||
},
|
||||
async failJob(input) {
|
||||
await rpc("fail_birth_time_rectification_v4_job", {
|
||||
p_worker_id: input.workerId,
|
||||
p_job_id: input.jobId,
|
||||
p_expected_case_version: input.expectedCaseVersion,
|
||||
p_error_code: input.errorCode,
|
||||
p_restore_question: input.restoreQuestion,
|
||||
p_now: input.now,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { CandidateSnapshot, EvidenceDomain } from "./contracts.ts";
|
||||
import { rectificationV4AlgorithmVersion } from "./contracts.ts";
|
||||
import type { RectificationV4CandidateEngine } from "./candidate-engine.ts";
|
||||
import { buildCandidateClusters } from "./candidate-clusters.ts";
|
||||
import { evaluateDecisionGate } from "./decision-gate.ts";
|
||||
import { evidenceSetHash } from "./fingerprints.ts";
|
||||
import { extractV4EventRevisions } from "./extraction.ts";
|
||||
import { latestEventRevisions, scoreableEvents } from "./evidence-ledger.ts";
|
||||
import { planNextQuestion } from "./question-planner.ts";
|
||||
import type { RectificationV4Store } from "./store.ts";
|
||||
|
||||
export function createRectificationV4Worker(input: {
|
||||
readonly store: RectificationV4Store;
|
||||
readonly engine: RectificationV4CandidateEngine;
|
||||
readonly workerId?: string;
|
||||
readonly now?: () => Date;
|
||||
}) {
|
||||
const workerId = input.workerId ?? randomUUID();
|
||||
const now = input.now ?? (() => new Date());
|
||||
|
||||
return {
|
||||
async runOnce(): Promise<boolean> {
|
||||
const claimed = await input.store.claimNextJob(workerId, now().toISOString());
|
||||
if (!claimed) return false;
|
||||
try {
|
||||
const extracted = claimed.turn.answer
|
||||
? extractV4EventRevisions({
|
||||
answer: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate: now().toISOString().slice(0, 10),
|
||||
existing: claimed.events,
|
||||
targetEventId: claimed.turn.questionTargetEventId,
|
||||
now: now(),
|
||||
})
|
||||
: [];
|
||||
const events = latestEventRevisions([...claimed.events, ...extracted]);
|
||||
await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "scoring_candidates", now: now().toISOString() });
|
||||
const scoreable = scoreableEvents(events);
|
||||
const domains = new Set(scoreable.map((event) => event.domain));
|
||||
let snapshot: CandidateSnapshot | null = null;
|
||||
if (scoreable.length >= 3 && domains.size >= 2) {
|
||||
const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable });
|
||||
await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "checking_robustness", now: now().toISOString() });
|
||||
const clusters = buildCandidateClusters(scored.candidates);
|
||||
const robustness = {
|
||||
...scored.robustness,
|
||||
calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash,
|
||||
};
|
||||
const gate = evaluateDecisionGate({
|
||||
clusters,
|
||||
robustness,
|
||||
scoreableEventCount: scoreable.length,
|
||||
scoreableDomainCount: domains.size,
|
||||
});
|
||||
snapshot = {
|
||||
id: scored.resultId,
|
||||
caseId: claimed.case.id,
|
||||
caseVersion: claimed.case.version,
|
||||
evidenceSetHash: evidenceSetHash(events),
|
||||
calculationSpecHash: claimed.case.calculationSpecHash,
|
||||
algorithmVersion: rectificationV4AlgorithmVersion,
|
||||
candidates: [...scored.candidates],
|
||||
clusters: [...clusters],
|
||||
robustness,
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: gate.canAcceptRange,
|
||||
gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)],
|
||||
createdAt: now().toISOString(),
|
||||
};
|
||||
}
|
||||
await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "planning_question", now: now().toISOString() });
|
||||
const covered = events.map((event) => event.domain);
|
||||
const asked = [...covered, ...(claimed.turn.questionDomain ? [claimed.turn.questionDomain] : [])];
|
||||
const nextQuestion = snapshot?.canAcceptRange ? null : planNextQuestion({
|
||||
askedDomains: [...new Set(asked)] as EvidenceDomain[],
|
||||
coveredDomains: [...new Set(covered)] as EvidenceDomain[],
|
||||
events,
|
||||
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
|
||||
});
|
||||
await input.store.completeJob({
|
||||
workerId,
|
||||
jobId: claimed.job.id,
|
||||
expectedCaseVersion: claimed.case.version,
|
||||
inputEvidenceSetHash: claimed.case.evidenceSetHash,
|
||||
outputEvidenceSetHash: evidenceSetHash(events),
|
||||
calculationSpecHash: claimed.case.calculationSpecHash,
|
||||
newEventRevisions: extracted,
|
||||
snapshot,
|
||||
nextQuestion,
|
||||
status: snapshot?.canAcceptRange ? "range_ready" : "awaiting_answer",
|
||||
phase: snapshot?.canAcceptRange ? "complete" : "collecting_evidence",
|
||||
}, now().toISOString());
|
||||
return true;
|
||||
} catch (error) {
|
||||
await input.store.failJob({
|
||||
workerId,
|
||||
jobId: claimed.job.id,
|
||||
expectedCaseVersion: claimed.case.version,
|
||||
errorCode: error instanceof Error ? error.message.slice(0, 120) : "unknown_worker_error",
|
||||
restoreQuestion: claimed.turn.questionId && claimed.turn.questionDomain ? {
|
||||
id: claimed.turn.questionId,
|
||||
domain: claimed.turn.questionDomain,
|
||||
targetEventId: claimed.turn.questionTargetEventId,
|
||||
prompt: claimed.turn.question,
|
||||
recallCost: "low",
|
||||
reason: "上一轮处理没有完成,请重新提交这段经历。",
|
||||
} : null,
|
||||
now: now().toISOString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client-core";
|
||||
import { readDatabaseUrl } from "@/lib/db/config";
|
||||
import {
|
||||
getSupabaseUrl,
|
||||
SupabaseConfigurationError,
|
||||
} from "./config";
|
||||
|
||||
export function createAdminSupabaseClient() {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
|
||||
null,
|
||||
"service_role",
|
||||
) as unknown as SupabaseClient;
|
||||
}
|
||||
const url = getSupabaseUrl();
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!serviceRoleKey) {
|
||||
throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]);
|
||||
}
|
||||
|
||||
return createClient(url, serviceRoleKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
|
||||
export function isAdminEmail(email: string | null | undefined) {
|
||||
const configured = process.env.ADMIN_EMAILS;
|
||||
if (!configured?.trim() || !email) return false;
|
||||
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return configured
|
||||
.split(",")
|
||||
.some((candidate) => candidate.trim().toLowerCase() === normalized);
|
||||
}
|
||||
@@ -1,38 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
|
||||
import { readDatabaseUrl } from "@/lib/db/config";
|
||||
import {
|
||||
getSupabaseUrl,
|
||||
SupabaseConfigurationError,
|
||||
} from "./config";
|
||||
|
||||
export function createAdminSupabaseClient() {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
|
||||
null,
|
||||
"service_role",
|
||||
) as unknown as SupabaseClient;
|
||||
}
|
||||
const url = getSupabaseUrl();
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!serviceRoleKey) {
|
||||
throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]);
|
||||
}
|
||||
|
||||
return createClient(url, serviceRoleKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
|
||||
export function isAdminEmail(email: string | null | undefined) {
|
||||
const configured = process.env.ADMIN_EMAILS;
|
||||
if (!configured?.trim() || !email) return false;
|
||||
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return configured
|
||||
.split(",")
|
||||
.some((candidate) => candidate.trim().toLowerCase() === normalized);
|
||||
}
|
||||
export {
|
||||
createAdminSupabaseClient,
|
||||
isAdminEmail,
|
||||
} from "./admin-client-core";
|
||||
|
||||
Reference in New Issue
Block a user