feat: orchestrate conversational rectification
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
conversationalRectificationCommandSchema,
|
||||
type ConversationalRectificationCommand,
|
||||
type ConversationalRectificationTurn,
|
||||
} from "../../../lib/conversational-rectification/contracts.ts";
|
||||
import {
|
||||
ConversationalRectificationError,
|
||||
toConversationalRectificationPublicError,
|
||||
} from "../../../lib/conversational-rectification/errors.ts";
|
||||
import {
|
||||
createConversationalRectificationService,
|
||||
type ConversationalRectificationPacketBuildInput,
|
||||
type ConversationalRectificationService,
|
||||
} from "../../../lib/conversational-rectification/orchestrator.ts";
|
||||
import type { DeclaredBirthInput, LifeEventEvidence } from "../../../lib/conversational-rectification/persistence-contracts.ts";
|
||||
import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts";
|
||||
import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts";
|
||||
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type AuthenticatedRequest = Readonly<{
|
||||
userId: string;
|
||||
context: unknown;
|
||||
}>;
|
||||
|
||||
export type BirthTimeConversationRouteService = ConversationalRectificationService;
|
||||
|
||||
export type BirthTimeConversationRouteLog = Readonly<{
|
||||
requestId: string;
|
||||
actionId: string | null;
|
||||
caseId: string | null;
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
export type BirthTimeConversationPostDependencies = Readonly<{
|
||||
authenticate(request: Request): Promise<AuthenticatedRequest | null>;
|
||||
createService(authenticated: AuthenticatedRequest): Promise<BirthTimeConversationRouteService>;
|
||||
createRequestId?(request: Request): string;
|
||||
log?(entry: BirthTimeConversationRouteLog): void;
|
||||
}>;
|
||||
|
||||
type ProfileQueryResult = Readonly<{
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
}>;
|
||||
|
||||
type ProfileClient = {
|
||||
from(table: string): {
|
||||
select(columns: string): {
|
||||
eq(column: string, value: string): {
|
||||
maybeSingle(): PromiseLike<ProfileQueryResult>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
async function authenticateProductionRequest(): Promise<AuthenticatedRequest | null> {
|
||||
const { createServerSupabaseClient } = await import("../../../lib/supabase/server.ts");
|
||||
const serverClient = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await serverClient.auth.getUser();
|
||||
if (error || !user) return null;
|
||||
return { userId: user.id, context: serverClient };
|
||||
}
|
||||
|
||||
async function requestPayload(request: Request): Promise<unknown> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function profileRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function declaredBirthInputFromProfile(value: unknown): {
|
||||
readonly declaredBirthInput: unknown;
|
||||
readonly revisionOfCaseId: string | null;
|
||||
} {
|
||||
const profile = profileRecord(value);
|
||||
if (!profile) throw new ConversationalRectificationError("profile_incomplete");
|
||||
const birthDate = text(profile.birth_date);
|
||||
const source = text(profile.birth_time_source);
|
||||
const cityCode = text(profile.city_code);
|
||||
const latitude = finiteNumber(profile.latitude);
|
||||
const longitude = finiteNumber(profile.longitude);
|
||||
const timezoneOffset = finiteNumber(profile.timezone_offset);
|
||||
if (!birthDate || !source || !cityCode || latitude === null || longitude === null
|
||||
|| timezoneOffset === null) {
|
||||
throw new ConversationalRectificationError("profile_incomplete");
|
||||
}
|
||||
const birthplace = {
|
||||
...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}),
|
||||
...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}),
|
||||
cityCode,
|
||||
...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}),
|
||||
latitude,
|
||||
longitude,
|
||||
timezoneOffset,
|
||||
};
|
||||
const common = {
|
||||
birthDate,
|
||||
birthTimeClue: text(profile.birth_time_clue),
|
||||
birthplace,
|
||||
};
|
||||
const reportedTime = text(profile.reported_birth_time)?.slice(0, 5) ?? null;
|
||||
const period = text(profile.birth_time_period);
|
||||
const before = integer(profile.uncertainty_before_minutes);
|
||||
const after = integer(profile.uncertainty_after_minutes);
|
||||
let declaredBirthInput: unknown;
|
||||
switch (source) {
|
||||
case "hospital_record":
|
||||
declaredBirthInput = {
|
||||
...common, source, reportedTime,
|
||||
uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2,
|
||||
};
|
||||
break;
|
||||
case "family_exact":
|
||||
case "approximate":
|
||||
declaredBirthInput = {
|
||||
...common, source, reportedTime,
|
||||
uncertaintyBeforeMinutes: before, uncertaintyAfterMinutes: after,
|
||||
};
|
||||
break;
|
||||
case "period_only":
|
||||
declaredBirthInput = { ...common, source, reportedPeriod: period };
|
||||
break;
|
||||
case "unknown":
|
||||
declaredBirthInput = { ...common, source };
|
||||
break;
|
||||
case "legacy_import":
|
||||
declaredBirthInput = {
|
||||
...common,
|
||||
source,
|
||||
...(reportedTime ? { reportedTime } : {}),
|
||||
...(period ? { reportedPeriod: period } : {}),
|
||||
...(before === null ? {} : { uncertaintyBeforeMinutes: before }),
|
||||
...(after === null ? {} : { uncertaintyAfterMinutes: after }),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new ConversationalRectificationError("profile_incomplete");
|
||||
}
|
||||
return {
|
||||
declaredBirthInput,
|
||||
revisionOfCaseId: text(profile.rectification_case_id),
|
||||
};
|
||||
}
|
||||
|
||||
function priceCredits(): number {
|
||||
const raw = process.env.RECTIFICATION_PRICE_CREDITS?.trim() ?? "1";
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > 100) {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function minute(value: string): number {
|
||||
const [hour = 0, part = 0] = value.split(":").map(Number);
|
||||
return hour * 60 + part;
|
||||
}
|
||||
|
||||
function clock(value: number): string {
|
||||
const normalized = ((value % 1_440) + 1_440) % 1_440;
|
||||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function declaredRange(input: DeclaredBirthInput): { readonly startTime: string; readonly endTime: string } {
|
||||
if (input.source === "period_only") {
|
||||
return {
|
||||
early_morning: { startTime: "04:00", endTime: "07:59" },
|
||||
morning: { startTime: "08:00", endTime: "11:59" },
|
||||
afternoon: { startTime: "12:00", endTime: "17:59" },
|
||||
evening: { startTime: "18:00", endTime: "22:59" },
|
||||
late_night: { startTime: "23:00", endTime: "03:59" },
|
||||
}[input.reportedPeriod];
|
||||
}
|
||||
if (input.source === "unknown") return { startTime: "00:01", endTime: "23:59" };
|
||||
if (input.source === "legacy_import" && !input.reportedTime) {
|
||||
if (input.reportedPeriod) {
|
||||
return declaredRange({ ...input, source: "period_only", reportedPeriod: input.reportedPeriod });
|
||||
}
|
||||
return { startTime: "00:01", endTime: "23:59" };
|
||||
}
|
||||
const reportedTime = input.reportedTime;
|
||||
if (!reportedTime) throw new ConversationalRectificationError("profile_incomplete");
|
||||
const before = input.uncertaintyBeforeMinutes ?? 2;
|
||||
const after = input.uncertaintyAfterMinutes ?? 2;
|
||||
return {
|
||||
startTime: clock(minute(reportedTime) - before),
|
||||
endTime: clock(minute(reportedTime) + after),
|
||||
};
|
||||
}
|
||||
|
||||
function scanCoordinates(range: { readonly startTime: string; readonly endTime: string }) {
|
||||
const start = minute(range.startTime);
|
||||
let end = minute(range.endTime);
|
||||
if (end < start) end += 1_440;
|
||||
const center = Math.round((start + end) / 2);
|
||||
return {
|
||||
centerTime: clock(center),
|
||||
uncertaintyMinutes: Math.max(1, Math.ceil((end - start) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
function currentRange(input: ConversationalRectificationPacketBuildInput) {
|
||||
const start = input.privateCandidate?.rangeStart;
|
||||
const end = input.privateCandidate?.rangeEnd;
|
||||
return start && end ? { startTime: start, endTime: end } : declaredRange(input.declaredBirthInput);
|
||||
}
|
||||
|
||||
function scoreableLifeEvents(evidence: readonly LifeEventEvidence[]): LifeEvent[] {
|
||||
return evidence.slice(-10).flatMap((item) => {
|
||||
if (item.scoreable !== true || !item.dateValue
|
||||
|| !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")) {
|
||||
return [];
|
||||
}
|
||||
const domain = item.domain === "family" ? "relationship"
|
||||
: item.domain === "other" ? null
|
||||
: item.domain;
|
||||
if (!domain) return [];
|
||||
return [{
|
||||
id: item.id,
|
||||
domain,
|
||||
precision: item.datePrecision as "day" | "month" | "year",
|
||||
date: item.dateValue,
|
||||
} as LifeEvent];
|
||||
});
|
||||
}
|
||||
|
||||
function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] {
|
||||
const raw = profileRecord(scan.raw.candidate_scan);
|
||||
const samples = Array.isArray(raw?.samples) ? raw.samples : [];
|
||||
const links = samples.flatMap((item, sampleIndex) => {
|
||||
const rawTime = text(profileRecord(item)?.time);
|
||||
const match = rawTime?.match(/(?:^|[T\s])(([01]\d|2[0-3]):[0-5]\d)/);
|
||||
return match?.[1] ? [{ sampleIndex, time: match[1] }] : [];
|
||||
});
|
||||
if (links.length !== scan.samples.length) {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: string) {
|
||||
const layers = [
|
||||
["D1", "ascendantSign"],
|
||||
["D4", "d4Sign"],
|
||||
["D9", "d9Sign"],
|
||||
["D10", "d10Sign"],
|
||||
["D24", "d24Sign"],
|
||||
["D30", "d30Sign"],
|
||||
] as const;
|
||||
const availableLayers = layers
|
||||
.filter(([, key]) => scan.samples.some((sample) => typeof sample[key] === "string" && sample[key]?.trim()))
|
||||
.map(([layer]) => layer);
|
||||
return {
|
||||
availableLayers,
|
||||
layerReferences: Object.fromEntries(availableLayers.map((layer) => [
|
||||
layer,
|
||||
[`server-scan-${calculationVersion}-${layer.toLowerCase()}`],
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
function boundaryDistance(range: { readonly startTime: string; readonly endTime: string }, representative: string) {
|
||||
const start = minute(range.startTime);
|
||||
let end = minute(range.endTime);
|
||||
let value = minute(representative);
|
||||
if (end < start) end += 1_440;
|
||||
if (value < start) value += 1_440;
|
||||
return Math.max(0, Math.min(value - start, end - value));
|
||||
}
|
||||
|
||||
async function buildProductionPacket(
|
||||
engine: BirthTimeJourneyEngine,
|
||||
input: ConversationalRectificationPacketBuildInput,
|
||||
) {
|
||||
const place = input.declaredBirthInput.birthplace;
|
||||
if (place.latitude === undefined || place.longitude === undefined) {
|
||||
throw new ConversationalRectificationError("profile_incomplete");
|
||||
}
|
||||
const baseRange = currentRange(input);
|
||||
const events = scoreableLifeEvents(input.evidence as readonly LifeEventEvidence[]);
|
||||
const eventScore: CandidateResult | null = events.length > 0
|
||||
? await engine.scoreEvents({
|
||||
birthDate: input.declaredBirthInput.birthDate,
|
||||
startTime: baseRange.startTime,
|
||||
endTime: baseRange.endTime,
|
||||
lat: place.latitude,
|
||||
lon: place.longitude,
|
||||
tz: place.timezoneOffset,
|
||||
events,
|
||||
})
|
||||
: null;
|
||||
const selectedRange = eventScore?.winningSegment
|
||||
? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime }
|
||||
: baseRange;
|
||||
const scanPoint = scanCoordinates(selectedRange);
|
||||
const { questionnaire } = await engine.scan({
|
||||
birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`,
|
||||
uncertaintyMinutes: scanPoint.uncertaintyMinutes,
|
||||
lat: place.latitude,
|
||||
lon: place.longitude,
|
||||
tz: place.timezoneOffset,
|
||||
ayanamsa: "lahiri",
|
||||
});
|
||||
const candidateDifferences = await engine.buildDifferencePacket({
|
||||
caseId: input.caseId,
|
||||
asOfDate: input.asOfDate,
|
||||
birthDate: input.declaredBirthInput.birthDate,
|
||||
startTime: selectedRange.startTime,
|
||||
endTime: selectedRange.endTime,
|
||||
lat: place.latitude,
|
||||
lon: place.longitude,
|
||||
tz: place.timezoneOffset,
|
||||
evidence: [],
|
||||
dismissedOpportunityIds: [],
|
||||
questionFingerprints: [],
|
||||
partitionFingerprints: [],
|
||||
recentRanges: [],
|
||||
candidateModel: null,
|
||||
});
|
||||
const calculationVersion = eventScore
|
||||
? `${candidateDifferences.packet.scoringVersion}+${eventScore.algorithmVersion}`
|
||||
: candidateDifferences.packet.scoringVersion;
|
||||
const metadata = layerMetadata(questionnaire, calculationVersion);
|
||||
const representative = eventScore?.winningSegment?.representativeTime
|
||||
?? scanPoint.centerTime;
|
||||
const { buildRectificationTechnicalPacket } = await import(
|
||||
"../../../lib/conversational-rectification/technical-packet.ts"
|
||||
);
|
||||
return {
|
||||
packet: buildRectificationTechnicalPacket({
|
||||
scan: questionnaire,
|
||||
candidateDifferences,
|
||||
eventScore,
|
||||
consultation: {
|
||||
source: "server_consultation_workflow",
|
||||
calculationVersion,
|
||||
availableLayers: metadata.availableLayers,
|
||||
layerReferences: metadata.layerReferences,
|
||||
timeLinkedScanSamples: sampleTimes(questionnaire),
|
||||
boundaryDistanceMinutes: boundaryDistance(selectedRange, representative),
|
||||
futureWindows: [],
|
||||
},
|
||||
}),
|
||||
resultId: eventScore?.resultId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function productionNarrativeGenerator(): Promise<RectificationNarrativeGenerator> {
|
||||
const [{ defaultLanguageModel }, { Agent }] = await Promise.all([
|
||||
import("../../../mastra/model.ts"),
|
||||
import("@mastra/core/agent"),
|
||||
]);
|
||||
const model = defaultLanguageModel();
|
||||
if (!model) {
|
||||
return {
|
||||
modelId: "deterministic-rectification-fallback",
|
||||
async generate() { throw new Error("NarrativeModelUnavailable"); },
|
||||
};
|
||||
}
|
||||
const agent = new Agent({
|
||||
id: `conversational-rectification-${model.id}`,
|
||||
name: "Conversational Rectification Narrator",
|
||||
model: model.model,
|
||||
instructions: "Return only the exact JSON object requested by the user prompt. Use only supplied packet facts. Never invent times, layers, references, scores, dates, or confirmation state.",
|
||||
});
|
||||
return {
|
||||
modelId: model.id,
|
||||
async generate(prompt) {
|
||||
const result = await agent.generate([{ role: "user", content: prompt }]);
|
||||
return { text: result.text };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createProductionService(
|
||||
authenticated: AuthenticatedRequest,
|
||||
): Promise<BirthTimeConversationRouteService> {
|
||||
const [
|
||||
{ createAdminSupabaseClient },
|
||||
{ createSupabaseConversationalRectificationStore },
|
||||
{ createSupabaseConversationalRectificationBilling },
|
||||
{ createJyotishBirthTimeJourneyEngine },
|
||||
narrativeGenerator,
|
||||
] = await Promise.all([
|
||||
import("../../../lib/supabase/admin.ts"),
|
||||
import("../../../lib/conversational-rectification/store.ts"),
|
||||
import("../../../lib/conversational-rectification/billing.ts"),
|
||||
import("../../../lib/birth-time-journey-engine.ts"),
|
||||
productionNarrativeGenerator(),
|
||||
]);
|
||||
const admin = createAdminSupabaseClient();
|
||||
const profileClient = authenticated.context as ProfileClient;
|
||||
const engine = createJyotishBirthTimeJourneyEngine();
|
||||
return createConversationalRectificationService({
|
||||
store: createSupabaseConversationalRectificationStore(admin),
|
||||
billing: createSupabaseConversationalRectificationBilling(admin),
|
||||
get rectificationPriceCredits() { return priceCredits(); },
|
||||
async loadDeclaredProfile(userId) {
|
||||
const { data, error } = await profileClient
|
||||
.from("profiles")
|
||||
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
if (error) throw new ConversationalRectificationError("store_unavailable");
|
||||
return declaredBirthInputFromProfile(data);
|
||||
},
|
||||
buildTechnicalPacket: (input) => buildProductionPacket(engine, input),
|
||||
narrativeGenerator,
|
||||
asOfDate: () => new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
|
||||
function stableRequestId(request: Request): string {
|
||||
const supplied = request.headers.get("x-request-id");
|
||||
return supplied && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(supplied)
|
||||
? supplied.toLowerCase()
|
||||
: randomUUID();
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown) {
|
||||
const publicError = toConversationalRectificationPublicError(error);
|
||||
return Response.json(publicError, { status: publicError.status });
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
service: BirthTimeConversationRouteService,
|
||||
userId: string,
|
||||
command: ConversationalRectificationCommand,
|
||||
): Promise<ConversationalRectificationTurn> {
|
||||
switch (command.type) {
|
||||
case "start": return service.start(userId, command);
|
||||
case "resume": return service.resume(userId, command);
|
||||
case "answer": return service.answer(userId, command);
|
||||
case "pause": return service.pause(userId, command);
|
||||
case "abandon": return service.abandon(userId, command);
|
||||
case "confirm": return service.confirm(userId, command);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBirthTimeConversationPostHandler(
|
||||
dependencies: BirthTimeConversationPostDependencies,
|
||||
) {
|
||||
return async function handleBirthTimeConversationPost(request: Request): Promise<Response> {
|
||||
const requestId = dependencies.createRequestId?.(request) ?? stableRequestId(request);
|
||||
let actionId: string | null = null;
|
||||
let caseId: string | null = null;
|
||||
try {
|
||||
const authenticated = await dependencies.authenticate(request);
|
||||
if (!authenticated) return errorResponse(new ConversationalRectificationError("authentication_required"));
|
||||
|
||||
const parsed = conversationalRectificationCommandSchema.safeParse(await requestPayload(request));
|
||||
if (!parsed.success) return errorResponse(new ConversationalRectificationError("invalid_command"));
|
||||
actionId = parsed.data.actionId;
|
||||
caseId = parsed.data.type === "start" ? parsed.data.actionId : parsed.data.caseId;
|
||||
|
||||
const service = await dependencies.createService(authenticated);
|
||||
return Response.json(await dispatch(service, authenticated.userId, parsed.data));
|
||||
} catch (error) {
|
||||
const publicError = toConversationalRectificationPublicError(error);
|
||||
dependencies.log?.({ requestId, actionId, caseId, code: publicError.code });
|
||||
return Response.json(publicError, { status: publicError.status });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const productionPost = createBirthTimeConversationPostHandler({
|
||||
authenticate: authenticateProductionRequest,
|
||||
createService: createProductionService,
|
||||
createRequestId: stableRequestId,
|
||||
log(entry) {
|
||||
console.error(
|
||||
`[birth-time-conversation] request=${entry.requestId} action=${entry.actionId ?? "none"} case=${entry.caseId ?? "none"} code=${entry.code}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return productionPost(request);
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import {
|
||||
conversationalRectificationCommandSchema,
|
||||
conversationalRectificationTurnSchema,
|
||||
type ConversationalRectificationCommand,
|
||||
type ConversationalRectificationTurn,
|
||||
} from "./contracts.ts";
|
||||
import { ConversationalRectificationError } from "./errors.ts";
|
||||
import { extractLifeEventEvidence } from "./evidence-extractor.ts";
|
||||
import {
|
||||
declaredBirthInputSchema,
|
||||
privateCandidateSchema,
|
||||
validationReceiptSchema,
|
||||
type DeclaredBirthInput,
|
||||
type LifeEventEvidence,
|
||||
type PrivateCandidate,
|
||||
type ValidationReceipt,
|
||||
} from "./persistence-contracts.ts";
|
||||
import {
|
||||
generateRectificationNarrative,
|
||||
type RectificationNarrativeGenerator,
|
||||
type RectificationNarrativeResult,
|
||||
} from "./narrative-agent.ts";
|
||||
import {
|
||||
projectRectificationTechnicalPacket,
|
||||
type RectificationEvidenceDomain,
|
||||
type RectificationTechnicalPacket,
|
||||
} from "./technical-packet.ts";
|
||||
import type { ConversationalRectificationBilling } from "./billing.ts";
|
||||
import type {
|
||||
ConversationalRectificationStore,
|
||||
LifeEventEvidenceInput,
|
||||
LoadedConversationalRectificationCase,
|
||||
PrivateCandidateInput,
|
||||
StoredConversationalRectificationCase,
|
||||
} from "./store.ts";
|
||||
|
||||
type CommandOf<Type extends ConversationalRectificationCommand["type"]> = Extract<
|
||||
ConversationalRectificationCommand,
|
||||
{ readonly type: Type }
|
||||
>;
|
||||
|
||||
export type ComputedConversationalRectificationPacket = Readonly<{
|
||||
packet: RectificationTechnicalPacket;
|
||||
resultId: string | null;
|
||||
}>;
|
||||
|
||||
export type ConversationalRectificationProfile = Readonly<{
|
||||
declaredBirthInput: unknown;
|
||||
revisionOfCaseId: string | null;
|
||||
}>;
|
||||
|
||||
export type ConversationalRectificationPacketBuildInput = Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
asOfDate: string;
|
||||
declaredBirthInput: DeclaredBirthInput;
|
||||
privateCandidate: PrivateCandidateInput | null;
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
}>;
|
||||
|
||||
export type ConversationalRectificationServicePorts = Readonly<{
|
||||
store: Pick<ConversationalRectificationStore,
|
||||
"createCaseWithFirstTurn" | "loadCase" | "saveTurn" | "pause" | "abandon" | "confirm">;
|
||||
billing: Pick<ConversationalRectificationBilling, "reserve" | "complete" | "release">;
|
||||
rectificationPriceCredits: number;
|
||||
loadDeclaredProfile(userId: string): Promise<ConversationalRectificationProfile>;
|
||||
buildTechnicalPacket(
|
||||
input: ConversationalRectificationPacketBuildInput,
|
||||
): Promise<ComputedConversationalRectificationPacket>;
|
||||
narrativeGenerator: RectificationNarrativeGenerator;
|
||||
asOfDate(): string;
|
||||
}>;
|
||||
|
||||
export type ConversationalRectificationService = Readonly<{
|
||||
start(userId: string, command: CommandOf<"start">): Promise<ConversationalRectificationTurn>;
|
||||
resume(userId: string, command: CommandOf<"resume">): Promise<ConversationalRectificationTurn>;
|
||||
answer(userId: string, command: CommandOf<"answer">): Promise<ConversationalRectificationTurn>;
|
||||
pause(userId: string, command: CommandOf<"pause">): Promise<ConversationalRectificationTurn>;
|
||||
abandon(userId: string, command: CommandOf<"abandon">): Promise<ConversationalRectificationTurn>;
|
||||
confirm(userId: string, command: CommandOf<"confirm">): Promise<ConversationalRectificationTurn>;
|
||||
}>;
|
||||
|
||||
const transitionValidatorVersion = "conversational-rectification-orchestrator-v1";
|
||||
const directionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不知道|不确定)/;
|
||||
|
||||
function safeFailure(error: unknown): ConversationalRectificationError {
|
||||
return error instanceof ConversationalRectificationError
|
||||
? error
|
||||
: new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
|
||||
function parseCommand<Type extends ConversationalRectificationCommand["type"]>(
|
||||
type: Type,
|
||||
value: unknown,
|
||||
): CommandOf<Type> {
|
||||
const parsed = conversationalRectificationCommandSchema.safeParse(value);
|
||||
if (!parsed.success || parsed.data.type !== type) {
|
||||
throw new ConversationalRectificationError("invalid_command");
|
||||
}
|
||||
return parsed.data as CommandOf<Type>;
|
||||
}
|
||||
|
||||
function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn {
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn);
|
||||
if (!parsed.success) throw new ConversationalRectificationError("store_unavailable");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function transitionReceipt(modelId = "deterministic-rectification-transition"): ValidationReceipt {
|
||||
return validationReceiptSchema.parse({
|
||||
modelId,
|
||||
schemaValidated: true,
|
||||
validatorVersion: transitionValidatorVersion,
|
||||
retryCount: 0,
|
||||
fallbackUsed: false,
|
||||
issues: [],
|
||||
});
|
||||
}
|
||||
|
||||
function latestReceipt(value: LoadedConversationalRectificationCase): ValidationReceipt {
|
||||
const receipt = value.validationReceipts.at(-1);
|
||||
const parsed = validationReceiptSchema.safeParse(receipt);
|
||||
if (!parsed.success) throw new ConversationalRectificationError("store_unavailable");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
|
||||
return evidence.slice(-20).map((item) => ({
|
||||
id: item.id,
|
||||
summary: item.eventSummary,
|
||||
dateLabel: item.dateValue
|
||||
? item.scoreable === false && item.extractionStatus !== "needs_clarification"
|
||||
? `${item.dateValue}(未来,仅作背景)`
|
||||
: item.dateValue
|
||||
: "日期待补充",
|
||||
}));
|
||||
}
|
||||
|
||||
function exactTechnicalReceipt(packet: RectificationTechnicalPacket) {
|
||||
const projected = projectRectificationTechnicalPacket(packet);
|
||||
return {
|
||||
calculationVersion: projected.technicalReceipt.calculationVersion,
|
||||
stableLayers: projected.technicalReceipt.stableLayers,
|
||||
sensitiveLayers: projected.technicalReceipt.sensitiveLayers,
|
||||
candidateDifferenceRefs: projected.technicalReceipt.candidateDifferenceRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function actionsFor(status: "active" | "confirming") {
|
||||
if (status === "confirming") {
|
||||
return ["answer", "pause", "abandon", "confirm"] as const;
|
||||
}
|
||||
return ["answer", "pause", "abandon"] as const;
|
||||
}
|
||||
|
||||
function turnFromNarrative(input: {
|
||||
readonly caseId: string;
|
||||
readonly turnVersion: number;
|
||||
readonly pendingConsultationQuestion: string | null;
|
||||
readonly packet: RectificationTechnicalPacket;
|
||||
readonly narrative: RectificationNarrativeResult;
|
||||
readonly evidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
}): ConversationalRectificationTurn {
|
||||
const projected = projectRectificationTechnicalPacket(input.packet);
|
||||
const status = projected.candidate.status === "ready_for_confirmation" ? "confirming" : "active";
|
||||
const evidenceRequest = input.narrative.output.evidenceRequest
|
||||
? {
|
||||
domains: input.narrative.output.evidenceRequest.domains,
|
||||
datePrecision: input.narrative.output.evidenceRequest.datePrecision,
|
||||
freeTextAllowed: true as const,
|
||||
}
|
||||
: null;
|
||||
const candidate = {
|
||||
status: projected.candidate.status,
|
||||
representativeTime: projected.candidate.representativeTime,
|
||||
rangeStart: projected.candidate.rangeStart,
|
||||
rangeEnd: projected.candidate.rangeEnd,
|
||||
};
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse({
|
||||
caseId: input.caseId,
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status,
|
||||
turnVersion: input.turnVersion,
|
||||
narrative: input.narrative.narrative,
|
||||
candidate,
|
||||
technicalReceipt: exactTechnicalReceipt(input.packet),
|
||||
evidenceRequest,
|
||||
evidenceRecap: evidenceRecap(input.evidence),
|
||||
actions: actionsFor(status),
|
||||
pendingConsultationQuestion: input.pendingConsultationQuestion,
|
||||
});
|
||||
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function privateCandidateFromPacket(input: {
|
||||
readonly packet: RectificationTechnicalPacket;
|
||||
readonly resultId: string | null;
|
||||
readonly iteration: number;
|
||||
}): PrivateCandidate {
|
||||
const packet = input.packet;
|
||||
const parsed = privateCandidateSchema.safeParse({
|
||||
resultId: input.resultId,
|
||||
representativeTime: packet.candidate.representativeTime,
|
||||
rangeStart: packet.candidate.range.startTime,
|
||||
rangeEnd: packet.candidate.range.endTime,
|
||||
calculationVersion: packet.calculationVersion,
|
||||
candidateWeights: Object.entries(packet.candidateWeights)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([, weight]) => weight),
|
||||
candidateModelRefs: packet.candidateModelRefs,
|
||||
d1Stability: packet.d1Stability,
|
||||
boundaryDistanceMinutes: packet.boundaryDistanceMinutes,
|
||||
supportedSensitiveLayers: packet.supportedSensitiveLayers,
|
||||
scoredHistoricalEvidence: packet.scoredHistoricalEvidence,
|
||||
suggestedDomains: packet.suggestedDomains.map((item) => item.domain),
|
||||
futureWindows: packet.futureWindows,
|
||||
workingState: {
|
||||
phase: packet.candidate.status === "ready_for_confirmation" ? "ready" : "collecting_evidence",
|
||||
iteration: input.iteration,
|
||||
notes: [],
|
||||
},
|
||||
});
|
||||
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function changedTurn(input: {
|
||||
readonly current: LoadedConversationalRectificationCase;
|
||||
readonly status: "paused" | "abandoned" | "completed";
|
||||
readonly narrative: string;
|
||||
readonly receipt?: ValidationReceipt;
|
||||
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
|
||||
const current = input.current.latestTurn;
|
||||
const actions = input.status === "paused"
|
||||
? ["answer", "abandon"]
|
||||
: input.status === "completed" && current.pendingConsultationQuestion
|
||||
? ["continue_original_question"]
|
||||
: [];
|
||||
const candidate = input.status === "completed"
|
||||
? { ...current.candidate, status: "confirmed" as const }
|
||||
: current.candidate;
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse({
|
||||
...current,
|
||||
status: input.status,
|
||||
turnVersion: input.current.turnVersion + 1,
|
||||
narrative: input.narrative,
|
||||
candidate,
|
||||
evidenceRequest: input.status === "completed" ? null : current.evidenceRequest,
|
||||
actions,
|
||||
});
|
||||
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
|
||||
return { turn: parsed.data, receipt: input.receipt ?? transitionReceipt() };
|
||||
}
|
||||
|
||||
function boundedNarrative(previous: string, suffix: string): string {
|
||||
const room = Math.max(1, 12_000 - suffix.length - 2);
|
||||
return `${previous.slice(0, room)}\n\n${suffix}`.slice(0, 12_000);
|
||||
}
|
||||
|
||||
function domainsForClarification(
|
||||
current: ConversationalRectificationTurn,
|
||||
hint: RectificationEvidenceDomain | undefined,
|
||||
): readonly RectificationEvidenceDomain[] {
|
||||
const values = [
|
||||
hint,
|
||||
...(current.evidenceRequest?.domains ?? []),
|
||||
"career" as const,
|
||||
"relationship" as const,
|
||||
].filter((value): value is RectificationEvidenceDomain => Boolean(value));
|
||||
return [...new Set(values)].slice(0, 4).length >= 2
|
||||
? [...new Set(values)].slice(0, 4)
|
||||
: ["career", "relationship"];
|
||||
}
|
||||
|
||||
function nonScoringTurn(input: {
|
||||
readonly current: LoadedConversationalRectificationCase;
|
||||
readonly newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
readonly domain?: RectificationEvidenceDomain;
|
||||
readonly directionChange: boolean;
|
||||
readonly scoringFallback?: boolean;
|
||||
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
|
||||
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
|
||||
const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification"
|
||||
&& item.scoreable === false && item.dateValue !== null);
|
||||
const narrative = input.scoringFallback
|
||||
? "本轮原文已安全保存,但新的专业解释未通过事实一致性校验,因此候选没有推进。请稍后重试,或继续补充一件已经发生并带有年月的事件。"
|
||||
: input.directionChange
|
||||
? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。"
|
||||
: hasFuture
|
||||
? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。"
|
||||
: "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
|
||||
const status = input.current.status === "confirming" ? "confirming" : "active";
|
||||
const actions = actionsFor(status);
|
||||
const evidenceRequest = status === "confirming" && input.current.latestTurn.evidenceRequest === null
|
||||
? null
|
||||
: {
|
||||
domains: domainsForClarification(input.current.latestTurn, input.domain),
|
||||
datePrecision: "month_preferred" as const,
|
||||
freeTextAllowed: true as const,
|
||||
};
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse({
|
||||
...input.current.latestTurn,
|
||||
status,
|
||||
turnVersion: input.current.turnVersion + 1,
|
||||
narrative,
|
||||
evidenceRequest,
|
||||
evidenceRecap: evidenceRecap(allEvidence),
|
||||
actions,
|
||||
});
|
||||
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
|
||||
return {
|
||||
turn: parsed.data,
|
||||
receipt: transitionReceipt(input.scoringFallback
|
||||
? "deterministic-scoring-safety-fallback"
|
||||
: "deterministic-evidence-clarification"),
|
||||
};
|
||||
}
|
||||
|
||||
function requireLoaded(
|
||||
value: LoadedConversationalRectificationCase | null,
|
||||
): LoadedConversationalRectificationCase {
|
||||
if (!value) throw new ConversationalRectificationError("case_not_found");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireExactVersion(current: LoadedConversationalRectificationCase, expected: number) {
|
||||
if (current.turnVersion !== expected) throw new ConversationalRectificationError("stale_turn");
|
||||
}
|
||||
|
||||
function requireMutable(current: LoadedConversationalRectificationCase) {
|
||||
if (!(["active", "paused", "confirming"] as const).includes(
|
||||
current.status as "active" | "paused" | "confirming",
|
||||
)) {
|
||||
throw new ConversationalRectificationError("invalid_transition");
|
||||
}
|
||||
}
|
||||
|
||||
export function createConversationalRectificationService(
|
||||
ports: ConversationalRectificationServicePorts,
|
||||
): ConversationalRectificationService {
|
||||
async function load(userId: string, caseId: string) {
|
||||
try {
|
||||
return requireLoaded(await ports.store.loadCase({ userId, caseId }));
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] {
|
||||
let extracted: readonly LifeEventEvidence[];
|
||||
try {
|
||||
extracted = extractLifeEventEvidence({
|
||||
rawText: command.answer,
|
||||
sourceTurnId: command.actionId,
|
||||
asOfDate: ports.asOfDate(),
|
||||
}).map((item) => ({
|
||||
...item,
|
||||
domain: item.domain === "other" && command.domain && command.domain !== "other"
|
||||
? command.domain
|
||||
: item.domain,
|
||||
}));
|
||||
} catch {
|
||||
throw new ConversationalRectificationError("invalid_command");
|
||||
}
|
||||
if (extracted.length > 20) throw new ConversationalRectificationError("invalid_command");
|
||||
return extracted;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async start(userId, rawCommand) {
|
||||
const command = parseCommand("start", rawCommand);
|
||||
let profile: ConversationalRectificationProfile;
|
||||
try {
|
||||
profile = await ports.loadDeclaredProfile(userId);
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
const declared = declaredBirthInputSchema.safeParse(profile.declaredBirthInput);
|
||||
if (!declared.success) throw new ConversationalRectificationError("profile_incomplete");
|
||||
|
||||
let price: number;
|
||||
try {
|
||||
price = ports.rectificationPriceCredits;
|
||||
} catch {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
if (!Number.isSafeInteger(price) || price < 1 || price > 1_000_000) {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
|
||||
const caseId = command.actionId;
|
||||
let existing: LoadedConversationalRectificationCase | null;
|
||||
try {
|
||||
existing = await ports.store.loadCase({ userId, caseId });
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
if (existing) {
|
||||
if (existing.pendingConsultationQuestion !== (command.pendingConsultationQuestion ?? null)) {
|
||||
throw new ConversationalRectificationError("action_conflict");
|
||||
}
|
||||
if (existing.billingState === "reserved") {
|
||||
try {
|
||||
await ports.billing.complete({
|
||||
userId,
|
||||
caseId,
|
||||
expectedVersion: 0,
|
||||
actionId: command.actionId,
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await ports.billing.release({
|
||||
userId,
|
||||
caseId,
|
||||
expectedVersion: 0,
|
||||
actionId: command.actionId,
|
||||
price,
|
||||
});
|
||||
} catch {
|
||||
throw new ConversationalRectificationError("billing_failed");
|
||||
}
|
||||
throw safeFailure(error);
|
||||
}
|
||||
} else if (existing.billingState !== "charged"
|
||||
&& existing.billingState !== "migration_waived") {
|
||||
throw new ConversationalRectificationError("billing_failed");
|
||||
}
|
||||
return publicTurn(existing);
|
||||
}
|
||||
|
||||
let reserved = false;
|
||||
try {
|
||||
const reservation = await ports.billing.reserve({
|
||||
userId,
|
||||
caseId,
|
||||
expectedVersion: 0,
|
||||
actionId: command.actionId,
|
||||
price,
|
||||
});
|
||||
reserved = reservation.billingState === "reserved";
|
||||
const computed = await ports.buildTechnicalPacket({
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: ports.asOfDate(),
|
||||
declaredBirthInput: declared.data,
|
||||
privateCandidate: null,
|
||||
evidence: [],
|
||||
});
|
||||
const narrative = await generateRectificationNarrative({
|
||||
phase: "first",
|
||||
packet: computed.packet,
|
||||
generator: ports.narrativeGenerator,
|
||||
});
|
||||
const privateCandidate = privateCandidateFromPacket({
|
||||
packet: computed.packet,
|
||||
resultId: computed.resultId,
|
||||
iteration: 0,
|
||||
});
|
||||
const firstTurn = turnFromNarrative({
|
||||
caseId,
|
||||
turnVersion: 0,
|
||||
pendingConsultationQuestion: command.pendingConsultationQuestion ?? null,
|
||||
packet: computed.packet,
|
||||
narrative,
|
||||
evidence: [],
|
||||
});
|
||||
const created = await ports.store.createCaseWithFirstTurn({
|
||||
userId,
|
||||
caseId,
|
||||
expectedVersion: 0,
|
||||
actionId: command.actionId,
|
||||
revisionOfCaseId: profile.revisionOfCaseId,
|
||||
pendingConsultationQuestion: command.pendingConsultationQuestion ?? null,
|
||||
declaredBirthInput: declared.data,
|
||||
firstTurn,
|
||||
validationReceipt: narrative.validationReceipt,
|
||||
privateCandidate,
|
||||
});
|
||||
await ports.billing.complete({
|
||||
userId,
|
||||
caseId,
|
||||
expectedVersion: 0,
|
||||
actionId: command.actionId,
|
||||
});
|
||||
return publicTurn(created);
|
||||
} catch (error) {
|
||||
if (reserved) {
|
||||
try {
|
||||
await ports.billing.release({
|
||||
userId,
|
||||
caseId,
|
||||
expectedVersion: 0,
|
||||
actionId: command.actionId,
|
||||
price,
|
||||
});
|
||||
} catch {
|
||||
throw new ConversationalRectificationError("billing_failed");
|
||||
}
|
||||
}
|
||||
throw safeFailure(error);
|
||||
}
|
||||
},
|
||||
|
||||
async resume(userId, rawCommand) {
|
||||
const command = parseCommand("resume", rawCommand);
|
||||
const current = await load(userId, command.caseId);
|
||||
requireExactVersion(current, command.turnVersion);
|
||||
return publicTurn(current);
|
||||
},
|
||||
|
||||
async answer(userId, rawCommand) {
|
||||
const command = parseCommand("answer", rawCommand);
|
||||
const current = await load(userId, command.caseId);
|
||||
requireMutable(current);
|
||||
const evidence = extractedEvidence(command);
|
||||
|
||||
if (current.turnVersion === command.turnVersion + 1) {
|
||||
try {
|
||||
const replayed = await ports.store.saveTurn({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn: current.latestTurn,
|
||||
evidence,
|
||||
validationReceipt: latestReceipt(current),
|
||||
privateCandidate: current.privateCandidate,
|
||||
});
|
||||
return publicTurn(replayed);
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
}
|
||||
requireExactVersion(current, command.turnVersion);
|
||||
|
||||
const scoreableEvidence = evidence.filter((item) => item.scoreable === true
|
||||
&& item.extractionStatus !== "needs_clarification");
|
||||
const directionChange = directionChangePattern.test(command.answer);
|
||||
if (directionChange || scoreableEvidence.length === 0) {
|
||||
const next = nonScoringTurn({
|
||||
current,
|
||||
newEvidence: evidence,
|
||||
domain: command.domain,
|
||||
directionChange,
|
||||
});
|
||||
try {
|
||||
const saved = await ports.store.saveTurn({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn: next.turn,
|
||||
evidence,
|
||||
validationReceipt: next.receipt,
|
||||
privateCandidate: current.privateCandidate,
|
||||
});
|
||||
return publicTurn(saved);
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const allScoreable = [...current.eventEvidence, ...evidence]
|
||||
.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification");
|
||||
const computed = await ports.buildTechnicalPacket({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
asOfDate: ports.asOfDate(),
|
||||
declaredBirthInput: current.declaredBirthInput,
|
||||
privateCandidate: current.privateCandidate,
|
||||
evidence: allScoreable,
|
||||
});
|
||||
const phase = computed.packet.candidate.status === "ready_for_confirmation"
|
||||
? "final" as const
|
||||
: "intermediate" as const;
|
||||
const narrative = await generateRectificationNarrative({
|
||||
phase,
|
||||
packet: computed.packet,
|
||||
generator: ports.narrativeGenerator,
|
||||
});
|
||||
if (!narrative.allowEvidenceScoringAdvance) {
|
||||
const next = nonScoringTurn({
|
||||
current,
|
||||
newEvidence: evidence,
|
||||
domain: command.domain,
|
||||
directionChange: false,
|
||||
scoringFallback: true,
|
||||
});
|
||||
const saved = await ports.store.saveTurn({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn: next.turn,
|
||||
evidence,
|
||||
validationReceipt: narrative.validationReceipt,
|
||||
privateCandidate: current.privateCandidate,
|
||||
});
|
||||
return publicTurn(saved);
|
||||
}
|
||||
const privateCandidate = privateCandidateFromPacket({
|
||||
packet: computed.packet,
|
||||
resultId: computed.resultId,
|
||||
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
|
||||
});
|
||||
const turn = turnFromNarrative({
|
||||
caseId: command.caseId,
|
||||
turnVersion: command.turnVersion + 1,
|
||||
pendingConsultationQuestion: current.pendingConsultationQuestion,
|
||||
packet: computed.packet,
|
||||
narrative,
|
||||
evidence: [...current.eventEvidence, ...evidence],
|
||||
});
|
||||
const saved = await ports.store.saveTurn({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn,
|
||||
evidence,
|
||||
validationReceipt: narrative.validationReceipt,
|
||||
privateCandidate,
|
||||
});
|
||||
return publicTurn(saved);
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
},
|
||||
|
||||
async pause(userId, rawCommand) {
|
||||
const command = parseCommand("pause", rawCommand);
|
||||
const current = await load(userId, command.caseId);
|
||||
if (current.turnVersion === command.turnVersion + 1 && current.status === "paused") {
|
||||
try {
|
||||
const replayed = await ports.store.pause({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn: current.latestTurn,
|
||||
validationReceipt: latestReceipt(current),
|
||||
});
|
||||
return publicTurn(replayed);
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
}
|
||||
requireExactVersion(current, command.turnVersion);
|
||||
if (current.status !== "active" && current.status !== "confirming") {
|
||||
throw new ConversationalRectificationError("invalid_transition");
|
||||
}
|
||||
const next = changedTurn({
|
||||
current,
|
||||
status: "paused",
|
||||
narrative: boundedNarrative(current.latestTurn.narrative, "校正已暂停,现有证据和候选已保存;继续时不会重复扣点。"),
|
||||
});
|
||||
try {
|
||||
return publicTurn(await ports.store.pause({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn: next.turn,
|
||||
validationReceipt: next.receipt,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
},
|
||||
|
||||
async abandon(userId, rawCommand) {
|
||||
const command = parseCommand("abandon", rawCommand);
|
||||
const current = await load(userId, command.caseId);
|
||||
if (current.turnVersion === command.turnVersion + 1 && current.status === "abandoned") {
|
||||
try {
|
||||
return publicTurn(await ports.store.abandon({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn: current.latestTurn,
|
||||
validationReceipt: latestReceipt(current),
|
||||
}));
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
}
|
||||
requireExactVersion(current, command.turnVersion);
|
||||
requireMutable(current);
|
||||
const next = changedTurn({
|
||||
current,
|
||||
status: "abandoned",
|
||||
narrative: boundedNarrative(current.latestTurn.narrative, "本次校正已放弃;再次校正期间原有确认时间始终没有被替换。"),
|
||||
});
|
||||
try {
|
||||
return publicTurn(await ports.store.abandon({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
turn: next.turn,
|
||||
validationReceipt: next.receipt,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
},
|
||||
|
||||
async confirm(userId, rawCommand) {
|
||||
const command = parseCommand("confirm", rawCommand);
|
||||
const current = await load(userId, command.caseId);
|
||||
if (current.turnVersion === command.turnVersion + 1 && current.status === "completed") {
|
||||
const resultId = current.privateCandidate.resultId;
|
||||
if (!resultId) throw new ConversationalRectificationError("candidate_changed");
|
||||
try {
|
||||
return publicTurn(await ports.store.confirm({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
resultId,
|
||||
time: command.time,
|
||||
calculationVersion: current.privateCandidate.calculationVersion,
|
||||
turn: current.latestTurn,
|
||||
validationReceipt: latestReceipt(current),
|
||||
}));
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
}
|
||||
requireExactVersion(current, command.turnVersion);
|
||||
const resultId = current.privateCandidate.resultId;
|
||||
if (current.status !== "confirming"
|
||||
|| current.latestTurn.candidate.status !== "ready_for_confirmation"
|
||||
|| !resultId
|
||||
|| current.privateCandidate.representativeTime !== command.time
|
||||
|| current.latestTurn.candidate.representativeTime !== command.time) {
|
||||
throw new ConversationalRectificationError("candidate_changed");
|
||||
}
|
||||
const next = changedTurn({
|
||||
current,
|
||||
status: "completed",
|
||||
narrative: boundedNarrative(
|
||||
current.latestTurn.narrative,
|
||||
current.pendingConsultationQuestion
|
||||
? "你已明确确认这个候选时间。现在可以使用新确认时间继续回答原问题。"
|
||||
: "你已明确确认这个候选时间,账户当前排盘时间已原子更新。",
|
||||
),
|
||||
});
|
||||
try {
|
||||
return publicTurn(await ports.store.confirm({
|
||||
userId,
|
||||
caseId: command.caseId,
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
resultId,
|
||||
time: command.time,
|
||||
calculationVersion: current.privateCandidate.calculationVersion,
|
||||
turn: next.turn,
|
||||
validationReceipt: next.receipt,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw safeFailure(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createConversationalRectificationService,
|
||||
type ConversationalRectificationServicePorts,
|
||||
} from "../src/lib/conversational-rectification/orchestrator.ts";
|
||||
import { conversationalRectificationTurnSchema } from "../src/lib/conversational-rectification/contracts.ts";
|
||||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||||
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
|
||||
import type {
|
||||
ConversationalRectificationTurnInput,
|
||||
LoadedConversationalRectificationCase,
|
||||
StoredConversationalRectificationCase,
|
||||
} from "../src/lib/conversational-rectification/store.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000701";
|
||||
const startActionId = "00000000-0000-4000-8000-000000000702";
|
||||
const answerActionId = "00000000-0000-4000-8000-000000000703";
|
||||
const pauseActionId = "00000000-0000-4000-8000-000000000704";
|
||||
const resumeActionId = "00000000-0000-4000-8000-000000000705";
|
||||
const confirmActionId = "00000000-0000-4000-8000-000000000706";
|
||||
const priorCaseId = "00000000-0000-4000-8000-000000000707";
|
||||
const resultId = "00000000-0000-4000-8000-000000000708";
|
||||
|
||||
const declaredBirthInput = {
|
||||
source: "approximate" as const,
|
||||
birthDate: "2000-01-01",
|
||||
reportedTime: "05:20",
|
||||
uncertaintyBeforeMinutes: 30 as const,
|
||||
uncertaintyAfterMinutes: 30 as const,
|
||||
birthTimeClue: null,
|
||||
birthplace: {
|
||||
countryCode: "CN",
|
||||
provinceCode: "110000",
|
||||
cityCode: "110000-city",
|
||||
latitude: 39.9042,
|
||||
longitude: 116.4074,
|
||||
timezoneOffset: 8,
|
||||
},
|
||||
};
|
||||
|
||||
function packet(ready = false): RectificationTechnicalPacket {
|
||||
return {
|
||||
calculationVersion: "rectification-technical-v1",
|
||||
candidate: {
|
||||
status: ready ? "ready_for_confirmation" : "pending_validation",
|
||||
representativeTime: ready ? "05:18" : "05:20",
|
||||
range: ready
|
||||
? { startTime: "05:16", endTime: "05:20" }
|
||||
: { startTime: "04:50", endTime: "05:50" },
|
||||
},
|
||||
useBoundary: ready
|
||||
? "该候选已达到确认门槛,但必须由用户明确确认后才能替换当前排盘时间。"
|
||||
: "该时间仅是待验证候选,必须由用户明确确认后才能替换当前排盘时间。",
|
||||
candidateModelRefs: ["candidate-model-v1"],
|
||||
candidateDifferenceRefs: ["consult-d1", "consult-d9", "consult-d10"],
|
||||
candidateWeights: { "05:16": 0.4, "05:18": 0.6 },
|
||||
partitionIds: ["private-partition"],
|
||||
d1Stability: "stable",
|
||||
boundaryDistanceMinutes: 2,
|
||||
sensitivityScope: {
|
||||
source: "time_linked_candidate_scan_samples",
|
||||
rangeStart: ready ? "05:16" : "04:50",
|
||||
rangeEnd: ready ? "05:20" : "05:50",
|
||||
sampleTimes: ready ? ["05:16", "05:20"] : ["04:50", "05:20", "05:50"],
|
||||
},
|
||||
stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["consult-d1"] }],
|
||||
sensitiveLayers: [
|
||||
{ layer: "D9", values: ["Aries", "Leo"], referenceIds: ["consult-d9"] },
|
||||
{ layer: "D10", values: ["Taurus", "Libra"], referenceIds: ["consult-d10"] },
|
||||
],
|
||||
supportedSensitiveLayers: ["D9", "D10"],
|
||||
scoredHistoricalEvidence: ready ? [{
|
||||
evidenceId: "00000000-0000-4000-8000-000000000709",
|
||||
domain: "career",
|
||||
candidateTime: "05:18",
|
||||
score: 8,
|
||||
ruleRefs: ["vim-career"],
|
||||
}] : [],
|
||||
suggestedDomains: [
|
||||
{ domain: "relationship", layer: "D9", reason: "D9 在候选范围内呈现 Aries / Leo 差异,可用已发生的关系事件区分。" },
|
||||
{ domain: "career", layer: "D10", reason: "D10 在候选范围内呈现 Taurus / Libra 差异,可用已发生的事业事件区分。" },
|
||||
],
|
||||
referenceIds: ["consult-d1", "consult-d9", "consult-d10"],
|
||||
futureWindows: [{
|
||||
label: "未来事业背景窗口",
|
||||
startDate: "2028-03-01",
|
||||
endDate: "2028-05-31",
|
||||
scoreable: false,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function validGenerator(events: string[]) {
|
||||
return {
|
||||
modelId: "synthetic-rectification-model",
|
||||
async generate(prompt: string) {
|
||||
events.push("narrative");
|
||||
const request = JSON.parse(prompt) as {
|
||||
phase: "first" | "intermediate" | "final";
|
||||
packet: Omit<ReturnType<typeof packet>, "candidate"> & {
|
||||
candidate: ReturnType<typeof packet>["candidate"] & {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const value = request.packet;
|
||||
const domains = value.suggestedDomains.map((item) => item.domain);
|
||||
const narrative = [
|
||||
`${value.candidate.representativeTime} 是待验证候选。`,
|
||||
"D1(Cancer)保持稳定。",
|
||||
"D9(Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。",
|
||||
"D10(Taurus / Libra)呈现分钟敏感差异,事业事件可区分 D10。",
|
||||
request.phase === "final" ? "当前证据已形成候选总结。" : "请提供已经发生的真实事件,写明哪一年、哪一月以及发生了什么。",
|
||||
"这仅是候选,必须由你确认后才会替换当前排盘时间。",
|
||||
].join("");
|
||||
return { text: JSON.stringify({
|
||||
narrative,
|
||||
candidateStatus: value.candidate.status,
|
||||
representativeTime: value.candidate.representativeTime,
|
||||
rangeStart: value.candidate.rangeStart,
|
||||
rangeEnd: value.candidate.rangeEnd,
|
||||
useBoundary: value.useBoundary,
|
||||
stableLayers: value.stableLayers.map((item) => item.layer),
|
||||
sensitiveLayers: value.sensitiveLayers.map((item) => item.layer),
|
||||
referenceIds: [],
|
||||
domainReasons: value.suggestedDomains.map((item) => ({ ...item })),
|
||||
evidenceRequest: request.phase === "final" ? null : {
|
||||
domains,
|
||||
datePrecision: "month_preferred",
|
||||
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
|
||||
},
|
||||
}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type MutableCase = {
|
||||
row: LoadedConversationalRectificationCase;
|
||||
};
|
||||
|
||||
function harness(options: {
|
||||
readonly packetFailure?: Error;
|
||||
readonly completeFailures?: number;
|
||||
readonly releaseFailure?: boolean;
|
||||
} = {}) {
|
||||
const events: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
const cases = new Map<string, MutableCase>();
|
||||
const receipts = new Map<string, { input: unknown; response: StoredConversationalRectificationCase }>();
|
||||
let packetBuilds = 0;
|
||||
let reserveCount = 0;
|
||||
let releaseCount = 0;
|
||||
let remainingCompleteFailures = options.completeFailures ?? 0;
|
||||
let billingState: LoadedConversationalRectificationCase["billingState"] = null;
|
||||
|
||||
function updateBillingState(next: LoadedConversationalRectificationCase["billingState"]) {
|
||||
billingState = next;
|
||||
for (const [caseId, value] of cases) {
|
||||
cases.set(caseId, { row: { ...value.row, billingState: next } });
|
||||
}
|
||||
}
|
||||
|
||||
function stored(input: {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly turn: ConversationalRectificationTurnInput;
|
||||
readonly privateCandidate: LoadedConversationalRectificationCase["privateCandidate"];
|
||||
readonly evidence?: LoadedConversationalRectificationCase["eventEvidence"];
|
||||
readonly receipts?: LoadedConversationalRectificationCase["validationReceipts"];
|
||||
readonly revisionOfCaseId?: string | null;
|
||||
}): LoadedConversationalRectificationCase {
|
||||
return {
|
||||
caseId: input.caseId,
|
||||
userId: input.userId,
|
||||
status: input.turn.status === "completed" ? "completed" : input.turn.status,
|
||||
turnVersion: input.turn.turnVersion,
|
||||
revisionOfCaseId: input.revisionOfCaseId ?? priorCaseId,
|
||||
importedFromCaseId: null,
|
||||
baselineActiveTime: "04:58",
|
||||
pendingConsultationQuestion: input.turn.pendingConsultationQuestion,
|
||||
billingState,
|
||||
latestTurn: conversationalRectificationTurnSchema.parse(input.turn),
|
||||
declaredBirthInput,
|
||||
privateCandidate: input.privateCandidate,
|
||||
eventEvidence: input.evidence ?? [],
|
||||
validationReceipts: input.receipts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function replay(actionId: string, input: unknown, make: () => LoadedConversationalRectificationCase) {
|
||||
const prior = receipts.get(actionId);
|
||||
if (prior) {
|
||||
assert.deepEqual(input, prior.input);
|
||||
return prior.response;
|
||||
}
|
||||
const response = make();
|
||||
receipts.set(actionId, { input: structuredClone(input), response });
|
||||
return response;
|
||||
}
|
||||
|
||||
const store: ConversationalRectificationServicePorts["store"] = {
|
||||
async loadCase(input) {
|
||||
const value = input.caseId
|
||||
? cases.get(input.caseId)?.row
|
||||
: [...cases.values()].at(-1)?.row;
|
||||
return value ?? null;
|
||||
},
|
||||
async createCaseWithFirstTurn(input) {
|
||||
events.push("create");
|
||||
mutations.push("create");
|
||||
return replay(input.actionId, input, () => {
|
||||
const row = stored({
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
turn: input.firstTurn,
|
||||
privateCandidate: input.privateCandidate,
|
||||
receipts: [input.validationReceipt],
|
||||
revisionOfCaseId: input.revisionOfCaseId,
|
||||
});
|
||||
cases.set(input.caseId, { row });
|
||||
return row;
|
||||
});
|
||||
},
|
||||
async saveTurn(input) {
|
||||
mutations.push("saveTurn");
|
||||
return replay(input.actionId, input, () => {
|
||||
const current = cases.get(input.turn.caseId)?.row;
|
||||
assert.ok(current);
|
||||
if (current.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||||
const row = stored({
|
||||
userId: input.userId,
|
||||
caseId: input.turn.caseId,
|
||||
turn: input.turn,
|
||||
privateCandidate: input.privateCandidate,
|
||||
evidence: [...current.eventEvidence, ...input.evidence],
|
||||
receipts: [...current.validationReceipts, input.validationReceipt],
|
||||
revisionOfCaseId: current.revisionOfCaseId,
|
||||
});
|
||||
cases.set(input.turn.caseId, { row });
|
||||
return row;
|
||||
});
|
||||
},
|
||||
async pause(input) {
|
||||
mutations.push("pause");
|
||||
return replay(input.actionId, input, () => {
|
||||
const current = cases.get(input.turn.caseId)?.row;
|
||||
assert.ok(current);
|
||||
if (current.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||||
const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn,
|
||||
receipts: [...current.validationReceipts, input.validationReceipt] });
|
||||
cases.set(input.turn.caseId, { row });
|
||||
return row;
|
||||
});
|
||||
},
|
||||
async abandon(input) {
|
||||
mutations.push("abandon");
|
||||
return replay(input.actionId, input, () => {
|
||||
const current = cases.get(input.turn.caseId)?.row;
|
||||
assert.ok(current);
|
||||
const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn,
|
||||
receipts: [...current.validationReceipts, input.validationReceipt] });
|
||||
cases.set(input.turn.caseId, { row });
|
||||
return row;
|
||||
});
|
||||
},
|
||||
async confirm(input) {
|
||||
mutations.push("confirm");
|
||||
return replay(input.actionId, input, () => {
|
||||
const current = cases.get(input.turn.caseId)?.row;
|
||||
assert.ok(current);
|
||||
const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn,
|
||||
receipts: [...current.validationReceipts, input.validationReceipt] });
|
||||
cases.set(input.turn.caseId, { row });
|
||||
return row;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const ports: ConversationalRectificationServicePorts = {
|
||||
get rectificationPriceCredits() {
|
||||
events.push("price");
|
||||
return 9;
|
||||
},
|
||||
store,
|
||||
billing: {
|
||||
async reserve(input) {
|
||||
reserveCount += 1;
|
||||
events.push(`reserve:${input.price}`);
|
||||
updateBillingState("reserved");
|
||||
return { success: true, credits: 91, billingState: "reserved" };
|
||||
},
|
||||
async complete() {
|
||||
events.push("complete");
|
||||
if (remainingCompleteFailures > 0) {
|
||||
remainingCompleteFailures -= 1;
|
||||
throw new Error("complete response unavailable");
|
||||
}
|
||||
updateBillingState("charged");
|
||||
return { success: true, credits: 91, billingState: "charged" };
|
||||
},
|
||||
async release() {
|
||||
releaseCount += 1;
|
||||
events.push("release");
|
||||
if (options.releaseFailure) throw new Error("release SQL detail");
|
||||
updateBillingState("released");
|
||||
return { success: true, credits: 100, billingState: "released" };
|
||||
},
|
||||
},
|
||||
async loadDeclaredProfile() {
|
||||
events.push("profile");
|
||||
return { declaredBirthInput, revisionOfCaseId: priorCaseId };
|
||||
},
|
||||
async buildTechnicalPacket(input) {
|
||||
packetBuilds += 1;
|
||||
events.push(input.evidence.length > 0 ? "score-packet" : "packet");
|
||||
if (options.packetFailure) throw options.packetFailure;
|
||||
return input.evidence.length > 0
|
||||
? { packet: packet(true), resultId }
|
||||
: { packet: packet(false), resultId: null };
|
||||
},
|
||||
narrativeGenerator: validGenerator(events),
|
||||
asOfDate: () => "2026-07-21",
|
||||
};
|
||||
|
||||
return {
|
||||
events,
|
||||
mutations,
|
||||
cases,
|
||||
service: createConversationalRectificationService(ports),
|
||||
counts: () => ({ packetBuilds, reserveCount, releaseCount }),
|
||||
};
|
||||
}
|
||||
|
||||
async function start(value: ReturnType<typeof harness>, pendingConsultationQuestion: string | null = "我的工作何时变化?") {
|
||||
return value.service.start(userId, {
|
||||
type: "start",
|
||||
actionId: startActionId,
|
||||
pendingConsultationQuestion,
|
||||
});
|
||||
}
|
||||
|
||||
test("start validates profile, reads server price, reserves, computes, saves, then charges", async () => {
|
||||
const value = harness();
|
||||
const turn = await start(value);
|
||||
|
||||
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "narrative", "create", "complete"]);
|
||||
assert.equal(turn.caseId, startActionId);
|
||||
assert.equal(turn.turnVersion, 0);
|
||||
assert.equal(turn.pendingConsultationQuestion, "我的工作何时变化?");
|
||||
assert.deepEqual(turn.technicalReceipt.sensitiveLayers, ["D9", "D10"]);
|
||||
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
|
||||
assert.equal(value.cases.get(startActionId)?.row.revisionOfCaseId, priorCaseId);
|
||||
assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58");
|
||||
});
|
||||
|
||||
test("start rejects a client price before profile, billing, or calculation", async () => {
|
||||
const value = harness();
|
||||
await assert.rejects(value.service.start(userId, {
|
||||
type: "start", actionId: startActionId, price: 0,
|
||||
} as never), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "invalid_command");
|
||||
assert.deepEqual(value.events, []);
|
||||
});
|
||||
|
||||
test("every post-reservation failure releases exactly once and never leaks its cause", async () => {
|
||||
const raw = "SQL model browser secret detail";
|
||||
const value = harness({ packetFailure: new Error(raw) });
|
||||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "service_unavailable" && !error.message.includes(raw));
|
||||
assert.equal(value.counts().releaseCount, 1);
|
||||
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "release"]);
|
||||
});
|
||||
|
||||
test("a start retry settles an existing reservation without reserving or computing again", async () => {
|
||||
const value = harness({ completeFailures: 1, releaseFailure: true });
|
||||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "billing_failed");
|
||||
assert.equal(value.cases.get(startActionId)?.row.billingState, "reserved");
|
||||
|
||||
const replayed = await start(value);
|
||||
|
||||
assert.equal(replayed.status, "active");
|
||||
assert.equal(value.cases.get(startActionId)?.row.billingState, "charged");
|
||||
assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 });
|
||||
assert.deepEqual(value.events.slice(-3), ["profile", "price", "complete"]);
|
||||
});
|
||||
|
||||
test("a settlement failure releases its created case once and cannot replay as success", async () => {
|
||||
const value = harness({ completeFailures: 1 });
|
||||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "service_unavailable");
|
||||
assert.equal(value.cases.get(startActionId)?.row.billingState, "released");
|
||||
assert.deepEqual(value.events.slice(-3), ["create", "complete", "release"]);
|
||||
|
||||
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "billing_failed");
|
||||
assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 });
|
||||
});
|
||||
|
||||
test("clear historical evidence is extracted, scored, narrated, recapped, and atomically saved", async () => {
|
||||
const value = harness();
|
||||
await start(value);
|
||||
const turn = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2021年7月毕业,并在2022年3月去外地工作",
|
||||
});
|
||||
|
||||
assert.equal(value.counts().packetBuilds, 2);
|
||||
assert.equal(turn.status, "confirming");
|
||||
assert.equal(turn.candidate.status, "ready_for_confirmation");
|
||||
assert.equal(turn.turnVersion, 1);
|
||||
assert.equal(turn.evidenceRecap.length, 2);
|
||||
const saved = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||||
assert.equal(saved.length, 2);
|
||||
assert.ok(saved.every((item) => item.rawText === "2021年7月毕业,并在2022年3月去外地工作"));
|
||||
assert.ok(saved.every((item) => item.scoreable === true));
|
||||
assert.ok(value.events.includes("score-packet"));
|
||||
});
|
||||
|
||||
test("vague, future, and unmatched answers stay conversational and never score", async () => {
|
||||
for (const [answer, domain] of [
|
||||
["后来换了工作", undefined],
|
||||
["2099年3月开始新工作", undefined],
|
||||
["这些都不符合,我想从家庭变化说起", "family"],
|
||||
] as const) {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
const turn = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer,
|
||||
...(domain ? { domain } : {}),
|
||||
});
|
||||
assert.equal(value.counts().packetBuilds, 1);
|
||||
assert.equal(turn.status, "active");
|
||||
assert.match(turn.narrative, /年月|已发生|已经发生|换个方向|未来/);
|
||||
assert.doesNotMatch(turn.narrative, /A[.、:]|B[.、:]|2006.?2011/);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.scoreable, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("pause persists, resume reads it without another charge, and stale commands are stable", async () => {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
const paused = await value.service.pause(userId, {
|
||||
type: "pause", caseId: startActionId, actionId: pauseActionId, turnVersion: 0,
|
||||
});
|
||||
assert.equal(paused.status, "paused");
|
||||
const resumed = await value.service.resume(userId, {
|
||||
type: "resume", caseId: startActionId, actionId: resumeActionId, turnVersion: 1,
|
||||
});
|
||||
assert.deepEqual(resumed, paused);
|
||||
assert.equal(value.counts().reserveCount, 1);
|
||||
await assert.rejects(value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2021年7月毕业",
|
||||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "stale_turn");
|
||||
});
|
||||
|
||||
test("a lost-response retry replays the saved answer without rescoring or regenerating", async () => {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
const command = {
|
||||
type: "answer" as const,
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2021年7月毕业,并在2022年3月去外地工作",
|
||||
};
|
||||
const first = await value.service.answer(userId, command);
|
||||
const before = [...value.events];
|
||||
const replayed = await value.service.answer(userId, command);
|
||||
assert.deepEqual(replayed, first);
|
||||
assert.equal(value.counts().packetBuilds, 2);
|
||||
assert.deepEqual(value.events, before);
|
||||
});
|
||||
|
||||
test("confirm delegates to the atomic store call, preserves the old baseline until then, and returns the saved question", async () => {
|
||||
const value = harness();
|
||||
await start(value, "请继续回答原来的事业问题");
|
||||
const ready = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2021年7月毕业,并在2022年3月去外地工作",
|
||||
});
|
||||
assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58");
|
||||
const before = value.mutations.length;
|
||||
const confirmed = await value.service.confirm(userId, {
|
||||
type: "confirm", caseId: startActionId, actionId: confirmActionId,
|
||||
turnVersion: ready.turnVersion, time: "05:18",
|
||||
});
|
||||
assert.deepEqual(value.mutations.slice(before), ["confirm"]);
|
||||
assert.equal(confirmed.status, "completed");
|
||||
assert.equal(confirmed.candidate.status, "confirmed");
|
||||
assert.equal(confirmed.pendingConsultationQuestion, "请继续回答原来的事业问题");
|
||||
assert.deepEqual(confirmed.actions, ["continue_original_question"]);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createBirthTimeConversationPostHandler,
|
||||
type BirthTimeConversationRouteService,
|
||||
} from "../src/app/api/birth-time-conversation/route.ts";
|
||||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000711";
|
||||
const actionId = "00000000-0000-4000-8000-000000000712";
|
||||
const caseId = "00000000-0000-4000-8000-000000000713";
|
||||
const requestId = "00000000-0000-4000-8000-000000000714";
|
||||
|
||||
const turn = {
|
||||
caseId,
|
||||
journeyProtocol: "conversational-evidence-v3" as const,
|
||||
status: "active" as const,
|
||||
turnVersion: 2,
|
||||
narrative: "这是经过服务端验证的合成校正解释。",
|
||||
candidate: {
|
||||
status: "pending_validation" as const,
|
||||
representativeTime: "05:20",
|
||||
rangeStart: "05:10",
|
||||
rangeEnd: "05:30",
|
||||
},
|
||||
technicalReceipt: {
|
||||
calculationVersion: "rectification-technical-v1",
|
||||
stableLayers: ["D1"],
|
||||
sensitiveLayers: ["D9", "D10"],
|
||||
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
|
||||
},
|
||||
evidenceRequest: {
|
||||
domains: ["relationship" as const, "career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
freeTextAllowed: true as const,
|
||||
},
|
||||
evidenceRecap: [],
|
||||
actions: ["answer" as const, "pause" as const, "abandon" as const],
|
||||
pendingConsultationQuestion: null,
|
||||
};
|
||||
|
||||
function request(body: unknown, events: string[]) {
|
||||
return {
|
||||
headers: new Headers({ "x-request-id": requestId }),
|
||||
async json() {
|
||||
events.push("body");
|
||||
return body;
|
||||
},
|
||||
} as Request;
|
||||
}
|
||||
|
||||
function service(overrides: Partial<BirthTimeConversationRouteService> = {}): BirthTimeConversationRouteService {
|
||||
const response = async () => turn;
|
||||
return {
|
||||
start: response,
|
||||
resume: response,
|
||||
answer: response,
|
||||
pause: response,
|
||||
abandon: response,
|
||||
confirm: response,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("authentication happens before body parsing and unauthenticated requests create no privileged service", async () => {
|
||||
const events: string[] = [];
|
||||
let creates = 0;
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
async authenticate() {
|
||||
events.push("auth");
|
||||
return null;
|
||||
},
|
||||
async createService() {
|
||||
creates += 1;
|
||||
return service();
|
||||
},
|
||||
createRequestId: () => requestId,
|
||||
});
|
||||
const response = await handler(request({ type: "start", actionId }, events));
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(events, ["auth"]);
|
||||
assert.equal(creates, 0);
|
||||
assert.deepEqual(await response.json(), {
|
||||
code: "authentication_required",
|
||||
status: 401,
|
||||
error: "请先登录",
|
||||
message: "登录后才能继续生时校正。",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("strict invalid commands return 400 before admin, billing, or service construction", async () => {
|
||||
const events: string[] = [];
|
||||
let creates = 0;
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
async authenticate() {
|
||||
events.push("auth");
|
||||
return { userId, context: null };
|
||||
},
|
||||
async createService() {
|
||||
creates += 1;
|
||||
return service();
|
||||
},
|
||||
createRequestId: () => requestId,
|
||||
});
|
||||
const response = await handler(request({ type: "start", actionId, price: 0 }, events));
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(events, ["auth", "body"]);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal((await response.json() as { code: string }).code, "invalid_command");
|
||||
});
|
||||
|
||||
test("valid commands dispatch exactly one authenticated service method", async () => {
|
||||
const events: string[] = [];
|
||||
const calls: unknown[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
async authenticate() {
|
||||
events.push("auth");
|
||||
return { userId, context: { authenticated: true } };
|
||||
},
|
||||
async createService() {
|
||||
events.push("service");
|
||||
return service({
|
||||
async answer(receivedUserId, command) {
|
||||
calls.push([receivedUserId, command]);
|
||||
return turn;
|
||||
},
|
||||
});
|
||||
},
|
||||
createRequestId: () => requestId,
|
||||
});
|
||||
const command = { type: "answer", caseId, actionId, turnVersion: 1, answer: "2021年7月毕业" };
|
||||
const response = await handler(request(command, events));
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(events, ["auth", "body", "service"]);
|
||||
assert.deepEqual(calls, [[userId, command]]);
|
||||
assert.deepEqual(await response.json(), turn);
|
||||
});
|
||||
|
||||
test("known conflicts and unavailable failures use stable safe Chinese responses", async () => {
|
||||
for (const [failure, status, code] of [
|
||||
[new ConversationalRectificationError("stale_turn"), 409, "stale_turn"],
|
||||
[new ConversationalRectificationError("action_conflict"), 409, "action_conflict"],
|
||||
[new ConversationalRectificationError("service_unavailable"), 503, "service_unavailable"],
|
||||
] as const) {
|
||||
const logs: unknown[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
async authenticate() { return { userId, context: null }; },
|
||||
async createService() {
|
||||
return service({ async resume() { throw failure; } });
|
||||
},
|
||||
createRequestId: () => requestId,
|
||||
log: (entry) => logs.push(entry),
|
||||
});
|
||||
const response = await handler(request({ type: "resume", caseId, actionId, turnVersion: 1 }, []));
|
||||
const body = await response.json() as { code: string; error: string; message: string };
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(body.code, code);
|
||||
assert.match(`${body.error}${body.message}`, /校正|服务|进度|稍后|重试/);
|
||||
assert.deepEqual(logs, [{ requestId, actionId, caseId, code }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown SQL, model, and browser errors are never exposed or logged", async () => {
|
||||
const raw = "duplicate key SQL WebKit DOMException model response with token=secret";
|
||||
const logs: unknown[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
async authenticate() { return { userId, context: null }; },
|
||||
async createService() {
|
||||
return service({ async pause() { throw new Error(raw); } });
|
||||
},
|
||||
createRequestId: () => requestId,
|
||||
log: (entry) => logs.push(entry),
|
||||
});
|
||||
const response = await handler(request({ type: "pause", caseId, actionId, turnVersion: 1 }, []));
|
||||
const serialized = JSON.stringify(await response.json());
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(serialized.includes(raw), false);
|
||||
assert.equal(JSON.stringify(logs).includes(raw), false);
|
||||
assert.deepEqual(logs, [{ requestId, actionId, caseId, code: "service_unavailable" }]);
|
||||
});
|
||||
|
||||
test("production route lazily creates privileged clients only after authentication and strict parsing", () => {
|
||||
const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(source, /^import .*supabase\/admin/m);
|
||||
const authenticateCall = source.indexOf("dependencies.authenticate(request)");
|
||||
const parseCall = source.indexOf("safeParse(await requestPayload(request))");
|
||||
const serviceCall = source.indexOf("dependencies.createService(authenticated)");
|
||||
assert.ok(authenticateCall < parseCall);
|
||||
assert.ok(parseCall < serviceCall);
|
||||
assert.match(source, /import\(["'].*supabase\/admin(?:\.ts)?["']\)/);
|
||||
assert.match(source, /process\.env\.RECTIFICATION_PRICE_CREDITS/);
|
||||
assert.doesNotMatch(source, /BIRTH_TIME_RECTIFICATION_PRICE_CREDITS/);
|
||||
assert.doesNotMatch(source, /command\.price|parsed\.data\.price/);
|
||||
});
|
||||
Reference in New Issue
Block a user