test: verify conversational birth-time rectification
This commit is contained in:
@@ -72,6 +72,12 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
SUPABASE_SERVICE_ROLE_KEY=...
|
||||
ADMIN_EMAILS=...
|
||||
|
||||
# Conversational birth-time rectification rollout controls.
|
||||
# Keep migrations false until the ordered database gate below has passed.
|
||||
RECTIFICATION_PRICE_CREDITS=3
|
||||
RECTIFICATION_V3_CREATE_ENABLED=true
|
||||
RECTIFICATION_V3_MIGRATIONS_READY=false
|
||||
|
||||
# Recommended multi-model catalog. The JSON references server-only keys.
|
||||
LLM_DEFAULT_MODEL_ID=deepseek-pro
|
||||
LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]'
|
||||
@@ -184,6 +190,77 @@ Editor shows `You do not have access to this project`, use the correct Supabase
|
||||
organization account or invite the current GitHub user to project
|
||||
`vtvnfqmonbfuxmqkqdlc` before retrying.
|
||||
|
||||
## Conversational birth-time rectification v3 rollout
|
||||
|
||||
`conversational-evidence-v3` is an account-level workflow. A web-image rollout
|
||||
does not prove its database contract is present. Apply migrations before the
|
||||
web image, in this order:
|
||||
|
||||
1. `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`
|
||||
2. `20260720010000_conversational_rectification_schema.sql`
|
||||
3. `20260720020000_conversational_rectification_billing.sql`
|
||||
4. `20260720030000_conversational_rectification_transitions.sql`
|
||||
5. `20260720040000_rectification_question_handoff.sql`
|
||||
6. `20260721010000_conversational_legacy_import_projection.sql`
|
||||
|
||||
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.
|
||||
|
||||
Before declaring rollout successful, fetch `https://jyotisha.chat/api/health`
|
||||
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`;
|
||||
- `readyForNewCases` is `true`;
|
||||
- ordinary health checks remain healthy. The health response must never contain
|
||||
environment values or credentials.
|
||||
|
||||
Using an authorized synthetic account with no real birth data, run this smoke
|
||||
sequence. A plain HTTP `200` is not substitute evidence:
|
||||
|
||||
1. Finish onboarding without rectification. Verify an unverified reported time
|
||||
offers current-chat consent or `先校正再询问`.
|
||||
2. Save a synthetic ordinary question and start v3. Verify one fixed fee and a
|
||||
rich first turn containing the candidate boundary, stable/sensitive layers,
|
||||
domain rationale, and a dated historical-event request.
|
||||
3. Answer with one explicit event, choose `都不符合`, submit one ambiguous
|
||||
event, then a clear event. Verify the ambiguous/future facts do not score.
|
||||
4. Pause, reload, and resume from a second authenticated browser session.
|
||||
Verify no second rectification charge.
|
||||
5. Reach a candidate, verify the prior active time is still in force, reject a
|
||||
mismatched candidate confirmation, then explicitly confirm the exact
|
||||
candidate. Verify the time changes atomically.
|
||||
6. Explicitly continue the saved ordinary question. Verify one normal
|
||||
consultation reservation. Delete its chat and verify the account case still
|
||||
resumes/loads.
|
||||
7. For an unfinished legacy case, verify exactly one
|
||||
`migration_waived` import, unchanged history, and no broad-year questionnaire.
|
||||
8. Inject one transient 502. Verify byte-identical retry and stable Chinese
|
||||
fallback, never raw browser English.
|
||||
|
||||
Record only protocol, phase, action kind, result category, latency bucket,
|
||||
billing state, error category, and deployment SHA. Narrative, event text, birth
|
||||
data, email, user/user-case identifiers, tokens, and model prompts are forbidden
|
||||
from telemetry.
|
||||
|
||||
### Rollback
|
||||
|
||||
Rollback is forward-compatible and non-destructive. First set
|
||||
`RECTIFICATION_V3_CREATE_ENABLED=false` 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,
|
||||
rows, turns, evidence, receipts, or legacy import links. Never point an imported
|
||||
case back to mutable legacy history. A revision in progress keeps the account's
|
||||
prior active time until its exact atomic confirmation succeeds. If no compatible
|
||||
reader is available, leave the current image serving existing cases and disable
|
||||
only creation; do not deploy an older schema consumer.
|
||||
|
||||
## Common operations
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# User-reported birth-time flow issues — 2026-07-20
|
||||
|
||||
This ledger records the five reported product failures and the evidence needed
|
||||
to close them. It contains no copied authentication header, cookie, token,
|
||||
email, user UUID, or real birth record. The original plan described this file as
|
||||
an existing modification target; it did not exist in this checkout, so Task 12
|
||||
created it.
|
||||
|
||||
`verified-local` means deterministic contract tests and the equivalent local
|
||||
PostgreSQL 14 workflow pass. It is deliberately not `closed`: closure also
|
||||
requires an authenticated synthetic production smoke whose health Git SHA is
|
||||
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-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-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. |
|
||||
|
||||
## Release decision
|
||||
|
||||
All five issues remain `verified-local` until the production closure artifacts
|
||||
above are attached. A public 200 response, an unverified browser session, or a
|
||||
local in-memory test cannot change them to `closed`. The deployment sequence and
|
||||
non-destructive rollback are defined in `deploy/README.md`.
|
||||
@@ -22,6 +22,14 @@ import {
|
||||
import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts";
|
||||
import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts";
|
||||
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
|
||||
import {
|
||||
conversationalRectificationLatencyBucket,
|
||||
createConversationalRectificationTelemetry,
|
||||
recordConversationalRectificationTelemetry,
|
||||
safeConversationalRectificationDeploymentSha,
|
||||
type ConversationalRectificationTelemetryPayload,
|
||||
type ConversationalRectificationTelemetrySink,
|
||||
} from "../../../lib/birth-time-journey-telemetry.ts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
@@ -34,9 +42,6 @@ type AuthenticatedRequest = Readonly<{
|
||||
export type BirthTimeConversationRouteService = ConversationalRectificationService;
|
||||
|
||||
export type BirthTimeConversationRouteLog = Readonly<{
|
||||
requestId: string;
|
||||
actionId: string | null;
|
||||
caseId: string | null;
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
@@ -45,6 +50,9 @@ export type BirthTimeConversationPostDependencies = Readonly<{
|
||||
createService(authenticated: AuthenticatedRequest): Promise<BirthTimeConversationRouteService>;
|
||||
createRequestId?(request: Request): string;
|
||||
log?(entry: BirthTimeConversationRouteLog): void;
|
||||
telemetry?: ConversationalRectificationTelemetrySink;
|
||||
deploymentSha?: string;
|
||||
now?(): number;
|
||||
}>;
|
||||
|
||||
type ProfileQueryResult = Readonly<{
|
||||
@@ -580,6 +588,8 @@ async function createProductionService(
|
||||
store: createSupabaseConversationalRectificationStore(admin),
|
||||
billing: createSupabaseConversationalRectificationBilling(admin),
|
||||
get rectificationPriceCredits() { return priceCredits(); },
|
||||
allowNewCaseCreation:
|
||||
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false",
|
||||
async loadDeclaredProfile(userId) {
|
||||
return loadProductionConversationalRectificationProfile({
|
||||
async loadProfile(receivedUserId) {
|
||||
@@ -665,11 +675,6 @@ function stableRequestId(request: Request): string {
|
||||
: randomUUID();
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown) {
|
||||
const publicError = toConversationalRectificationPublicError(error);
|
||||
return Response.json(publicError, { status: publicError.status });
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
service: BirthTimeConversationRouteService,
|
||||
userId: string,
|
||||
@@ -685,27 +690,90 @@ async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
function telemetryPhase(
|
||||
turn: ConversationalRectificationTurn | null,
|
||||
): ConversationalRectificationTelemetryPayload["phase"] {
|
||||
switch (turn?.status) {
|
||||
case "active": return "collecting_evidence";
|
||||
case "paused": return "paused";
|
||||
case "confirming": return "confirming";
|
||||
case "completed": return "completed";
|
||||
case "abandoned": return "abandoned";
|
||||
default: return "entry";
|
||||
}
|
||||
}
|
||||
|
||||
function telemetryErrorCategory(
|
||||
code: string,
|
||||
): ConversationalRectificationTelemetryPayload["errorCategory"] {
|
||||
if (code === "authentication_required") return "authentication";
|
||||
if (code === "invalid_command" || code === "profile_incomplete") return "validation";
|
||||
if (code === "stale_turn" || code === "action_conflict" || code === "candidate_changed"
|
||||
|| code === "invalid_transition" || code === "case_not_found") return "conflict";
|
||||
if (code === "billing_failed") return "billing";
|
||||
if (code === "service_unavailable" || code === "store_unavailable") return "dependency";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function telemetryResultCategory(
|
||||
status: number,
|
||||
): ConversationalRectificationTelemetryPayload["resultCategory"] {
|
||||
if (status === 409) return "conflict";
|
||||
if (status >= 400 && status < 500) return "rejected";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
export function createBirthTimeConversationPostHandler(
|
||||
dependencies: BirthTimeConversationPostDependencies,
|
||||
) {
|
||||
return async function handleBirthTimeConversationPost(request: Request): Promise<Response> {
|
||||
const requestId = dependencies.createRequestId?.(request) ?? stableRequestId(request);
|
||||
let actionId: string | null = null;
|
||||
let caseId: string | null = null;
|
||||
const startedAt = dependencies.now?.() ?? Date.now();
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const telemetry = dependencies.telemetry
|
||||
? createConversationalRectificationTelemetry(dependencies.telemetry)
|
||||
: recordConversationalRectificationTelemetry;
|
||||
const deploymentSha = safeConversationalRectificationDeploymentSha(
|
||||
dependencies.deploymentSha
|
||||
?? process.env.GITHUB_SHA
|
||||
?? process.env.VERCEL_GIT_COMMIT_SHA
|
||||
?? process.env.NEXT_PUBLIC_GIT_COMMIT,
|
||||
);
|
||||
dependencies.createRequestId?.(request);
|
||||
let actionKind: ConversationalRectificationTelemetryPayload["actionKind"] = "unknown";
|
||||
try {
|
||||
const authenticated = await dependencies.authenticate(request);
|
||||
if (!authenticated) return errorResponse(new ConversationalRectificationError("authentication_required"));
|
||||
if (!authenticated) throw new ConversationalRectificationError("authentication_required");
|
||||
|
||||
const parsed = conversationalRectificationCommandSchema.safeParse(await requestPayload(request));
|
||||
if (!parsed.success) return errorResponse(new ConversationalRectificationError("invalid_command"));
|
||||
actionId = parsed.data.actionId;
|
||||
caseId = parsed.data.type === "start" ? parsed.data.actionId : parsed.data.caseId;
|
||||
if (!parsed.success) throw new ConversationalRectificationError("invalid_command");
|
||||
actionKind = parsed.data.type;
|
||||
|
||||
const service = await dependencies.createService(authenticated);
|
||||
return Response.json(await dispatch(service, authenticated.userId, parsed.data));
|
||||
const turn = await dispatch(service, authenticated.userId, parsed.data);
|
||||
telemetry({
|
||||
protocol: "conversational-evidence-v3",
|
||||
phase: telemetryPhase(turn),
|
||||
actionKind,
|
||||
resultCategory: "success",
|
||||
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
|
||||
billingState: actionKind === "start" ? "unknown" : "unchanged",
|
||||
errorCategory: "none",
|
||||
deploymentSha,
|
||||
});
|
||||
return Response.json(turn);
|
||||
} catch (error) {
|
||||
const publicError = toConversationalRectificationPublicError(error);
|
||||
dependencies.log?.({ requestId, actionId, caseId, code: publicError.code });
|
||||
dependencies.log?.({ code: publicError.code });
|
||||
telemetry({
|
||||
protocol: "conversational-evidence-v3",
|
||||
phase: "entry",
|
||||
actionKind,
|
||||
resultCategory: telemetryResultCategory(publicError.status),
|
||||
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
|
||||
billingState: publicError.code === "billing_failed" ? "unknown" : "not_applicable",
|
||||
errorCategory: telemetryErrorCategory(publicError.code),
|
||||
deploymentSha,
|
||||
});
|
||||
return Response.json(publicError, { status: publicError.status });
|
||||
}
|
||||
};
|
||||
@@ -715,10 +783,11 @@ const productionPost = createBirthTimeConversationPostHandler({
|
||||
authenticate: authenticateProductionRequest,
|
||||
createService: createProductionService,
|
||||
createRequestId: stableRequestId,
|
||||
deploymentSha: process.env.GITHUB_SHA
|
||||
?? process.env.VERCEL_GIT_COMMIT_SHA
|
||||
?? process.env.NEXT_PUBLIC_GIT_COMMIT,
|
||||
log(entry) {
|
||||
console.error(
|
||||
`[birth-time-conversation] request=${entry.requestId} action=${entry.actionId ?? "none"} case=${entry.caseId ?? "none"} code=${entry.code}`,
|
||||
);
|
||||
console.error(`[birth-time-conversation] code=${entry.code}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ const gitCommit =
|
||||
?? 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 envCheck(names: string[]): Check {
|
||||
const missing = names.filter((name) => !process.env[name]);
|
||||
@@ -66,6 +70,9 @@ export async function GET() {
|
||||
jyotishApi: await jyotishApiCheck(),
|
||||
};
|
||||
const status = aggregate(checks);
|
||||
const rectificationV3Ready = rectificationV3CreationEnabled
|
||||
&& rectificationV3MigrationsReady
|
||||
&& gitCommit !== "unknown";
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
@@ -73,6 +80,15 @@ export async function GET() {
|
||||
deployment: {
|
||||
gitCommit,
|
||||
},
|
||||
rollout: {
|
||||
conversationalRectificationV3: {
|
||||
protocol: "conversational-evidence-v3",
|
||||
newCaseCreation: rectificationV3CreationEnabled ? "enabled" : "paused",
|
||||
migrations: rectificationV3MigrationsReady ? "ready" : "unverified",
|
||||
syntheticSmoke: "required",
|
||||
readyForNewCases: rectificationV3Ready,
|
||||
},
|
||||
},
|
||||
checks,
|
||||
},
|
||||
{ status: status === "ok" ? 200 : 503 },
|
||||
|
||||
@@ -1,6 +1,118 @@
|
||||
import { z } from "zod";
|
||||
import type { VersionedJourneyResponse } from "./birth-time-journey-service.ts";
|
||||
|
||||
export const conversationalRectificationTelemetryProtocols = [
|
||||
"conversational-evidence-v3",
|
||||
] as const;
|
||||
export const conversationalRectificationTelemetryPhases = [
|
||||
"entry",
|
||||
"collecting_evidence",
|
||||
"paused",
|
||||
"confirming",
|
||||
"completed",
|
||||
"abandoned",
|
||||
] as const;
|
||||
export const conversationalRectificationTelemetryActionKinds = [
|
||||
"start",
|
||||
"resume",
|
||||
"answer",
|
||||
"pause",
|
||||
"abandon",
|
||||
"confirm",
|
||||
"unknown",
|
||||
] as const;
|
||||
export const conversationalRectificationTelemetryResultCategories = [
|
||||
"success",
|
||||
"rejected",
|
||||
"conflict",
|
||||
"failed",
|
||||
] as const;
|
||||
export const conversationalRectificationTelemetryLatencyBuckets = [
|
||||
"lt_100ms",
|
||||
"100_499ms",
|
||||
"500_1999ms",
|
||||
"2s_plus",
|
||||
] as const;
|
||||
export const conversationalRectificationTelemetryBillingStates = [
|
||||
"not_applicable",
|
||||
"charged",
|
||||
"released",
|
||||
"migration_waived",
|
||||
"unchanged",
|
||||
"unknown",
|
||||
] as const;
|
||||
export const conversationalRectificationTelemetryErrorCategories = [
|
||||
"none",
|
||||
"authentication",
|
||||
"validation",
|
||||
"conflict",
|
||||
"billing",
|
||||
"dependency",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
const deploymentShaSchema = z.union([
|
||||
z.string().regex(/^[0-9a-f]{7,64}$/),
|
||||
z.literal("unknown"),
|
||||
]);
|
||||
|
||||
export const conversationalRectificationTelemetryPayloadSchema = z.object({
|
||||
protocol: z.enum(conversationalRectificationTelemetryProtocols),
|
||||
phase: z.enum(conversationalRectificationTelemetryPhases),
|
||||
actionKind: z.enum(conversationalRectificationTelemetryActionKinds),
|
||||
resultCategory: z.enum(conversationalRectificationTelemetryResultCategories),
|
||||
latencyBucket: z.enum(conversationalRectificationTelemetryLatencyBuckets),
|
||||
billingState: z.enum(conversationalRectificationTelemetryBillingStates),
|
||||
errorCategory: z.enum(conversationalRectificationTelemetryErrorCategories),
|
||||
deploymentSha: deploymentShaSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export type ConversationalRectificationTelemetryPayload = z.infer<
|
||||
typeof conversationalRectificationTelemetryPayloadSchema
|
||||
>;
|
||||
export type ConversationalRectificationTelemetrySink = (
|
||||
payload: ConversationalRectificationTelemetryPayload,
|
||||
) => void;
|
||||
|
||||
function conversationalRectificationConsoleSink(
|
||||
payload: ConversationalRectificationTelemetryPayload,
|
||||
): void {
|
||||
console.info("[conversational-rectification]", JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function createConversationalRectificationTelemetry(
|
||||
sink: ConversationalRectificationTelemetrySink = conversationalRectificationConsoleSink,
|
||||
): ConversationalRectificationTelemetrySink {
|
||||
return (input) => {
|
||||
const payload = conversationalRectificationTelemetryPayloadSchema.parse(input);
|
||||
try {
|
||||
sink(payload);
|
||||
} catch { // no-excuse-ok: observability cannot break the product request
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const recordConversationalRectificationTelemetry =
|
||||
createConversationalRectificationTelemetry();
|
||||
|
||||
export function conversationalRectificationLatencyBucket(
|
||||
latencyMs: number,
|
||||
): ConversationalRectificationTelemetryPayload["latencyBucket"] {
|
||||
if (!Number.isFinite(latencyMs) || latencyMs < 0) return "2s_plus";
|
||||
if (latencyMs < 100) return "lt_100ms";
|
||||
if (latencyMs < 500) return "100_499ms";
|
||||
if (latencyMs < 2_000) return "500_1999ms";
|
||||
return "2s_plus";
|
||||
}
|
||||
|
||||
export function safeConversationalRectificationDeploymentSha(
|
||||
value: string | undefined,
|
||||
): ConversationalRectificationTelemetryPayload["deploymentSha"] {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return /^[0-9a-f]{7,64}$/.test(normalized) ? normalized : "unknown";
|
||||
}
|
||||
|
||||
export const journeyMetricNames = [
|
||||
"turn_advanced",
|
||||
"draft_corrected",
|
||||
|
||||
@@ -71,6 +71,7 @@ export type ConversationalRectificationServicePorts = Readonly<{
|
||||
& Partial<Pick<ConversationalRectificationStore, "importLegacy">>;
|
||||
billing: Pick<ConversationalRectificationBilling, "reserve" | "complete" | "release">;
|
||||
rectificationPriceCredits: number;
|
||||
allowNewCaseCreation?: boolean;
|
||||
loadDeclaredProfile(userId: string): Promise<ConversationalRectificationProfile>;
|
||||
loadLegacyCase?(
|
||||
userId: string,
|
||||
@@ -518,6 +519,9 @@ export function createConversationalRectificationService(
|
||||
&& current.billingState === "migration_waived") {
|
||||
throw new ConversationalRectificationError("action_conflict");
|
||||
}
|
||||
if (ports.allowNewCaseCreation === false) {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
|
||||
const legacy = await loadLegacy(userId, legacyCaseId);
|
||||
if (!legacy) throw new ConversationalRectificationError("case_not_found");
|
||||
@@ -690,6 +694,9 @@ export function createConversationalRectificationService(
|
||||
}
|
||||
return publicTurn(existing);
|
||||
}
|
||||
if (ports.allowNewCaseCreation === false) {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
|
||||
let reserved = false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createBirthTimeConversationPostHandler } from "../src/app/api/birth-time-conversation/route.ts";
|
||||
import {
|
||||
clearBirthTimeConsultationConsent,
|
||||
createBirthTimeConsultationConsentState,
|
||||
grantBirthTimeConsultationConsent,
|
||||
resolveBirthTimeConsultationRoute,
|
||||
} from "../src/lib/birth-time-consultation-consent.ts";
|
||||
import { isDeclaredBirthProfileComplete } from "../src/lib/birth-time-intake-model.ts";
|
||||
import {
|
||||
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
|
||||
sendConversationalRectificationCommand,
|
||||
} from "../src/lib/conversational-rectification/client.ts";
|
||||
import {
|
||||
conversationalRectificationTurnSchema,
|
||||
type ConversationalRectificationTurn,
|
||||
} from "../src/lib/conversational-rectification/contracts.ts";
|
||||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||||
import {
|
||||
createConversationalRectificationService,
|
||||
type ConversationalRectificationServicePorts,
|
||||
} from "../src/lib/conversational-rectification/orchestrator.ts";
|
||||
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
|
||||
import type {
|
||||
LoadedConversationalRectificationCase,
|
||||
StoredConversationalRectificationCase,
|
||||
} from "../src/lib/conversational-rectification/store.ts";
|
||||
import { createRectificationQuestionHandoffCoordinator } from "../src/lib/rectification-question-handoff.ts";
|
||||
import type { ConversationalRectificationTelemetryPayload } from "../src/lib/birth-time-journey-telemetry.ts";
|
||||
import { createConversationalRectificationTelemetry } from "../src/lib/birth-time-journey-telemetry.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000009001";
|
||||
const caseId = "00000000-0000-4000-8000-000000009002";
|
||||
const originalQuestion = "我下一次适合换工作的时间是什么时候?";
|
||||
const deploymentSha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
const declaredBirthInput = {
|
||||
source: "approximate" as const,
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: "05:30",
|
||||
uncertaintyBeforeMinutes: 30 as const,
|
||||
uncertaintyAfterMinutes: 30 as const,
|
||||
birthTimeClue: "家人只记得天刚亮",
|
||||
birthplace: {
|
||||
countryCode: "CN",
|
||||
provinceCode: "130000",
|
||||
cityCode: "130400",
|
||||
districtCode: "130406",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezoneOffset: 8,
|
||||
},
|
||||
};
|
||||
|
||||
const onboardingDraft = {
|
||||
date: "1990-01-01",
|
||||
time: "05:30",
|
||||
reportedTime: "05:30",
|
||||
birthTimeStatus: "reported" as const,
|
||||
birthTimeSource: "approximate" as const,
|
||||
birthTimePeriod: "" as const,
|
||||
birthTimeClue: "家人只记得天刚亮",
|
||||
uncertaintyBeforeMinutes: 30,
|
||||
uncertaintyAfterMinutes: 30,
|
||||
countryCode: "CN",
|
||||
provinceCode: "130000",
|
||||
cityCode: "130400",
|
||||
districtCode: "130406",
|
||||
city: "合成测试城市",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezoneOffset: 8,
|
||||
};
|
||||
|
||||
function technicalPacket(ready: boolean): RectificationTechnicalPacket {
|
||||
return {
|
||||
calculationVersion: "synthetic-rectification-v3",
|
||||
candidate: {
|
||||
status: ready ? "ready_for_confirmation" : "pending_validation",
|
||||
representativeTime: ready ? "05:18" : "05:30",
|
||||
range: ready
|
||||
? { startTime: "05:16", endTime: "05:20" }
|
||||
: { startTime: "05:00", endTime: "06:00" },
|
||||
},
|
||||
useBoundary: ready
|
||||
? "候选已达到确认门槛,但明确确认前仍使用旧的账户排盘时间。"
|
||||
: "当前时间只是待验证候选,不能当作已经校正完成的出生分钟。",
|
||||
candidateModelRefs: ["synthetic-model-ref"],
|
||||
candidateDifferenceRefs: ["synthetic-d1", "synthetic-d9", "synthetic-d10", "synthetic-d24"],
|
||||
candidateWeights: ready ? { "05:18": 0.75, "05:19": 0.25 } : { "05:30": 0.5 },
|
||||
partitionIds: ["private-synthetic-partition"],
|
||||
d1Stability: "stable",
|
||||
boundaryDistanceMinutes: ready ? 2 : 30,
|
||||
sensitivityScope: {
|
||||
source: "time_linked_candidate_scan_samples",
|
||||
rangeStart: ready ? "05:16" : "05:00",
|
||||
rangeEnd: ready ? "05:20" : "06:00",
|
||||
sampleTimes: ready ? ["05:16", "05:18", "05:20"] : ["05:00", "05:30", "06:00"],
|
||||
},
|
||||
stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["synthetic-d1"] }],
|
||||
sensitiveLayers: [
|
||||
{ layer: "D9", values: ["Aries", "Leo"], referenceIds: ["synthetic-d9"] },
|
||||
{ layer: "D10", values: ["Taurus", "Libra"], referenceIds: ["synthetic-d10"] },
|
||||
{ layer: "D24", values: ["Gemini", "Virgo"], referenceIds: ["synthetic-d24"] },
|
||||
],
|
||||
supportedSensitiveLayers: ["D9", "D10", "D24"],
|
||||
scoredHistoricalEvidence: ready ? [{
|
||||
evidenceId: "00000000-0000-4000-8000-000000009099",
|
||||
domain: "career",
|
||||
candidateTime: "05:18",
|
||||
score: 8,
|
||||
ruleRefs: ["synthetic-history-rule"],
|
||||
}] : [],
|
||||
suggestedDomains: [
|
||||
{ domain: "career", layer: "D10", reason: "D10 在候选范围内变化,已发生的事业事件可以区分候选。" },
|
||||
{ domain: "education", layer: "D24", reason: "D24 在候选范围内变化,已发生的学业事件可以区分候选。" },
|
||||
{ domain: "relocation", layer: "D9", reason: "D9 在候选范围内变化,已发生的搬迁事件可以区分候选。" },
|
||||
],
|
||||
referenceIds: ["synthetic-d1", "synthetic-d9", "synthetic-d10", "synthetic-d24", "synthetic-history-rule"],
|
||||
futureWindows: [{
|
||||
label: "未来背景窗口",
|
||||
startDate: "2027-01-01",
|
||||
endDate: "2027-03-31",
|
||||
scoreable: false,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function narrativeGenerator() {
|
||||
return {
|
||||
modelId: "synthetic-grounded-narrator",
|
||||
async generate(prompt: string) {
|
||||
const request = JSON.parse(prompt) as {
|
||||
phase: "first" | "intermediate" | "final";
|
||||
packet: ReturnType<typeof technicalPacket> & {
|
||||
candidate: ReturnType<typeof technicalPacket>["candidate"] & {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const packet = request.packet;
|
||||
const final = request.phase === "final";
|
||||
return { text: JSON.stringify({
|
||||
narrative: [
|
||||
`${packet.candidate.representativeTime} 是待验证候选,范围为 ${packet.candidate.rangeStart} 至 ${packet.candidate.rangeEnd}。`,
|
||||
"D1 的 Cancer 在范围内保持稳定。",
|
||||
"D9 的 Aries / Leo 存在分钟敏感差异,搬迁事件可以区分 D9。",
|
||||
"D10 的 Taurus / Libra 存在分钟敏感差异,事业事件可以区分 D10。",
|
||||
"D24 的 Gemini / Virgo 存在分钟敏感差异,学业事件可以区分 D24。",
|
||||
final ? "现有已发生事件支持进入候选确认。" : "请写一件已经发生的真实事件,注明哪一年、哪一月以及发生了什么。",
|
||||
packet.useBoundary,
|
||||
].join(""),
|
||||
candidateStatus: packet.candidate.status,
|
||||
representativeTime: packet.candidate.representativeTime,
|
||||
rangeStart: packet.candidate.rangeStart,
|
||||
rangeEnd: packet.candidate.rangeEnd,
|
||||
useBoundary: packet.useBoundary,
|
||||
stableLayers: packet.stableLayers.map((item) => item.layer),
|
||||
sensitiveLayers: packet.sensitiveLayers.map((item) => item.layer),
|
||||
referenceIds: [],
|
||||
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
|
||||
evidenceRequest: final ? null : {
|
||||
domains: packet.suggestedDomains.map((item) => item.domain),
|
||||
datePrecision: "month_preferred",
|
||||
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
|
||||
},
|
||||
}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Receipt = Readonly<{
|
||||
kind: "save_turn" | "pause" | "abandon" | "confirm";
|
||||
expectedVersion: number;
|
||||
fingerprint: string;
|
||||
row: StoredConversationalRectificationCase;
|
||||
}>;
|
||||
|
||||
function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreation?: boolean } = {}) {
|
||||
const cases = new Map<string, LoadedConversationalRectificationCase>();
|
||||
const receipts = new Map<string, Receipt>();
|
||||
let activeTime = "04:58";
|
||||
let billingState: LoadedConversationalRectificationCase["billingState"] = null;
|
||||
let reserveCount = 0;
|
||||
let chargeCount = 0;
|
||||
let releaseCount = 0;
|
||||
const legacyCaseId = "00000000-0000-4000-8000-000000009080";
|
||||
|
||||
const withBilling = (state: LoadedConversationalRectificationCase["billingState"]) => {
|
||||
billingState = state;
|
||||
for (const [id, row] of cases) cases.set(id, { ...row, billingState: state });
|
||||
};
|
||||
const save = (input: {
|
||||
row?: LoadedConversationalRectificationCase;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
turn: unknown;
|
||||
privateCandidate: LoadedConversationalRectificationCase["privateCandidate"];
|
||||
evidence?: LoadedConversationalRectificationCase["eventEvidence"];
|
||||
receipt?: LoadedConversationalRectificationCase["validationReceipts"][number];
|
||||
importedFromCaseId?: string | null;
|
||||
}): LoadedConversationalRectificationCase => {
|
||||
const prior = input.row;
|
||||
const turn = conversationalRectificationTurnSchema.parse(input.turn);
|
||||
const next = {
|
||||
caseId: input.caseId,
|
||||
userId: input.userId,
|
||||
status: turn.status,
|
||||
turnVersion: turn.turnVersion,
|
||||
revisionOfCaseId: prior?.revisionOfCaseId ?? "00000000-0000-4000-8000-000000009000",
|
||||
importedFromCaseId: input.importedFromCaseId ?? prior?.importedFromCaseId ?? null,
|
||||
baselineActiveTime: "04:58",
|
||||
pendingConsultationQuestion: turn.pendingConsultationQuestion,
|
||||
billingState,
|
||||
latestTurn: structuredClone(turn),
|
||||
declaredBirthInput,
|
||||
privateCandidate: structuredClone(input.privateCandidate),
|
||||
eventEvidence: structuredClone(input.evidence ?? prior?.eventEvidence ?? []),
|
||||
validationReceipts: [
|
||||
...(prior?.validationReceipts ?? []),
|
||||
...(input.receipt ? [structuredClone(input.receipt)] : []),
|
||||
],
|
||||
} satisfies LoadedConversationalRectificationCase;
|
||||
cases.set(input.caseId, next);
|
||||
return next;
|
||||
};
|
||||
const replay = (input: {
|
||||
userId: string;
|
||||
caseId: string;
|
||||
expectedVersion: number;
|
||||
actionId: string;
|
||||
commandFingerprint: string;
|
||||
}, kind: Receipt["kind"], mutate: () => LoadedConversationalRectificationCase) => {
|
||||
const prior = receipts.get(input.actionId);
|
||||
if (prior) {
|
||||
if (prior.kind !== kind || prior.expectedVersion !== input.expectedVersion
|
||||
|| prior.fingerprint !== input.commandFingerprint) throw new ConversationalRectificationError("action_conflict");
|
||||
return prior.row;
|
||||
}
|
||||
const row = mutate();
|
||||
receipts.set(input.actionId, { kind, expectedVersion: input.expectedVersion, fingerprint: input.commandFingerprint, row });
|
||||
return row;
|
||||
};
|
||||
|
||||
const store: ConversationalRectificationServicePorts["store"] = {
|
||||
async loadActionReceipt(input) {
|
||||
const prior = receipts.get(input.actionId);
|
||||
if (!prior) return null;
|
||||
if (prior.kind !== input.actionKind || prior.expectedVersion !== input.expectedVersion
|
||||
|| prior.fingerprint !== input.commandFingerprint) throw new ConversationalRectificationError("action_conflict");
|
||||
return prior.row;
|
||||
},
|
||||
async loadCase(input) {
|
||||
const row = input.caseId ? cases.get(input.caseId) : [...cases.values()].at(-1);
|
||||
return row?.userId === input.userId ? structuredClone(row) : null;
|
||||
},
|
||||
async createCaseWithFirstTurn(input) {
|
||||
return save({
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
turn: input.firstTurn,
|
||||
privateCandidate: input.privateCandidate,
|
||||
receipt: input.validationReceipt,
|
||||
});
|
||||
},
|
||||
async saveTurn(input) {
|
||||
return replay(input, "save_turn", () => {
|
||||
const row = cases.get(input.caseId);
|
||||
if (!row || row.userId !== input.userId) throw new ConversationalRectificationError("case_not_found");
|
||||
if (row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||||
return save({
|
||||
row,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
turn: input.turn,
|
||||
privateCandidate: input.privateCandidate,
|
||||
evidence: [...row.eventEvidence, ...input.evidence],
|
||||
receipt: input.validationReceipt,
|
||||
});
|
||||
});
|
||||
},
|
||||
async pause(input) {
|
||||
return replay(input, "pause", () => {
|
||||
const row = cases.get(input.caseId);
|
||||
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||||
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
|
||||
privateCandidate: row.privateCandidate, receipt: input.validationReceipt });
|
||||
});
|
||||
},
|
||||
async abandon(input) {
|
||||
return replay(input, "abandon", () => {
|
||||
const row = cases.get(input.caseId);
|
||||
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||||
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
|
||||
privateCandidate: row.privateCandidate, receipt: input.validationReceipt });
|
||||
});
|
||||
},
|
||||
async confirm(input) {
|
||||
return replay(input, "confirm", () => {
|
||||
const row = cases.get(input.caseId);
|
||||
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
|
||||
if (row.privateCandidate.resultId !== input.resultId
|
||||
|| row.privateCandidate.representativeTime !== input.time
|
||||
|| row.privateCandidate.calculationVersion !== input.calculationVersion) {
|
||||
throw new ConversationalRectificationError("candidate_changed");
|
||||
}
|
||||
activeTime = input.time;
|
||||
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
|
||||
privateCandidate: { ...row.privateCandidate, workingState: { phase: "confirmed", iteration: 4, notes: [] } },
|
||||
receipt: input.validationReceipt });
|
||||
});
|
||||
},
|
||||
async importLegacy(input) {
|
||||
if (cases.has(input.caseId)) return cases.get(input.caseId)!;
|
||||
withBilling("migration_waived");
|
||||
return save({
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
turn: input.firstTurn,
|
||||
privateCandidate: input.privateCandidate,
|
||||
evidence: [...input.evidence],
|
||||
receipt: input.validationReceipt,
|
||||
importedFromCaseId: input.legacyCaseId,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const service = createConversationalRectificationService({
|
||||
store,
|
||||
billing: {
|
||||
async reserve() { reserveCount += 1; withBilling("reserved"); return { success: true, credits: 97, billingState: "reserved" }; },
|
||||
async complete() { chargeCount += 1; withBilling("charged"); return { success: true, credits: 97, billingState: "charged" }; },
|
||||
async release() { releaseCount += 1; withBilling("released"); return { success: true, credits: 100, billingState: "released" }; },
|
||||
},
|
||||
rectificationPriceCredits: 3,
|
||||
allowNewCaseCreation: options.allowNewCaseCreation,
|
||||
async loadDeclaredProfile() {
|
||||
return {
|
||||
declaredBirthInput,
|
||||
revisionOfCaseId: "00000000-0000-4000-8000-000000009000",
|
||||
legacyCaseId: options.legacy ? legacyCaseId : null,
|
||||
};
|
||||
},
|
||||
async loadLegacyCase(receivedUserId, receivedCaseId) {
|
||||
if (!options.legacy || receivedUserId !== userId || receivedCaseId !== legacyCaseId) return null;
|
||||
return {
|
||||
caseId: legacyCaseId,
|
||||
userId,
|
||||
journeyProtocol: "dynamic-choice-v2",
|
||||
status: "rectifying",
|
||||
turnVersion: 4,
|
||||
declaredBirthInput,
|
||||
currentRange: { startTime: "05:10", endTime: "05:50" },
|
||||
lifeEvents: [{
|
||||
id: "00000000-0000-4000-8000-000000009081",
|
||||
domain: "career",
|
||||
precision: "month",
|
||||
date: "2014-07",
|
||||
}],
|
||||
currentChoicePrompt: "2006-2011 还是 2011-2016?",
|
||||
choiceAnswers: ["A"],
|
||||
};
|
||||
},
|
||||
async buildTechnicalPacket(input) {
|
||||
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) {
|
||||
return {
|
||||
packet: {
|
||||
...packet,
|
||||
candidate: {
|
||||
...packet.candidate,
|
||||
status: "pending_validation",
|
||||
range: { startTime: input.privateCandidate.rangeStart, endTime: input.privateCandidate.rangeEnd },
|
||||
},
|
||||
sensitivityScope: {
|
||||
...packet.sensitivityScope,
|
||||
rangeStart: input.privateCandidate.rangeStart,
|
||||
rangeEnd: input.privateCandidate.rangeEnd,
|
||||
},
|
||||
},
|
||||
resultId: null,
|
||||
};
|
||||
}
|
||||
return { packet, resultId: ready ? "00000000-0000-4000-8000-000000009099" : null };
|
||||
},
|
||||
narrativeGenerator: narrativeGenerator(),
|
||||
asOfDate: () => "2026-07-21",
|
||||
});
|
||||
|
||||
return {
|
||||
service,
|
||||
cases,
|
||||
billing: () => ({ reserveCount, chargeCount, releaseCount, state: billingState }),
|
||||
activeTime: () => activeTime,
|
||||
legacyCaseId,
|
||||
};
|
||||
}
|
||||
|
||||
async function post(
|
||||
handler: (request: Request) => Promise<Response>,
|
||||
command: Record<string, unknown>,
|
||||
): Promise<ConversationalRectificationTurn> {
|
||||
const response = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(command),
|
||||
}));
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(payload));
|
||||
return payload as ConversationalRectificationTurn;
|
||||
}
|
||||
|
||||
test("authenticated synthetic flow covers soft entry, rich evidence, resume, atomic confirmation, and handoff", async () => {
|
||||
assert.equal(isDeclaredBirthProfileComplete(onboardingDraft), true, "onboarding may finish without rectification");
|
||||
|
||||
let consent = createBirthTimeConsultationConsentState();
|
||||
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
|
||||
kind: "choice", canUseUnverifiedTime: true,
|
||||
});
|
||||
consent = grantBirthTimeConsultationConsent(consent, "chat-a", "unverified_birth_time");
|
||||
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
|
||||
kind: "consult", mode: "unverified_birth_time", time: "05:30",
|
||||
});
|
||||
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-b").kind, "choice");
|
||||
consent = clearBirthTimeConsultationConsent(consent, "chat-a");
|
||||
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "choice");
|
||||
|
||||
const backend = createSyntheticBackend();
|
||||
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
createService: async () => backend.service,
|
||||
deploymentSha,
|
||||
telemetry: (payload) => telemetry.push(payload),
|
||||
now: (() => { let value = 0; return () => (value += 25); })(),
|
||||
});
|
||||
let turn = await post(handler, { type: "start", actionId: caseId, pendingConsultationQuestion: originalQuestion });
|
||||
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
|
||||
assert.equal(turn.status, "active");
|
||||
assert.match(turn.narrative, /05:30.*待验证候选/);
|
||||
assert.match(turn.narrative, /D1.*稳定/);
|
||||
assert.match(turn.narrative, /D9.*敏感差异/);
|
||||
assert.match(turn.narrative, /D10.*敏感差异/);
|
||||
assert.match(turn.narrative, /哪一年、哪一月/);
|
||||
assert.deepEqual(turn.evidenceRequest?.domains, ["career", "education", "relocation"]);
|
||||
assert.equal(turn.evidenceRequest?.freeTextAllowed, true);
|
||||
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(backend.activeTime(), "04:58", "revision must retain the old active minute");
|
||||
|
||||
turn = await post(handler, {
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009003",
|
||||
turnVersion: turn.turnVersion, domain: "career", answer: "都不符合,我想换一个方向",
|
||||
});
|
||||
assert.equal(turn.status, "active");
|
||||
assert.match(turn.narrative, /不沿用不符合.*自由描述另一件已经发生/);
|
||||
assert.equal(backend.billing().chargeCount, 1);
|
||||
|
||||
turn = await post(handler, {
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009004",
|
||||
turnVersion: turn.turnVersion, domain: "career", answer: "后来工作压力很大",
|
||||
});
|
||||
assert.match(turn.narrative, /还缺少.*明确时间/);
|
||||
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
|
||||
|
||||
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");
|
||||
turn = await post(handler, {
|
||||
type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009006",
|
||||
turnVersion: turn.turnVersion,
|
||||
});
|
||||
assert.equal(turn.status, "paused");
|
||||
|
||||
const secondDevice = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
createService: async () => backend.service,
|
||||
deploymentSha,
|
||||
telemetry: (payload) => telemetry.push(payload),
|
||||
});
|
||||
turn = await post(secondDevice, {
|
||||
type: "resume", caseId, actionId: "00000000-0000-4000-8000-000000009007",
|
||||
turnVersion: turn.turnVersion,
|
||||
});
|
||||
assert.equal(turn.status, "paused");
|
||||
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
|
||||
|
||||
turn = await post(secondDevice, {
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009008",
|
||||
turnVersion: turn.turnVersion, domain: "education", answer: "2011年6月大学毕业",
|
||||
});
|
||||
turn = await post(secondDevice, {
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009009",
|
||||
turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
|
||||
});
|
||||
assert.equal(turn.status, "confirming");
|
||||
assert.equal(turn.candidate.representativeTime, "05:18");
|
||||
assert.equal(backend.activeTime(), "04:58");
|
||||
|
||||
const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009010",
|
||||
turnVersion: turn.turnVersion, time: "05:17",
|
||||
}),
|
||||
}));
|
||||
assert.equal(wrong.status, 409);
|
||||
assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic");
|
||||
|
||||
turn = await post(secondDevice, {
|
||||
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011",
|
||||
turnVersion: turn.turnVersion, time: "05:18",
|
||||
});
|
||||
assert.equal(turn.status, "completed");
|
||||
assert.deepEqual(turn.actions, ["continue_original_question"]);
|
||||
assert.equal(backend.activeTime(), "05:18");
|
||||
assert.equal(backend.billing().chargeCount, 1);
|
||||
|
||||
let ordinaryReservations = 0;
|
||||
let ordinaryAnswers = 0;
|
||||
const handoff = createRectificationQuestionHandoffCoordinator<"timing">();
|
||||
const continued = await handoff.continueOriginalQuestion(
|
||||
turn.pendingConsultationQuestion ?? "",
|
||||
{ sessionId: "new-device-chat", theme: "timing" },
|
||||
async (context) => {
|
||||
ordinaryReservations += 1;
|
||||
ordinaryAnswers += 1;
|
||||
assert.equal(context.question, originalQuestion);
|
||||
assert.equal(backend.activeTime(), "05:18");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(continued, true);
|
||||
assert.equal(ordinaryReservations, 1);
|
||||
assert.equal(ordinaryAnswers, 1);
|
||||
|
||||
const chats = new Set(["new-device-chat"]);
|
||||
chats.delete("new-device-chat");
|
||||
assert.equal(chats.size, 0);
|
||||
const caseAfterChatDeletion = backend.cases.get(caseId);
|
||||
assert.equal(caseAfterChatDeletion?.status, "completed", "chat deletion must not cascade to the account case");
|
||||
|
||||
const allowedTelemetryKeys = [
|
||||
"protocol", "phase", "actionKind", "resultCategory", "latencyBucket",
|
||||
"billingState", "errorCategory", "deploymentSha",
|
||||
].sort();
|
||||
assert.ok(telemetry.length >= 10);
|
||||
for (const payload of telemetry) {
|
||||
assert.deepEqual(Object.keys(payload).sort(), allowedTelemetryKeys);
|
||||
assert.equal(payload.protocol, "conversational-evidence-v3");
|
||||
assert.equal(payload.deploymentSha, deploymentSha);
|
||||
const serialized = JSON.stringify(payload);
|
||||
for (const forbidden of [originalQuestion, "1990-01-01", userId, caseId, "05:18", "05:30"]) {
|
||||
assert.equal(serialized.includes(forbidden), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 replay = await backend.service.start(userId, { type: "start", actionId: caseId });
|
||||
assert.deepEqual(replay, first);
|
||||
assert.equal(backend.cases.get(caseId)?.importedFromCaseId, backend.legacyCaseId);
|
||||
assert.equal(backend.cases.get(caseId)?.billingState, "migration_waived");
|
||||
assert.deepEqual(backend.billing(), { reserveCount: 0, chargeCount: 0, releaseCount: 0, state: "migration_waived" });
|
||||
assert.deepEqual(first.candidate.rangeStart, "05:10");
|
||||
assert.deepEqual(first.candidate.rangeEnd, "05:50");
|
||||
assert.doesNotMatch(first.narrative, /2006-2011|2011-2016|哪个时间段/);
|
||||
assert.equal(JSON.stringify(first).includes("choiceAnswers"), false);
|
||||
});
|
||||
|
||||
test("v3 telemetry rejects every field outside the privacy-safe category contract", () => {
|
||||
const emitted: unknown[] = [];
|
||||
const record = createConversationalRectificationTelemetry((payload) => emitted.push(payload));
|
||||
const valid = {
|
||||
protocol: "conversational-evidence-v3",
|
||||
phase: "collecting_evidence",
|
||||
actionKind: "answer",
|
||||
resultCategory: "success",
|
||||
latencyBucket: "lt_100ms",
|
||||
billingState: "unchanged",
|
||||
errorCategory: "none",
|
||||
deploymentSha,
|
||||
} as const;
|
||||
record(valid);
|
||||
for (const forbidden of [
|
||||
"narrative", "eventText", "birthDate", "birthTime", "email", "userId",
|
||||
"accessToken", "refreshToken", "modelPrompt", "caseId", "actionId",
|
||||
]) {
|
||||
assert.throws(() => record({ ...valid, [forbidden]: "private" } as never));
|
||||
}
|
||||
assert.deepEqual(emitted, [valid]);
|
||||
});
|
||||
|
||||
test("transient 502 replays the same command and terminal failures expose only stable Chinese copy", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const bodies: string[] = [];
|
||||
let attempts = 0;
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
attempts += 1;
|
||||
bodies.push(String(init?.body));
|
||||
if (attempts === 1) return new Response("Bad gateway", { status: 502 });
|
||||
return Response.json({
|
||||
caseId,
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status: "active",
|
||||
turnVersion: 0,
|
||||
narrative: "合成安全响应。",
|
||||
candidate: { status: "pending_validation", representativeTime: "05:30", rangeStart: "05:00", rangeEnd: "06:00" },
|
||||
technicalReceipt: { calculationVersion: "synthetic-v1", stableLayers: ["D1"], sensitiveLayers: ["D9"], candidateDifferenceRefs: [] },
|
||||
evidenceRequest: { domains: ["career", "education"], datePrecision: "month_preferred", freeTextAllowed: true },
|
||||
evidenceRecap: [],
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
pendingConsultationQuestion: null,
|
||||
});
|
||||
};
|
||||
try {
|
||||
const turn = await sendConversationalRectificationCommand({ type: "start", actionId: caseId });
|
||||
assert.equal(turn.status, "active");
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(bodies[0], bodies[1]);
|
||||
|
||||
globalThis.fetch = async () => new Response("The string did not match the expected pattern", { status: 502 });
|
||||
await assert.rejects(
|
||||
sendConversationalRectificationCommand({ type: "start", actionId: caseId }),
|
||||
(error: unknown) => error instanceof Error
|
||||
&& error.message === CONVERSATIONAL_RECTIFICATION_UNAVAILABLE
|
||||
&& !error.message.includes("expected pattern"),
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("health exposes deployment identity and explicit v3 rollout readiness without environment secrets", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const prior = {
|
||||
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,
|
||||
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
};
|
||||
process.env.GITHUB_SHA = deploymentSha;
|
||||
process.env.RECTIFICATION_V3_CREATE_ENABLED = "true";
|
||||
process.env.RECTIFICATION_V3_MIGRATIONS_READY = "true";
|
||||
process.env["SUPABASE_SERVICE_ROLE_KEY"] = "synthetic-runtime-secret-never-return";
|
||||
globalThis.fetch = async () => Response.json({ status: "ok" });
|
||||
try {
|
||||
const { GET: healthGet } = await import(`../src/app/api/health/route.ts?e2e=${Date.now()}`);
|
||||
const response = await healthGet();
|
||||
const body = await response.json() as Record<string, unknown>;
|
||||
assert.deepEqual(body.deployment, { gitCommit: deploymentSha });
|
||||
assert.deepEqual(body.rollout, {
|
||||
conversationalRectificationV3: {
|
||||
protocol: "conversational-evidence-v3",
|
||||
newCaseCreation: "enabled",
|
||||
migrations: "ready",
|
||||
syntheticSmoke: "required",
|
||||
readyForNewCases: true,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(body).includes("synthetic-runtime-secret-never-return"), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
for (const [key, value] of Object.entries(prior)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rollback flag stops only new cases while existing v3 resume stays readable", async () => {
|
||||
const enabled = createSyntheticBackend();
|
||||
await enabled.service.start(userId, { type: "start", actionId: caseId });
|
||||
const backend = createSyntheticBackend({ allowNewCaseCreation: false });
|
||||
backend.cases.set(caseId, enabled.cases.get(caseId)!);
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
createService: async () => backend.service,
|
||||
telemetry: () => undefined,
|
||||
});
|
||||
const blockedStart = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "start", actionId: "00000000-0000-4000-8000-000000009050" }),
|
||||
}));
|
||||
assert.equal(blockedStart.status, 503);
|
||||
|
||||
const lostStartReplay = await post(handler, { type: "start", actionId: caseId });
|
||||
assert.equal(lostStartReplay.caseId, caseId, "an existing start identity remains replayable");
|
||||
|
||||
const resumed = await post(handler, {
|
||||
type: "resume", caseId, actionId: "00000000-0000-4000-8000-000000009051", turnVersion: 0,
|
||||
});
|
||||
assert.equal(resumed.caseId, caseId);
|
||||
assert.equal(backend.activeTime(), "04:58");
|
||||
});
|
||||
|
||||
test("a throwing injected telemetry sink cannot turn a committed request into failure", async () => {
|
||||
const backend = createSyntheticBackend();
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
createService: async () => backend.service,
|
||||
telemetry: () => { throw new Error("synthetic telemetry outage"); },
|
||||
});
|
||||
const response = 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(response.status, 200);
|
||||
assert.equal(backend.cases.get(caseId)?.status, "active");
|
||||
assert.equal(backend.billing().chargeCount, 1);
|
||||
});
|
||||
@@ -284,7 +284,7 @@ test("known conflicts and unavailable failures use stable safe Chinese responses
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(body.code, code);
|
||||
assert.match(`${body.error}${body.message}`, /校正|服务|进度|稍后|重试/);
|
||||
assert.deepEqual(logs, [{ requestId, actionId, caseId, code }]);
|
||||
assert.deepEqual(logs, [{ code }]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -304,7 +304,7 @@ test("unknown SQL, model, and browser errors are never exposed or logged", async
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(serialized.includes(raw), false);
|
||||
assert.equal(JSON.stringify(logs).includes(raw), false);
|
||||
assert.deepEqual(logs, [{ requestId, actionId, caseId, code: "service_unavailable" }]);
|
||||
assert.deepEqual(logs, [{ code: "service_unavailable" }]);
|
||||
});
|
||||
|
||||
test("production profile conversion links terminal v3 revisions and owner-bound unfinished legacy imports", async () => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATIONS = ROOT / "frontend" / "supabase" / "migrations"
|
||||
V3_MIGRATIONS = tuple(sorted(MIGRATIONS.glob("2026072*conversational*.sql"))) + (
|
||||
MIGRATIONS / "20260720040000_rectification_question_handoff.sql",
|
||||
)
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
return "\n".join(path.read_text(encoding="utf-8") for path in V3_MIGRATIONS)
|
||||
|
||||
|
||||
def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None:
|
||||
sql = _sql().lower()
|
||||
tables = (
|
||||
"birth_time_rectification_turns",
|
||||
"birth_time_rectification_event_evidence",
|
||||
"birth_time_rectification_billing",
|
||||
"birth_time_rectification_question_handoffs",
|
||||
"birth_time_rectification_handoff_attach_receipts",
|
||||
"birth_time_rectification_handoff_settlements",
|
||||
)
|
||||
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 = (
|
||||
"create_conversational_rectification_case",
|
||||
"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",
|
||||
"claim_conversational_rectification_handoff",
|
||||
"settle_conversational_rectification_handoff",
|
||||
)
|
||||
for function in mutation_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
|
||||
|
||||
|
||||
def test_public_projection_excludes_private_weights_and_future_evidence_is_not_scored() -> 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:
|
||||
fixture = (ROOT / "frontend/tests/conversational-rectification-e2e.test.ts").read_text(encoding="utf-8")
|
||||
forbidden = (
|
||||
r"sb-[a-z0-9]{16,}",
|
||||
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"refresh_token",
|
||||
r"auth-token",
|
||||
)
|
||||
for pattern in forbidden:
|
||||
assert re.search(pattern, fixture, re.IGNORECASE) is None
|
||||
Reference in New Issue
Block a user