test: close conversational rectification review gaps
This commit is contained in:
+18
-4
@@ -77,6 +77,8 @@ ADMIN_EMAILS=...
|
||||
RECTIFICATION_PRICE_CREDITS=3
|
||||
RECTIFICATION_V3_CREATE_ENABLED=true
|
||||
RECTIFICATION_V3_MIGRATIONS_READY=false
|
||||
# Set only after the authenticated synthetic smoke passes on this exact image.
|
||||
RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA=
|
||||
|
||||
# Recommended multi-model catalog. The JSON references server-only keys.
|
||||
LLM_DEFAULT_MODEL_ID=deepseek-pro
|
||||
@@ -207,15 +209,26 @@ Run `cd frontend && npx supabase db push --linked` with the authorized project
|
||||
account. Verify the linked migration ledger contains all six versions. Do not
|
||||
print the database URL or any service-role credential. Then set
|
||||
`RECTIFICATION_V3_MIGRATIONS_READY=true`, keep
|
||||
`RECTIFICATION_V3_CREATE_ENABLED=true`, and deploy the tested Git revision.
|
||||
`RECTIFICATION_V3_CREATE_ENABLED=true`, leave
|
||||
`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` empty, and deploy the tested Git revision.
|
||||
Creation is available for the authorized smoke account, but the revision is not
|
||||
ready for general rollout until that smoke is recorded.
|
||||
|
||||
Before declaring rollout successful, fetch `https://jyotisha.chat/api/health`
|
||||
and verify all of the following against the revision that passed validation:
|
||||
Before the smoke, fetch `https://jyotisha.chat/api/health` and verify the full
|
||||
deployment SHA, healthy dependencies, enabled creation, ready migrations,
|
||||
`syntheticSmoke: pending`, and `readyForNewCases: false`. A missing, abbreviated,
|
||||
malformed, or previous-revision smoke SHA must remain pending.
|
||||
|
||||
After the smoke sequence below passes, set
|
||||
`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character
|
||||
lowercase Git SHA and restart the web container. Then fetch health again and
|
||||
verify all of the following against the revision that passed validation:
|
||||
|
||||
- `deployment.gitCommit` exactly equals the tested 40-character Git SHA;
|
||||
- `rollout.conversationalRectificationV3.protocol` is
|
||||
`conversational-evidence-v3`;
|
||||
- `newCaseCreation` and `migrations` are `enabled` and `ready`;
|
||||
- `syntheticSmoke` is `matched`;
|
||||
- `readyForNewCases` is `true`;
|
||||
- ordinary health checks remain healthy. The health response must never contain
|
||||
environment values or credentials.
|
||||
@@ -251,7 +264,8 @@ from telemetry.
|
||||
### Rollback
|
||||
|
||||
Rollback is forward-compatible and non-destructive. First set
|
||||
`RECTIFICATION_V3_CREATE_ENABLED=false` and redeploy a revision that can still
|
||||
`RECTIFICATION_V3_CREATE_ENABLED=false`, clear
|
||||
`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA`, and redeploy a revision that can still
|
||||
read/resume v3. Health must report `newCaseCreation: paused`. This stops only
|
||||
new v3 starts: keep reads, resume, answer, pause, confirmation, and saved-question
|
||||
handoff available for existing cases. Never reverse or delete the v3 migrations,
|
||||
|
||||
@@ -13,11 +13,11 @@ the tested deployment SHA.
|
||||
|
||||
| Issue | Reported failure | Current status | Local evidence | Production closure artifact |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; Task 12 synthetic flow deletes the chat while retaining the account case. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. |
|
||||
| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; the PG14 full-flow test deletes a real RLS-owned chat as `authenticated` and proves the account case remains. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. |
|
||||
| ISSUE-BT-002 | A new chat could not establish a fresh rectification interaction and unfinished progress was coupled to chat state. | verified-local | `20260720010000_conversational_rectification_schema.sql`; account-level resume in `frontend/tests/conversational-rectification-e2e.test.ts` across two route clients; Task 9 current-chat consent tests. | Authenticated new-device/new-chat resume smoke tied to the deployed SHA. |
|
||||
| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; PG14 confirmation/rollback runtime suite. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. |
|
||||
| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; the PG14 full-flow test rejects `05:20`, confirms exact `05:21`, and proves the old time survives until commit. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. |
|
||||
| ISSUE-BT-004 | Choosing `都不符合` surfaced the same raw English pattern error. | verified-local | The actual orchestrator treats `都不符合` as a normal direction change; Task 12 E2E advances the durable turn and preserves the single fee; client maps terminal 502/non-JSON failures to stable Chinese copy. | Authenticated production `都不符合` action followed by reload/resume, tied to the deployed SHA. |
|
||||
| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; `20260721010000_conversational_legacy_import_projection.sql` imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. |
|
||||
| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; PG14 proves future background persists without scoring and the legacy suite imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. |
|
||||
|
||||
## Release decision
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2257,3 +2257,284 @@ def test_import_abandon_then_paid_revision_keeps_active_time_and_charges_normall
|
||||
"importedFrom": None,
|
||||
"baseline": "04:58",
|
||||
}
|
||||
|
||||
|
||||
def test_real_database_full_conversational_flow_survives_authenticated_chat_deletion(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000003101"
|
||||
case_id = "00000000-0000-4000-8000-000000003102"
|
||||
attach_action = "00000000-0000-4000-8000-000000003103"
|
||||
pause_action = "00000000-0000-4000-8000-000000003104"
|
||||
save_action = "00000000-0000-4000-8000-000000003105"
|
||||
wrong_confirm = "00000000-0000-4000-8000-000000003106"
|
||||
exact_confirm = "00000000-0000-4000-8000-000000003107"
|
||||
claim_action = "00000000-0000-4000-8000-000000003108"
|
||||
chat_id = "00000000-0000-4000-8000-000000003109"
|
||||
question = "2027年是否适合换工作?"
|
||||
question_fingerprint = hashlib.sha256(question.encode("utf-8")).hexdigest()
|
||||
receipt = {"modelId": "synthetic-model", "schemaValidated": True}
|
||||
_create_user(pg14_database, user_id, credits=20)
|
||||
|
||||
assert _reserve(pg14_database, user_id, case_id)["credits"] == 17
|
||||
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
|
||||
assert _complete(pg14_database, user_id, case_id)["billing_state"] == "charged"
|
||||
attached = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select public.attach_conversational_rectification_question(
|
||||
'{user_id}'::uuid, '{case_id}'::uuid, 0, '{attach_action}'::uuid,
|
||||
{_text(question)}, '{question_fingerprint}'
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert attached["pending_consultation_question"] == question
|
||||
|
||||
paused_turn = {
|
||||
**_valid_turn(case_id),
|
||||
"status": "paused",
|
||||
"turnVersion": 1,
|
||||
"actions": ["answer", "abandon"],
|
||||
"pendingConsultationQuestion": question,
|
||||
}
|
||||
paused = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select public.pause_conversational_rectification_case(
|
||||
'{user_id}'::uuid, '{case_id}'::uuid, 0, '{pause_action}'::uuid,
|
||||
{_jsonb(paused_turn)}, {_jsonb(receipt)}, '{'a' * 64}'
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert paused["status"] == "paused"
|
||||
loaded = json.loads(pg14_database.sql(
|
||||
f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text"
|
||||
))
|
||||
assert loaded["status"] == "paused"
|
||||
assert loaded["turn_version"] == 1
|
||||
|
||||
future_evidence = {
|
||||
"id": "00000000-0000-4000-8000-000000003110",
|
||||
"rawText": "2027年计划换工作",
|
||||
"domain": "career",
|
||||
"eventSummary": "计划换工作",
|
||||
"dateValue": "2027",
|
||||
"datePrecision": "year",
|
||||
"extractionStatus": "clear",
|
||||
"scoreable": False,
|
||||
"correctsEvidenceIds": [],
|
||||
}
|
||||
ready_turn = {
|
||||
**_valid_turn(case_id),
|
||||
"status": "confirming",
|
||||
"turnVersion": 2,
|
||||
"candidate": {
|
||||
"status": "ready_for_confirmation",
|
||||
"representativeTime": "05:21",
|
||||
"rangeStart": "05:10",
|
||||
"rangeEnd": "05:30",
|
||||
},
|
||||
"evidenceRequest": None,
|
||||
"evidenceRecap": [{
|
||||
"id": future_evidence["id"],
|
||||
"summary": future_evidence["eventSummary"],
|
||||
"dateLabel": "2027(未来,仅作背景)",
|
||||
}],
|
||||
"actions": ["answer", "pause", "abandon", "confirm"],
|
||||
"pendingConsultationQuestion": question,
|
||||
}
|
||||
saved = json.loads(pg14_database.sql(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
1,
|
||||
save_action,
|
||||
[future_evidence],
|
||||
turn=ready_turn,
|
||||
command_fingerprint="b" * 64,
|
||||
)))
|
||||
assert saved["status"] == "confirming"
|
||||
loaded_ready = json.loads(pg14_database.sql(
|
||||
f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text"
|
||||
))
|
||||
assert loaded_ready["event_evidence"][0]["scoreable"] is False
|
||||
|
||||
completed_turn = {
|
||||
**ready_turn,
|
||||
"status": "completed",
|
||||
"turnVersion": 3,
|
||||
"candidate": {**ready_turn["candidate"], "status": "confirmed"},
|
||||
"actions": ["continue_original_question"],
|
||||
}
|
||||
mismatch = f"""
|
||||
select public.confirm_conversational_rectification_candidate(
|
||||
'{user_id}'::uuid, '{case_id}'::uuid, 2, '{wrong_confirm}'::uuid,
|
||||
'00000000-0000-4000-8000-000000000991'::uuid, '05:20'::time,
|
||||
'rectification-v3.1', {_jsonb(completed_turn)}, {_jsonb(receipt)}, '{'c' * 64}'
|
||||
);
|
||||
"""
|
||||
assert pg14_database.rejects(mismatch)
|
||||
assert pg14_database.sql(
|
||||
f"select pg_catalog.to_char(active_birth_time, 'HH24:MI') from public.profiles where id = '{user_id}'::uuid"
|
||||
) == "04:58"
|
||||
|
||||
confirmed = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select public.confirm_conversational_rectification_candidate(
|
||||
'{user_id}'::uuid, '{case_id}'::uuid, 2, '{exact_confirm}'::uuid,
|
||||
'00000000-0000-4000-8000-000000000991'::uuid, '05:21'::time,
|
||||
'rectification-v3.1', {_jsonb(completed_turn)}, {_jsonb(receipt)}, '{'d' * 64}'
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert confirmed["status"] == "completed"
|
||||
assert pg14_database.sql(
|
||||
f"select pg_catalog.to_char(active_birth_time, 'HH24:MI') from public.profiles where id = '{user_id}'::uuid"
|
||||
) == "05:21"
|
||||
|
||||
claimed = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select public.claim_conversational_rectification_handoff(
|
||||
'{user_id}'::uuid, '{case_id}'::uuid, 3, '{claim_action}'::uuid,
|
||||
'{question_fingerprint}'
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert claimed["status"] == "claimed"
|
||||
request_id = claimed["requestId"]
|
||||
executing = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select public.begin_conversational_rectification_handoff_execution(
|
||||
'{user_id}'::uuid, '{case_id}'::uuid, 3, '{claim_action}'::uuid,
|
||||
'{request_id}'::uuid, '{question_fingerprint}'
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert executing["status"] == "ready"
|
||||
reservation = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select row_to_json(result)::text from public.begin_consultation_credit(
|
||||
'{user_id}'::uuid, '{request_id}'
|
||||
) result;
|
||||
"""
|
||||
))
|
||||
assert reservation["success"] is True
|
||||
settlement = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select public.settle_conversational_rectification_handoff(
|
||||
'{user_id}'::uuid, '{case_id}'::uuid, '{claim_action}'::uuid,
|
||||
'{request_id}'::uuid, true
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert settlement["status"] == "consumed"
|
||||
|
||||
pg14_database.sql(
|
||||
f"insert into public.chat_sessions (id, user_id) values ('{chat_id}'::uuid, '{user_id}'::uuid)"
|
||||
)
|
||||
pg14_database.sql(
|
||||
"""
|
||||
create or replace function auth.uid() returns uuid
|
||||
language sql stable set search_path = ''
|
||||
as 'select nullif(pg_catalog.current_setting(''request.jwt.claim.sub'', true), '''')::uuid';
|
||||
"""
|
||||
)
|
||||
try:
|
||||
pg14_database.sql(
|
||||
f"""
|
||||
begin;
|
||||
set local role authenticated;
|
||||
select pg_catalog.set_config('request.jwt.claim.sub', '{user_id}', true);
|
||||
delete from public.chat_sessions where id = '{chat_id}'::uuid;
|
||||
commit;
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
pg14_database.sql(
|
||||
"""
|
||||
create or replace function auth.uid() returns uuid
|
||||
language sql stable set search_path = '' as 'select null::uuid';
|
||||
"""
|
||||
)
|
||||
durable = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'chatCount', (select pg_catalog.count(*) from public.chat_sessions where id = '{chat_id}'::uuid),
|
||||
'caseCount', (select pg_catalog.count(*) from public.birth_time_rectification_cases where id = '{case_id}'::uuid),
|
||||
'credits', credits,
|
||||
'rectificationCharges', (select pg_catalog.count(*) from public.birth_time_rectification_billing where case_id = '{case_id}'::uuid and state = 'charged'),
|
||||
'handoff', (select state from public.birth_time_rectification_question_handoffs where case_id = '{case_id}'::uuid),
|
||||
'question', (select pending_consultation_question from public.birth_time_rectification_cases where id = '{case_id}'::uuid)
|
||||
)::text
|
||||
from public.profiles where id = '{user_id}'::uuid;
|
||||
"""
|
||||
))
|
||||
assert durable == {
|
||||
"chatCount": 0,
|
||||
"caseCount": 1,
|
||||
"credits": 16,
|
||||
"rectificationCharges": 1,
|
||||
"handoff": "consumed",
|
||||
"question": None,
|
||||
}
|
||||
|
||||
|
||||
def test_all_v3_tables_and_public_rpcs_are_service_role_only(pg14_database: PgDatabase) -> None:
|
||||
tables = [
|
||||
"birth_time_rectification_cases",
|
||||
"birth_time_rectification_turns",
|
||||
"birth_time_rectification_event_evidence",
|
||||
"birth_time_rectification_billing",
|
||||
"birth_time_rectification_action_receipts",
|
||||
"birth_time_rectification_question_handoffs",
|
||||
"birth_time_rectification_handoff_attach_receipts",
|
||||
"birth_time_rectification_handoff_settlements",
|
||||
]
|
||||
for table in tables:
|
||||
privileges = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'anon', pg_catalog.has_table_privilege('anon', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
'authenticated', pg_catalog.has_table_privilege('authenticated', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
'serviceRole', pg_catalog.has_table_privilege('service_role', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE')
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert privileges == {"anon": False, "authenticated": False, "serviceRole": True}, table
|
||||
|
||||
rpc_names = [
|
||||
"reserve_conversational_rectification_fee",
|
||||
"complete_conversational_rectification_fee",
|
||||
"release_conversational_rectification_fee",
|
||||
"create_conversational_rectification_case",
|
||||
"load_conversational_rectification_case",
|
||||
"replay_conversational_rectification_action",
|
||||
"save_conversational_rectification_turn",
|
||||
"pause_conversational_rectification_case",
|
||||
"abandon_conversational_rectification_case",
|
||||
"confirm_conversational_rectification_candidate",
|
||||
"import_legacy_conversational_rectification_case",
|
||||
"attach_conversational_rectification_question",
|
||||
"load_conversational_rectification_handoff",
|
||||
"claim_conversational_rectification_handoff",
|
||||
"begin_conversational_rectification_handoff_execution",
|
||||
"settle_conversational_rectification_handoff",
|
||||
]
|
||||
rows = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object(
|
||||
'name', proc.proname,
|
||||
'anon', pg_catalog.has_function_privilege('anon', proc.oid, 'EXECUTE'),
|
||||
'authenticated', pg_catalog.has_function_privilege('authenticated', proc.oid, 'EXECUTE'),
|
||||
'serviceRole', pg_catalog.has_function_privilege('service_role', proc.oid, 'EXECUTE')
|
||||
) order by proc.proname)::text
|
||||
from pg_catalog.pg_proc proc
|
||||
join pg_catalog.pg_namespace namespace on namespace.oid = proc.pronamespace
|
||||
where namespace.nspname = 'public'
|
||||
and proc.proname = any({_text('{' + ','.join(rpc_names) + '}')}::text[]);
|
||||
"""
|
||||
))
|
||||
assert {row["name"] for row in rows} == set(rpc_names)
|
||||
assert all(row == {
|
||||
"name": row["name"],
|
||||
"anon": False,
|
||||
"authenticated": False,
|
||||
"serviceRole": True,
|
||||
} for row in rows)
|
||||
|
||||
@@ -4,7 +4,9 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATIONS = ROOT / "frontend" / "supabase" / "migrations"
|
||||
V3_MIGRATIONS = tuple(sorted(MIGRATIONS.glob("2026072*conversational*.sql"))) + (
|
||||
V3_MIGRATIONS = (MIGRATIONS / "20260717020000_birth_time_journey.sql",) + tuple(
|
||||
sorted(MIGRATIONS.glob("2026072*conversational*.sql"))
|
||||
) + (
|
||||
MIGRATIONS / "20260720040000_rectification_question_handoff.sql",
|
||||
)
|
||||
|
||||
@@ -13,12 +15,24 @@ def _sql() -> str:
|
||||
return "\n".join(path.read_text(encoding="utf-8") for path in V3_MIGRATIONS)
|
||||
|
||||
|
||||
def _function_body(name: str) -> str:
|
||||
matches = re.findall(
|
||||
rf"create or replace function public\.{re.escape(name)}\([\s\S]*?\)\s*"
|
||||
rf"returns[\s\S]*?\bas \$\$([\s\S]*?)\$\$;",
|
||||
_sql().lower(),
|
||||
)
|
||||
assert matches, f"missing function body: {name}"
|
||||
return matches[-1]
|
||||
|
||||
|
||||
def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None:
|
||||
sql = _sql().lower()
|
||||
tables = (
|
||||
"birth_time_rectification_cases",
|
||||
"birth_time_rectification_turns",
|
||||
"birth_time_rectification_event_evidence",
|
||||
"birth_time_rectification_billing",
|
||||
"birth_time_rectification_action_receipts",
|
||||
"birth_time_rectification_question_handoffs",
|
||||
"birth_time_rectification_handoff_attach_receipts",
|
||||
"birth_time_rectification_handoff_settlements",
|
||||
@@ -26,49 +40,90 @@ def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None:
|
||||
for table in tables:
|
||||
assert f"alter table public.{table} enable row level security" in sql
|
||||
assert re.search(rf"revoke all on table public\.{table}\s+from (?:public, )?anon, authenticated", sql)
|
||||
assert not re.search(rf"grant (?:select|insert|update|delete|all).+public\.{table}.+to authenticated", sql)
|
||||
|
||||
mutation_rpcs = (
|
||||
public_rpcs = (
|
||||
"reserve_conversational_rectification_fee",
|
||||
"complete_conversational_rectification_fee",
|
||||
"release_conversational_rectification_fee",
|
||||
"create_conversational_rectification_case",
|
||||
"load_conversational_rectification_case",
|
||||
"replay_conversational_rectification_action",
|
||||
"save_conversational_rectification_turn",
|
||||
"pause_conversational_rectification_case",
|
||||
"abandon_conversational_rectification_case",
|
||||
"confirm_conversational_rectification_candidate",
|
||||
"import_legacy_conversational_rectification_case",
|
||||
"attach_conversational_rectification_question",
|
||||
"load_conversational_rectification_handoff",
|
||||
"claim_conversational_rectification_handoff",
|
||||
"begin_conversational_rectification_handoff_execution",
|
||||
"settle_conversational_rectification_handoff",
|
||||
)
|
||||
for function in mutation_rpcs:
|
||||
for function in public_rpcs:
|
||||
assert re.search(rf"revoke all on function public\.{function}\(", sql)
|
||||
assert not re.search(rf"grant execute on function public\.{function}\([\s\S]{{0,800}}?to (?:anon|authenticated)", sql)
|
||||
|
||||
|
||||
def test_mutations_bind_owner_version_action_and_fingerprint_before_writes() -> None:
|
||||
sql = _sql().lower()
|
||||
for field in ("p_user_id", "p_case_id", "p_expected_version", "p_action_id"):
|
||||
assert field in sql
|
||||
assert "p_command_fingerprint" in sql
|
||||
assert "p_question_fingerprint" in sql
|
||||
assert "for update" in sql
|
||||
assert "conversational_stale_turn" in sql
|
||||
assert "conversational_action_conflict" in sql
|
||||
assert "question_fingerprint" in sql
|
||||
# This is an explicit per-RPC guard matrix. It prevents an unrelated
|
||||
# function elsewhere in the migration bundle from satisfying a global
|
||||
# keyword search. Runtime adversarial/no-side-effect behavior is covered
|
||||
# by test_conversational_rectification_postgres_runtime.py.
|
||||
guard_matrix = {
|
||||
"reserve_conversational_rectification_fee": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update",
|
||||
),
|
||||
"complete_conversational_rectification_fee": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update",
|
||||
),
|
||||
"release_conversational_rectification_fee": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update",
|
||||
),
|
||||
"create_conversational_rectification_case": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "v_fingerprint", "for update",
|
||||
),
|
||||
"save_conversational_rectification_turn": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
|
||||
),
|
||||
"pause_conversational_rectification_case": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
|
||||
),
|
||||
"abandon_conversational_rectification_case": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
|
||||
),
|
||||
"confirm_conversational_rectification_candidate": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
|
||||
),
|
||||
"import_legacy_conversational_rectification_case": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_legacy_case_id", "for update",
|
||||
),
|
||||
"attach_conversational_rectification_question": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_question_fingerprint", "for update",
|
||||
),
|
||||
"claim_conversational_rectification_handoff": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_question_fingerprint", "for update",
|
||||
),
|
||||
"begin_conversational_rectification_handoff_execution": (
|
||||
"p_user_id", "p_case_id", "p_expected_version", "p_claim_action_id", "p_request_id", "p_question_fingerprint", "for update",
|
||||
),
|
||||
"settle_conversational_rectification_handoff": (
|
||||
"p_user_id", "p_case_id", "p_claim_action_id", "p_request_id", "for update",
|
||||
),
|
||||
}
|
||||
for function, guards in guard_matrix.items():
|
||||
body = _function_body(function)
|
||||
for guard in guards:
|
||||
assert guard in body, f"{function} is missing guard {guard}"
|
||||
|
||||
|
||||
def test_public_projection_excludes_private_weights_and_future_evidence_is_not_scored() -> None:
|
||||
def test_public_projection_excludes_private_weights() -> None:
|
||||
contracts = (ROOT / "frontend/src/lib/conversational-rectification/contracts.ts").read_text(encoding="utf-8")
|
||||
projection = (ROOT / "frontend/src/lib/conversational-rectification/technical-packet.ts").read_text(encoding="utf-8")
|
||||
route = (ROOT / "frontend/src/app/api/birth-time-conversation/route.ts").read_text(encoding="utf-8")
|
||||
orchestrator = (ROOT / "frontend/src/lib/conversational-rectification/orchestrator.ts").read_text(encoding="utf-8")
|
||||
|
||||
public_region = contracts[contracts.index("const candidateSchema"):contracts.index("export type ConversationalRectificationTurn")]
|
||||
assert "candidateWeights" not in public_region
|
||||
assert "partitionIds" not in public_region
|
||||
assert "candidateWeights" not in projection[projection.index("export function projectRectificationTechnicalPacket"):]
|
||||
assert "item.scoreable !== true" in route
|
||||
assert "futureWindows" in orchestrator
|
||||
assert "scoreable: false" in orchestrator
|
||||
|
||||
|
||||
def test_synthetic_fixtures_contain_no_secret_or_token_shapes() -> None:
|
||||
@@ -78,7 +133,7 @@ def test_synthetic_fixtures_contain_no_secret_or_token_shapes() -> None:
|
||||
r"eyj[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+",
|
||||
r"bearer\s+[a-z0-9._-]{20,}",
|
||||
r"supabase_service_role_key\s*=",
|
||||
r"openai_api_key\s*=",
|
||||
r"openai_api_key\s*=\s*[\"'](?:sk-|[a-z0-9]{32})",
|
||||
r"refresh_token",
|
||||
r"auth-token",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user