test: close conversational rectification review gaps
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,12 @@ type Receipt = Readonly<{
|
||||
row: StoredConversationalRectificationCase;
|
||||
}>;
|
||||
|
||||
function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreation?: boolean } = {}) {
|
||||
function createSyntheticBackend(options: {
|
||||
legacy?: boolean;
|
||||
allowNewCaseCreation?: boolean;
|
||||
packetFailure?: boolean;
|
||||
packetEvidenceCalls?: string[][];
|
||||
} = {}) {
|
||||
const cases = new Map<string, LoadedConversationalRectificationCase>();
|
||||
const receipts = new Map<string, Receipt>();
|
||||
let activeTime = "04:58";
|
||||
@@ -364,6 +369,8 @@ function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreatio
|
||||
};
|
||||
},
|
||||
async buildTechnicalPacket(input) {
|
||||
if (options.packetFailure) throw new Error("synthetic packet failure");
|
||||
options.packetEvidenceCalls?.push(input.evidence.map((item) => item.id));
|
||||
const ready = input.evidence.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification").length >= 3;
|
||||
const packet = technicalPacket(ready);
|
||||
if (input.preserveCandidateRange && input.privateCandidate?.rangeStart && input.privateCandidate.rangeEnd) {
|
||||
@@ -428,7 +435,8 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
consent = clearBirthTimeConsultationConsent(consent, "chat-a");
|
||||
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "choice");
|
||||
|
||||
const backend = createSyntheticBackend();
|
||||
const packetEvidenceCalls: string[][] = [];
|
||||
const backend = createSyntheticBackend({ packetEvidenceCalls });
|
||||
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
@@ -450,6 +458,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
|
||||
assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false);
|
||||
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
|
||||
assert.equal(telemetry.at(-1)?.billingState, "charged");
|
||||
assert.equal(backend.activeTime(), "04:58", "revision must retain the old active minute");
|
||||
|
||||
turn = await post(handler, {
|
||||
@@ -467,11 +476,23 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
assert.match(turn.narrative, /还缺少.*明确时间/);
|
||||
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
|
||||
|
||||
const futureEvidenceAction = "00000000-0000-4000-8000-000000009050";
|
||||
turn = await post(handler, {
|
||||
type: "answer", caseId, actionId: futureEvidenceAction,
|
||||
turnVersion: turn.turnVersion, domain: "career", answer: "2027年计划换工作",
|
||||
});
|
||||
assert.match(turn.narrative, /未来事件只能作为背景.*不能用于校正评分/);
|
||||
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2027(未来,仅作背景)");
|
||||
const futureEvidenceId = turn.evidenceRecap.at(-1)?.id;
|
||||
assert.ok(futureEvidenceId);
|
||||
const historicalEvidenceIds: string[] = [];
|
||||
|
||||
turn = await post(handler, {
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009005",
|
||||
turnVersion: turn.turnVersion, domain: "career", answer: "2014年7月第一次正式入职",
|
||||
});
|
||||
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2014-07");
|
||||
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
|
||||
turn = await post(handler, {
|
||||
type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009006",
|
||||
turnVersion: turn.turnVersion,
|
||||
@@ -495,13 +516,17 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009008",
|
||||
turnVersion: turn.turnVersion, domain: "education", answer: "2011年6月大学毕业",
|
||||
});
|
||||
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
|
||||
turn = await post(secondDevice, {
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009009",
|
||||
turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
|
||||
});
|
||||
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
|
||||
assert.equal(turn.status, "confirming");
|
||||
assert.equal(turn.candidate.representativeTime, "05:18");
|
||||
assert.equal(backend.activeTime(), "04:58");
|
||||
assert.equal(packetEvidenceCalls.some((ids) => ids.includes(futureEvidenceId)), false);
|
||||
assert.deepEqual(packetEvidenceCalls.at(-1), historicalEvidenceIds);
|
||||
|
||||
const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
@@ -513,6 +538,8 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
}));
|
||||
assert.equal(wrong.status, 409);
|
||||
assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic");
|
||||
assert.equal(telemetry.at(-1)?.phase, "confirming");
|
||||
assert.equal(telemetry.at(-1)?.billingState, "unchanged");
|
||||
|
||||
turn = await post(secondDevice, {
|
||||
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011",
|
||||
@@ -565,7 +592,13 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
|
||||
test("legacy unfinished work imports once with migration waiver and no questionnaire or charge", async () => {
|
||||
const backend = createSyntheticBackend({ legacy: true });
|
||||
const first = await backend.service.start(userId, { type: "start", actionId: caseId });
|
||||
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
createService: async () => backend.service,
|
||||
telemetry: (payload) => telemetry.push(payload),
|
||||
});
|
||||
const first = await post(handler, { type: "start", actionId: caseId });
|
||||
const replay = await backend.service.start(userId, { type: "start", actionId: caseId });
|
||||
assert.deepEqual(replay, first);
|
||||
assert.equal(backend.cases.get(caseId)?.importedFromCaseId, backend.legacyCaseId);
|
||||
@@ -575,9 +608,10 @@ test("legacy unfinished work imports once with migration waiver and no questionn
|
||||
assert.deepEqual(first.candidate.rangeEnd, "05:50");
|
||||
assert.doesNotMatch(first.narrative, /2006-2011|2011-2016|哪个时间段/);
|
||||
assert.equal(JSON.stringify(first).includes("choiceAnswers"), false);
|
||||
assert.equal(telemetry.at(-1)?.billingState, "migration_waived");
|
||||
});
|
||||
|
||||
test("v3 telemetry rejects every field outside the privacy-safe category contract", () => {
|
||||
test("v3 telemetry drops invalid payloads without affecting the product request", () => {
|
||||
const emitted: unknown[] = [];
|
||||
const record = createConversationalRectificationTelemetry((payload) => emitted.push(payload));
|
||||
const valid = {
|
||||
@@ -595,11 +629,66 @@ test("v3 telemetry rejects every field outside the privacy-safe category contrac
|
||||
"narrative", "eventText", "birthDate", "birthTime", "email", "userId",
|
||||
"accessToken", "refreshToken", "modelPrompt", "caseId", "actionId",
|
||||
]) {
|
||||
assert.throws(() => record({ ...valid, [forbidden]: "private" } as never));
|
||||
assert.doesNotThrow(() => record({ ...valid, [forbidden]: "private" } as never));
|
||||
}
|
||||
assert.deepEqual(emitted, [valid]);
|
||||
});
|
||||
|
||||
test("telemetry reports released reservations and authentication rejects before service creation", async () => {
|
||||
const backend = createSyntheticBackend({ packetFailure: true });
|
||||
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
createService: async () => backend.service,
|
||||
telemetry: (payload) => telemetry.push(payload),
|
||||
});
|
||||
const failed = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "start", actionId: caseId }),
|
||||
}));
|
||||
assert.equal(failed.status, 503);
|
||||
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 0, releaseCount: 1, state: "released" });
|
||||
assert.equal(telemetry.at(-1)?.billingState, "released");
|
||||
assert.equal(telemetry.at(-1)?.errorCategory, "dependency");
|
||||
|
||||
let serviceCreations = 0;
|
||||
const unauthenticated = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => null,
|
||||
createService: async () => { serviceCreations += 1; return backend.service; },
|
||||
telemetry: () => undefined,
|
||||
});
|
||||
const rejected = await unauthenticated(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "start", actionId: caseId }),
|
||||
}));
|
||||
assert.equal(rejected.status, 401);
|
||||
assert.equal(serviceCreations, 0);
|
||||
});
|
||||
|
||||
test("authenticated route remains owner-bound", async () => {
|
||||
const backend = createSyntheticBackend();
|
||||
const started = await backend.service.start(userId, { type: "start", actionId: caseId });
|
||||
const foreign = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId: "00000000-0000-4000-8000-000000009999", context: {} }),
|
||||
createService: async () => backend.service,
|
||||
telemetry: () => undefined,
|
||||
});
|
||||
const response = await foreign(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "resume",
|
||||
caseId,
|
||||
actionId: "00000000-0000-4000-8000-000000009998",
|
||||
turnVersion: started.turnVersion,
|
||||
}),
|
||||
}));
|
||||
assert.equal(response.status, 404);
|
||||
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
|
||||
});
|
||||
|
||||
test("transient 502 replays the same command and terminal failures expose only stable Chinese copy", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const bodies: string[] = [];
|
||||
@@ -646,12 +735,20 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho
|
||||
GITHUB_SHA: process.env.GITHUB_SHA,
|
||||
RECTIFICATION_V3_CREATE_ENABLED: process.env.RECTIFICATION_V3_CREATE_ENABLED,
|
||||
RECTIFICATION_V3_MIGRATIONS_READY: process.env.RECTIFICATION_V3_MIGRATIONS_READY,
|
||||
RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA,
|
||||
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
};
|
||||
process.env.GITHUB_SHA = deploymentSha;
|
||||
process.env.RECTIFICATION_V3_CREATE_ENABLED = "true";
|
||||
process.env.RECTIFICATION_V3_MIGRATIONS_READY = "true";
|
||||
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = deploymentSha;
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.invalid";
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "synthetic-public-key";
|
||||
process.env["SUPABASE_SERVICE_ROLE_KEY"] = "synthetic-runtime-secret-never-return";
|
||||
process.env.OPENAI_API_KEY = "synthetic-model-key";
|
||||
globalThis.fetch = async () => Response.json({ status: "ok" });
|
||||
try {
|
||||
const { GET: healthGet } = await import(`../src/app/api/health/route.ts?e2e=${Date.now()}`);
|
||||
@@ -663,7 +760,7 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho
|
||||
protocol: "conversational-evidence-v3",
|
||||
newCaseCreation: "enabled",
|
||||
migrations: "ready",
|
||||
syntheticSmoke: "required",
|
||||
syntheticSmoke: "matched",
|
||||
readyForNewCases: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -781,6 +781,43 @@ test("production packet deterministically sends only the latest six supported ev
|
||||
);
|
||||
});
|
||||
|
||||
test("persistable future background evidence never reaches the production scorer", async () => {
|
||||
const scoreCalls: LifeEvent[][] = [];
|
||||
const engine = packetEngine({ scoreCalls });
|
||||
const historical = [
|
||||
syntheticEvidence(61, "education", "2018-06", "month"),
|
||||
syntheticEvidence(62, "relocation", "2020-09", "month"),
|
||||
syntheticEvidence(63, "career", "2024-03", "month"),
|
||||
];
|
||||
const future = {
|
||||
...syntheticEvidence(64, "career", "2027", "year"),
|
||||
rawText: "2027年计划换工作",
|
||||
eventSummary: "计划换工作",
|
||||
scoreable: false as const,
|
||||
};
|
||||
|
||||
await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput: {
|
||||
source: "approximate",
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: "05:20",
|
||||
uncertaintyBeforeMinutes: 30,
|
||||
uncertaintyAfterMinutes: 30,
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
},
|
||||
privateCandidate: null,
|
||||
evidence: [...historical, future],
|
||||
});
|
||||
|
||||
assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [[
|
||||
...historical.map((item) => item.id),
|
||||
]]);
|
||||
});
|
||||
|
||||
test("family evidence stays out of relationship scoring when three real scorer domains exist", async () => {
|
||||
const scoreCalls: LifeEvent[][] = [];
|
||||
const engine = packetEngine({ scoreCalls });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { GET as healthGet } from "../src/app/api/health/route.ts";
|
||||
|
||||
function serviceBlock(compose: string, service: string) {
|
||||
const match = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][a-z0-9_-]*:|^volumes:)`, "m"));
|
||||
@@ -83,3 +84,81 @@ test("production workflow rejects a SHA mismatch in verification", () => {
|
||||
/Verify production must use the tested SHA/,
|
||||
);
|
||||
});
|
||||
|
||||
test("v3 readiness requires healthy dependencies and smoke proof for the exact full deployment SHA", async () => {
|
||||
const keys = [
|
||||
"GITHUB_SHA", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY",
|
||||
"RECTIFICATION_V3_CREATE_ENABLED", "RECTIFICATION_V3_MIGRATIONS_READY",
|
||||
"RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA",
|
||||
] as const;
|
||||
const prior = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
||||
const originalFetch = globalThis.fetch;
|
||||
const currentSha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const oldSha = "89abcdef0123456789abcdef0123456789abcdef";
|
||||
Object.assign(process.env, {
|
||||
GITHUB_SHA: currentSha,
|
||||
NEXT_PUBLIC_SUPABASE_URL: "https://example.invalid",
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: "synthetic-public-key",
|
||||
SUPABASE_SERVICE_ROLE_KEY: "synthetic-service-key",
|
||||
OPENAI_API_KEY: "synthetic-model-key",
|
||||
RECTIFICATION_V3_CREATE_ENABLED: "true",
|
||||
RECTIFICATION_V3_MIGRATIONS_READY: "true",
|
||||
});
|
||||
globalThis.fetch = async () => Response.json({ status: "ok" });
|
||||
|
||||
async function readiness() {
|
||||
const response = await healthGet();
|
||||
const body = await response.json() as {
|
||||
status: string;
|
||||
rollout: { conversationalRectificationV3: {
|
||||
syntheticSmoke: string;
|
||||
readyForNewCases: boolean;
|
||||
} };
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
try {
|
||||
delete process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA;
|
||||
assert.deepEqual((await readiness()).rollout.conversationalRectificationV3, {
|
||||
protocol: "conversational-evidence-v3",
|
||||
newCaseCreation: "enabled",
|
||||
migrations: "ready",
|
||||
syntheticSmoke: "pending",
|
||||
readyForNewCases: false,
|
||||
});
|
||||
|
||||
process.env.GITHUB_SHA = "deadbee";
|
||||
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = "deadbee";
|
||||
assert.equal((await readiness()).rollout.conversationalRectificationV3.readyForNewCases, false);
|
||||
|
||||
process.env.GITHUB_SHA = currentSha;
|
||||
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = oldSha;
|
||||
assert.equal((await readiness()).rollout.conversationalRectificationV3.syntheticSmoke, "pending");
|
||||
|
||||
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = currentSha;
|
||||
const ready = await readiness();
|
||||
assert.equal(ready.status, "ok");
|
||||
assert.deepEqual(ready.rollout.conversationalRectificationV3, {
|
||||
protocol: "conversational-evidence-v3",
|
||||
newCaseCreation: "enabled",
|
||||
migrations: "ready",
|
||||
syntheticSmoke: "matched",
|
||||
readyForNewCases: true,
|
||||
});
|
||||
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const blocked = await readiness();
|
||||
assert.equal(blocked.status, "blocked");
|
||||
assert.equal(blocked.rollout.conversationalRectificationV3.syntheticSmoke, "matched");
|
||||
assert.equal(blocked.rollout.conversationalRectificationV3.readyForNewCases, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
for (const key of keys) {
|
||||
const value = prior[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user