test: close conversational rectification review gaps

This commit is contained in:
Jesse_Chen
2026-07-21 15:06:42 +08:00
parent ffaa7dc9c5
commit 6827e0ca45
11 changed files with 685 additions and 54 deletions
@@ -10,6 +10,7 @@ import {
} from "../../../lib/conversational-rectification/errors.ts";
import {
createConversationalRectificationService,
conversationalRectificationTelemetryOutcome,
evidencePredatesBirthDate,
type ConversationalRectificationPacketBuildInput,
type ConversationalRectificationService,
@@ -691,7 +692,7 @@ async function dispatch(
}
function telemetryPhase(
turn: ConversationalRectificationTurn | null,
turn: Pick<ConversationalRectificationTurn, "status"> | null,
): ConversationalRectificationTelemetryPayload["phase"] {
switch (turn?.status) {
case "active": return "collecting_evidence";
@@ -740,6 +741,7 @@ export function createBirthTimeConversationPostHandler(
);
dependencies.createRequestId?.(request);
let actionKind: ConversationalRectificationTelemetryPayload["actionKind"] = "unknown";
let service: BirthTimeConversationRouteService | null = null;
try {
const authenticated = await dependencies.authenticate(request);
if (!authenticated) throw new ConversationalRectificationError("authentication_required");
@@ -748,29 +750,32 @@ export function createBirthTimeConversationPostHandler(
if (!parsed.success) throw new ConversationalRectificationError("invalid_command");
actionKind = parsed.data.type;
const service = await dependencies.createService(authenticated);
service = await dependencies.createService(authenticated);
const turn = await dispatch(service, authenticated.userId, parsed.data);
const outcome = conversationalRectificationTelemetryOutcome(service);
telemetry({
protocol: "conversational-evidence-v3",
phase: telemetryPhase(turn),
actionKind,
resultCategory: "success",
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: actionKind === "start" ? "unknown" : "unchanged",
billingState: outcome?.billingState ?? (actionKind === "start" ? "unknown" : "unchanged"),
errorCategory: "none",
deploymentSha,
});
return Response.json(turn);
} catch (error) {
const publicError = toConversationalRectificationPublicError(error);
const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null;
dependencies.log?.({ code: publicError.code });
telemetry({
protocol: "conversational-evidence-v3",
phase: "entry",
phase: telemetryPhase(outcome?.caseStatus ? { status: outcome.caseStatus } : null),
actionKind,
resultCategory: telemetryResultCategory(publicError.status),
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: publicError.code === "billing_failed" ? "unknown" : "not_applicable",
billingState: outcome?.billingState
?? (publicError.code === "billing_failed" ? "unknown" : "not_applicable"),
errorCategory: telemetryErrorCategory(publicError.code),
deploymentSha,
});
+18 -12
View File
@@ -7,15 +7,12 @@ type Check = {
};
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const gitCommit =
process.env.GITHUB_SHA
?? process.env.VERCEL_GIT_COMMIT_SHA
?? process.env.NEXT_PUBLIC_GIT_COMMIT
?? "unknown";
const rectificationV3CreationEnabled =
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false";
const rectificationV3MigrationsReady =
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
function deployedGitCommit(): string {
return process.env.GITHUB_SHA
?? process.env.VERCEL_GIT_COMMIT_SHA
?? process.env.NEXT_PUBLIC_GIT_COMMIT
?? "unknown";
}
function envCheck(names: string[]): Check {
const missing = names.filter((name) => !process.env[name]);
@@ -62,6 +59,12 @@ function aggregate(checks: Record<string, Check>) {
}
export async function GET() {
const gitCommit = deployedGitCommit();
const rectificationV3CreationEnabled =
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false";
const rectificationV3MigrationsReady =
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
const smokeSha = process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA?.trim().toLowerCase() ?? "";
const checks = {
web: { status: "ok" } satisfies Check,
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
@@ -70,9 +73,12 @@ export async function GET() {
jyotishApi: await jyotishApiCheck(),
};
const status = aggregate(checks);
const rectificationV3Ready = rectificationV3CreationEnabled
const deploymentHasFullSha = /^[0-9a-f]{40}$/.test(gitCommit);
const smokeMatchesDeployment = deploymentHasFullSha && smokeSha === gitCommit;
const rectificationV3Ready = status === "ok"
&& rectificationV3CreationEnabled
&& rectificationV3MigrationsReady
&& gitCommit !== "unknown";
&& smokeMatchesDeployment;
return NextResponse.json(
{
status,
@@ -85,7 +91,7 @@ export async function GET() {
protocol: "conversational-evidence-v3",
newCaseCreation: rectificationV3CreationEnabled ? "enabled" : "paused",
migrations: rectificationV3MigrationsReady ? "ready" : "unverified",
syntheticSmoke: "required",
syntheticSmoke: smokeMatchesDeployment ? "matched" : "pending",
readyForNewCases: rectificationV3Ready,
},
},
@@ -84,8 +84,8 @@ export function createConversationalRectificationTelemetry(
sink: ConversationalRectificationTelemetrySink = conversationalRectificationConsoleSink,
): ConversationalRectificationTelemetrySink {
return (input) => {
const payload = conversationalRectificationTelemetryPayloadSchema.parse(input);
try {
const payload = conversationalRectificationTelemetryPayloadSchema.parse(input);
sink(payload);
} catch { // no-excuse-ok: observability cannot break the product request
return;
@@ -99,6 +99,22 @@ export type ConversationalRectificationService = Readonly<{
confirm(userId: string, command: CommandOf<"confirm">): Promise<ConversationalRectificationTurn>;
}>;
export type ConversationalRectificationTelemetryOutcome = Readonly<{
billingState: "not_applicable" | "charged" | "released" | "migration_waived" | "unchanged" | "unknown";
caseStatus: ConversationalRectificationTurn["status"] | null;
}>;
const telemetryOutcomes = new WeakMap<
ConversationalRectificationService,
() => ConversationalRectificationTelemetryOutcome
>();
export function conversationalRectificationTelemetryOutcome(
service: ConversationalRectificationService,
): ConversationalRectificationTelemetryOutcome | null {
return telemetryOutcomes.get(service)?.() ?? null;
}
const transitionValidatorVersion = "conversational-rectification-orchestrator-v1";
const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/;
const genericUncertaintyPattern = /(?:不知道|不确定)/;
@@ -458,9 +474,25 @@ function requireMutable(current: LoadedConversationalRectificationCase) {
export function createConversationalRectificationService(
ports: ConversationalRectificationServicePorts,
): ConversationalRectificationService {
let lastTelemetryOutcome: ConversationalRectificationTelemetryOutcome = {
billingState: "not_applicable",
caseStatus: null,
};
function resetTelemetryOutcome() {
lastTelemetryOutcome = { billingState: "not_applicable", caseStatus: null };
}
function observeCase(
value: LoadedConversationalRectificationCase | StoredConversationalRectificationCase,
billingState: ConversationalRectificationTelemetryOutcome["billingState"] = "unchanged",
) {
lastTelemetryOutcome = { billingState, caseStatus: publicTurn(value).status };
}
async function load(userId: string, caseId: string) {
try {
return requireLoaded(await ports.store.loadCase({ userId, caseId }));
const current = requireLoaded(await ports.store.loadCase({ userId, caseId }));
observeCase(current);
return current;
} catch (error) {
throw safeFailure(error);
}
@@ -481,7 +513,9 @@ export function createConversationalRectificationService(
actionKind,
commandFingerprint: fingerprint,
});
return receipt ? publicTurn(receipt) : null;
if (!receipt) return null;
observeCase(receipt);
return publicTurn(receipt);
} catch (error) {
throw safeFailure(error);
}
@@ -493,6 +527,7 @@ export function createConversationalRectificationService(
actionId: string,
pendingConsultationQuestion: string | null = null,
): Promise<ConversationalRectificationTurn> {
resetTelemetryOutcome();
const importer = ports.store.importLegacy;
const loadLegacy = ports.loadLegacyCase;
if (!importer || !loadLegacy) {
@@ -502,6 +537,7 @@ export function createConversationalRectificationService(
try {
const existingByAction = await ports.store.loadCase({ userId, caseId: actionId });
if (existingByAction) {
observeCase(existingByAction);
if (existingByAction.importedFromCaseId !== legacyCaseId
|| existingByAction.billingState !== "migration_waived"
|| existingByAction.pendingConsultationQuestion !== pendingConsultationQuestion) {
@@ -513,6 +549,7 @@ export function createConversationalRectificationService(
if (current?.importedFromCaseId === legacyCaseId
&& current.billingState === "migration_waived"
&& current.pendingConsultationQuestion === pendingConsultationQuestion) {
observeCase(current);
return publicTurn(current);
}
if (current?.importedFromCaseId === legacyCaseId
@@ -579,6 +616,7 @@ export function createConversationalRectificationService(
validationReceipt: narrative.validationReceipt,
privateCandidate,
});
observeCase(imported, "migration_waived");
return publicTurn(imported);
} catch (error) {
if (error instanceof ConversationalRectificationError
@@ -588,6 +626,7 @@ export function createConversationalRectificationService(
if (winner?.importedFromCaseId === legacyCaseId
&& winner.billingState === "migration_waived"
&& winner.pendingConsultationQuestion === pendingConsultationQuestion) {
observeCase(winner);
return publicTurn(winner);
}
} catch {
@@ -623,9 +662,10 @@ export function createConversationalRectificationService(
return extracted;
}
return Object.freeze({
const service: ConversationalRectificationService = Object.freeze({
importLegacyCase,
async start(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("start", rawCommand);
let profile: ConversationalRectificationProfile;
try {
@@ -663,6 +703,7 @@ export function createConversationalRectificationService(
throw safeFailure(error);
}
if (existing) {
observeCase(existing);
if (existing.pendingConsultationQuestion !== (command.pendingConsultationQuestion ?? null)) {
throw new ConversationalRectificationError("action_conflict");
}
@@ -674,6 +715,7 @@ export function createConversationalRectificationService(
expectedVersion: 0,
actionId: command.actionId,
});
observeCase(existing, "charged");
} catch (error) {
try {
await ports.billing.release({
@@ -683,6 +725,7 @@ export function createConversationalRectificationService(
actionId: command.actionId,
price,
});
observeCase(existing, "released");
} catch {
throw new ConversationalRectificationError("billing_failed");
}
@@ -708,6 +751,7 @@ export function createConversationalRectificationService(
price,
});
reserved = reservation.billingState === "reserved";
if (reserved) lastTelemetryOutcome = { billingState: "unknown", caseStatus: null };
const computed = await ports.buildTechnicalPacket({
userId,
caseId,
@@ -746,12 +790,14 @@ export function createConversationalRectificationService(
validationReceipt: narrative.validationReceipt,
privateCandidate,
});
observeCase(created, "unknown");
await ports.billing.complete({
userId,
caseId,
expectedVersion: 0,
actionId: command.actionId,
});
observeCase(created, "charged");
return publicTurn(created);
} catch (error) {
if (reserved) {
@@ -763,6 +809,10 @@ export function createConversationalRectificationService(
actionId: command.actionId,
price,
});
lastTelemetryOutcome = {
billingState: "released",
caseStatus: lastTelemetryOutcome.caseStatus,
};
} catch {
throw new ConversationalRectificationError("billing_failed");
}
@@ -772,12 +822,14 @@ export function createConversationalRectificationService(
},
async resume(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("resume", rawCommand);
const current = await load(userId, command.caseId);
return publicTurn(current);
},
async answer(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("answer", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "save_turn", fingerprint);
@@ -1035,6 +1087,7 @@ export function createConversationalRectificationService(
},
async pause(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("pause", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "pause", fingerprint);
@@ -1081,6 +1134,7 @@ export function createConversationalRectificationService(
},
async abandon(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("abandon", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "abandon", fingerprint);
@@ -1124,6 +1178,7 @@ export function createConversationalRectificationService(
},
async confirm(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("confirm", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "confirm", fingerprint);
@@ -1186,4 +1241,6 @@ export function createConversationalRectificationService(
}
},
});
telemetryOutcomes.set(service, () => lastTelemetryOutcome);
return service;
}