feat: rebuild birth time rectification workflow

This commit is contained in:
Jesse_Chen
2026-07-27 09:57:14 +08:00
parent 380aba4628
commit 2ca245d643
66 changed files with 5826 additions and 699 deletions
+25 -2
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import {
parseRectificationPriceCredits,
} from "@/lib/birth-time-consultation-consent";
import { resolveAccountRectificationCase } from "@/lib/account-rectification-case";
import { resolveAccountRectificationCase, resolveAccountRectificationV4Case } from "@/lib/account-rectification-case";
import {
accountProfilePatchSchema,
applyAccountProfileConcurrencyGuards,
@@ -26,6 +26,14 @@ function isMissingProfileColumn(error: { code?: string; message?: string } | nul
|| message.includes("column");
}
function isMissingRectificationV4Relation(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "42P01"
|| error?.code === "PGRST205"
|| message.includes('relation "public.birth_time_rectification_v4_cases" does not exist')
|| (message.includes("schema cache") && message.includes("birth_time_rectification_v4_cases"));
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
@@ -40,6 +48,19 @@ export async function GET() {
process.env.RECTIFICATION_PRICE_CREDITS,
);
const admin = createAdminSupabaseClient();
const { data: rectificationV4CaseData, error: rectificationV4CaseError } = await admin
.from("birth_time_rectification_v4_cases")
.select("id,status,version,accepted_range_start,updated_at")
.eq("user_id", user.id)
.neq("status", "abandoned")
.is("accepted_range_start", null)
.order("updated_at", { ascending: false })
.limit(1);
if (rectificationV4CaseError && !isMissingRectificationV4Relation(rectificationV4CaseError)) {
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
}
const rectificationV4CaseRows = rectificationV4CaseError ? [] : rectificationV4CaseData;
const { data: rectificationCaseRows, error: rectificationCaseError } = await admin
.from("birth_time_rectification_cases")
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at")
@@ -82,7 +103,9 @@ export async function GET() {
if (profileError || !profile) {
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
}
const rectificationCase = resolveAccountRectificationCase(
const rectificationCase = resolveAccountRectificationV4Case(
Array.isArray(rectificationV4CaseRows) ? rectificationV4CaseRows : [],
) ?? resolveAccountRectificationCase(
profile,
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
);
+197 -25
View File
@@ -5,6 +5,7 @@ import {
getGeneralJyotishAgent,
getJyotishAgent,
runConsultationWorkflow,
toAgentConsultationContext,
} from "@/mastra";
import {
languageModelConfigurationMessage,
@@ -33,8 +34,9 @@ import {
} from "@/lib/consultation-route-service";
import {
createRectificationHandoffService,
createRectificationV4HandoffService,
type RectificationHandoffExecution,
type RectificationHandoffService,
type RectificationV4HandoffExecution,
} from "@/lib/rectification-handoff-service";
import { z } from "zod";
@@ -56,20 +58,38 @@ const chatRequestMetadataSchema = z.object({
.default([]),
});
const rectificationHandoffSchema = z.object({
const rectificationV3HandoffSchema = z.object({
protocol: z.literal("conversational-evidence-v3").optional(),
caseId: z.string().uuid(),
turnVersion: z.number().int().nonnegative(),
claimActionId: z.string().uuid(),
requestId: z.string().uuid(),
}).strict();
const rectificationV4HandoffSchema = z.object({
protocol: z.literal("rectification-evidence-v4"),
caseId: z.string().uuid(),
caseVersion: z.number().int().nonnegative(),
claimActionId: z.string().uuid(),
requestId: z.string().uuid(),
}).strict();
const v4ContinuationRequestSchema = z.object({
...chatRequestMetadataSchema.shape,
consultationMode: consultationBirthTimeModeSchema,
question: z.string().trim().min(1).max(500),
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
entrypoint: z.undefined().optional(),
rectificationHandoff: rectificationV4HandoffSchema,
}).strict();
const chartChatRequestSchema = consultationInputSchema.extend({
...chatRequestMetadataSchema.shape,
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
.optional()
.default("verified_chart"),
entrypoint: consultationEntrypointSchema.optional(),
rectificationHandoff: rectificationHandoffSchema.optional(),
rectificationHandoff: rectificationV3HandoffSchema.optional(),
}).strict();
const generalChatRequestSchema = z.object({
@@ -80,7 +100,11 @@ const generalChatRequestSchema = z.object({
entrypoint: z.undefined().optional(),
}).strict();
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
const chatRequestSchema = z.union([
v4ContinuationRequestSchema,
generalChatRequestSchema,
chartChatRequestSchema,
]);
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
@@ -94,6 +118,54 @@ function chinaCalendarDate(now: Date) {
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
function rangeBoundaryWorkflowContext(
start: Awaited<ReturnType<typeof runConsultationWorkflow>>,
end: Awaited<ReturnType<typeof runConsultationWorkflow>>,
acceptedRange: Readonly<{ start: string; end: string }>,
) {
const startConsumer = start.consumer_context;
const endConsumer = end.consumer_context;
return {
...start,
success: start.success && end.success,
consumer_context: {
...startConsumer,
core_status: startConsumer.core_status === "blocked" || endConsumer.core_status === "blocked"
? "blocked"
: startConsumer.core_status === "degraded" || endConsumer.core_status === "degraded"
? "degraded"
: "ready",
available_layers: startConsumer.available_layers.filter((layer) =>
endConsumer.available_layers.includes(layer)),
missing_route_layers: [...new Set([
...startConsumer.missing_route_layers,
...endConsumer.missing_route_layers,
])],
hard_blockers: [...new Set([
...startConsumer.hard_blockers,
...endConsumer.hard_blockers,
])],
answer_policy: {
...startConsumer.answer_policy,
can_answer_direction: startConsumer.answer_policy.can_answer_direction
&& endConsumer.answer_policy.can_answer_direction,
can_answer_precise_timing: false,
birth_time_confidence: "accepted_candidate_range",
candidate_is_confirmed: false,
require_boundary_agreement: true,
},
},
candidate_range: {
...acceptedRange,
claim_status: "candidate_range_not_birth_time_truth",
},
range_boundary_contexts: {
start: toAgentConsultationContext(start),
end: toAgentConsultationContext(end),
},
};
}
async function recordModelUsage(
accounting: ReturnType<typeof createAdminSupabaseClient>,
userId: string,
@@ -182,25 +254,21 @@ export async function POST(request: Request) {
const handoff = "rectificationHandoff" in parsed.data
? parsed.data.rectificationHandoff
: undefined;
let handoffService: RectificationHandoffService | null = null;
let handoffExecution: RectificationHandoffExecution | null = null;
const v4Handoff = handoff?.protocol === "rectification-evidence-v4";
let handoffExecution: RectificationHandoffExecution | RectificationV4HandoffExecution | null = null;
let settleHandoffRequest: ((emitted: boolean) => Promise<void>) | null = null;
let handoffSettlement: Promise<void> | null = null;
async function settleHandoff(emitted: boolean) {
if (!handoff || !handoffService || !handoffExecution
if (!settleHandoffRequest || !handoffExecution
|| handoffExecution.status !== "ready") return;
handoffSettlement ??= handoffService.settle({
userId,
caseId: handoff.caseId,
claimActionId: handoff.claimActionId,
requestId: handoff.requestId,
emitted,
}).then(() => undefined);
handoffSettlement ??= settleHandoffRequest(emitted);
await handoffSettlement;
}
if (handoff) {
if (!["verified_chart", "unverified_birth_time"].includes(parsed.data.consultationMode)
if ((!v4Handoff
&& !["verified_chart", "unverified_birth_time"].includes(parsed.data.consultationMode))
|| parsed.data.entrypoint !== undefined
|| requestId !== handoff.requestId) {
return NextResponse.json(
@@ -212,15 +280,41 @@ export async function POST(request: Request) {
);
}
try {
handoffService = createRectificationHandoffService(accounting);
handoffExecution = await handoffService.beginExecution({
userId,
caseId: handoff.caseId,
turnVersion: handoff.turnVersion,
claimActionId: handoff.claimActionId,
requestId: handoff.requestId,
question: parsed.data.question,
});
if (handoff.protocol === "rectification-evidence-v4") {
const service = createRectificationV4HandoffService(accounting);
handoffExecution = await service.beginExecution({
userId,
caseId: handoff.caseId,
caseVersion: handoff.caseVersion,
claimActionId: handoff.claimActionId,
requestId: handoff.requestId,
question: parsed.data.question,
});
settleHandoffRequest = (emitted) => service.settle({
userId,
caseId: handoff.caseId,
claimActionId: handoff.claimActionId,
requestId: handoff.requestId,
emitted,
}).then(() => undefined);
} else {
const service = createRectificationHandoffService(accounting);
handoffExecution = await service.beginExecution({
userId,
caseId: handoff.caseId,
turnVersion: handoff.turnVersion,
claimActionId: handoff.claimActionId,
requestId: handoff.requestId,
question: parsed.data.question,
});
settleHandoffRequest = (emitted) => service.settle({
userId,
caseId: handoff.caseId,
claimActionId: handoff.claimActionId,
requestId: handoff.requestId,
emitted,
}).then(() => undefined);
}
} catch {
return NextResponse.json(
{
@@ -275,6 +369,10 @@ export async function POST(request: Request) {
prepared = await prepareConsultationRoute({
userId,
mode: parsed.data.consultationMode,
...(v4Handoff && handoffExecution?.status === "ready"
&& "acceptedRange" in handoffExecution
? { candidateRange: handoffExecution.acceptedRange }
: {}),
async loadProfile(profileUserId) {
const { data, error } = await supabase
.from("profiles")
@@ -439,7 +537,9 @@ export async function POST(request: Request) {
try {
const { history } = parsed.data;
const name = prepared.serverChart?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
const consultationMode: ConsultationBirthTimeMode = v4Handoff
? "unverified_birth_time"
: prepared.consultationMode;
if (!shouldRunBirthChartWorkflow(consultationMode)) {
const result = await getGeneralJyotishAgent(selectedModel).stream([
{
@@ -484,6 +584,78 @@ export async function POST(request: Request) {
}
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
if (v4Handoff) {
if (!handoffExecution || handoffExecution.status !== "ready"
|| !("acceptedRange" in handoffExecution)) {
throw new Error("rectification_v4_range_missing");
}
const acceptedRange = handoffExecution.acceptedRange;
const boundaryInput = (time: string) => {
const [hour, minute] = time.split(":").map(Number);
return consultationInputSchema.parse({
...prepared.serverChart?.toolInput,
hour,
minute,
entryMode: "direct_chart",
question: resolvedQuestion.modelQuestion,
theme: parsed.data.theme,
});
};
const [startWorkflow, endWorkflow] = await Promise.all([
runConsultationWorkflow(boundaryInput(acceptedRange.start)),
runConsultationWorkflow(boundaryInput(acceptedRange.end)),
]);
const workflowContext = rangeBoundaryWorkflowContext(
startWorkflow,
endWorkflow,
acceptedRange,
);
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
...history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
resolvedQuestion.modelQuestion,
`本次只能使用已保存候选范围 ${acceptedRange.start}${acceptedRange.end} 的两个边界共同支持的结论。`,
"不得选择中点、峰值或单一代表分钟,不得把候选范围说成已确认出生时间。",
].filter(Boolean).join("\n"),
},
]);
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(
accounting,
userId,
requestId,
modelSelection.usageModelId,
result.totalUsage,
);
};
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeAndRecordUsage : cancel);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard("unverified_birth_time", false),
mode: "mastra",
requestId,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
"x-jyotish-precise-timing": "blocked",
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": "unverified_birth_time",
},
onFirstOutput: () => settle(completeAndRecordUsage),
onComplete: () => settle(completeAndRecordUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
});
}
const toolInput = consultationInputSchema.parse({
...prepared.serverChart.toolInput,
// Unverified use is still a normal chart calculation with a hard answer
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters";
import { assessBirthTime } from "@/lib/birth-time-journey";
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
import type { CalculationSpec } from "@/lib/rectification-v4/contracts";
import { createRectificationV4CaseService } from "@/lib/rectification-v4/case-service";
import { RectificationV4StoreError } from "@/lib/rectification-v4/store";
import { createRectificationV4SupabaseStore } from "@/lib/rectification-v4/supabase-store";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
const idSchema = z.string().uuid();
export async function rectificationV4Context() {
const auth = await createServerSupabaseClient();
const { data: { user }, error } = await auth.auth.getUser();
if (error || !user) throw new RectificationV4HttpError(401, "请先登录后再继续生时校正。");
const admin = createAdminSupabaseClient();
return {
userId: user.id,
auth,
service: createRectificationV4CaseService(createRectificationV4SupabaseStore(admin)),
};
}
export async function requestBody<T>(request: Request, schema: z.ZodType<T>): Promise<T> {
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) throw new RectificationV4HttpError(400, "提交内容不完整,请检查后重试。");
return parsed.data;
}
export function routeId(value: string): string {
const parsed = idSchema.safeParse(value);
if (!parsed.success) throw new RectificationV4HttpError(404, "没有找到这次生时校正记录。");
return parsed.data;
}
export async function calculationSpecForUser(
auth: Awaited<ReturnType<typeof createServerSupabaseClient>>,
userId: string,
): Promise<CalculationSpec> {
const { data, error } = await auth.from("profiles")
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset")
.eq("id", userId).maybeSingle();
if (error) throw error;
if (!data) throw new RectificationV4HttpError(409, "请先补全出生日期、时间线索和出生地点。");
const profile = await resolveMissingBirthTimezoneOffset(data);
const assessment = parseBirthTimeProfile(profile);
const range = assessBirthTime(assessment, { kind: "unavailable" }).reportedRange;
return {
version: "rectification-calculation-spec-v4",
birthDate: assessment.date,
candidateRange: {
start: range.startTime ?? "00:00",
end: range.endTime ?? "23:59",
},
latitude: assessment.location.lat,
longitude: assessment.location.lon,
timezoneOffsetHours: assessment.location.tz,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
};
}
export class RectificationV4HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
export function rectificationV4Error(error: unknown): NextResponse {
if (error instanceof RectificationV4HttpError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
if (error instanceof RectificationV4StoreError) {
const responses: Record<RectificationV4StoreError["code"], readonly [number, string]> = {
not_found: [404, "没有找到这次生时校正记录。"],
stale_version: [409, "记录已在其他位置更新,正在重新载入。"],
invalid_state: [409, "当前状态无法执行这个操作,请刷新后重试。"],
stale_job: [409, "这次计算已过期,请以最新结果为准。"],
lease_lost: [409, "计算任务已由其他进程接管,请稍后刷新。"],
};
const response = responses[error.code];
return NextResponse.json({ error: response[1] }, { status: response[0] });
}
if (error instanceof z.ZodError) {
return NextResponse.json({ error: "出生资料或提交内容格式不正确。" }, { status: 400 });
}
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "生时校正服务尚未配置。" }, { status: 503 });
}
console.error("rectification_v4_route_failed", error);
return NextResponse.json({ error: "暂时无法处理,请稍后再试。" }, { status: 500 });
}
@@ -0,0 +1,12 @@
import { rectificationV4Error } from "../../../_server";
import { transitionCase } from "../../_action";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
return await transitionCase(request, params, "abandon");
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { acceptRangeRequestSchema } from "@/lib/rectification-v4/contracts";
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
const body = await requestBody(request, acceptRangeRequestSchema);
const context = await rectificationV4Context();
const result = await context.service.acceptRange({
...body,
userId: context.userId,
caseId: routeId((await params).caseId),
});
return result
? NextResponse.json(result)
: NextResponse.json({ error: "当前结果还不足以保存这个范围。" }, { status: 409 });
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { answerRequestSchema } from "@/lib/rectification-v4/contracts";
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
const body = await requestBody(request, answerRequestSchema);
const context = await rectificationV4Context();
const result = await context.service.answer({ ...body, userId: context.userId, caseId: routeId((await params).caseId) });
return result
? NextResponse.json(result, { status: 202 })
: NextResponse.json({ error: "当前没有待回答的问题,请刷新后重试。" }, { status: 409 });
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { reviseEventRequestSchema } from "@/lib/rectification-v4/contracts";
import { appendEventRevision } from "@/lib/rectification-v4/evidence-ledger";
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../../../_server";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string; eventId: string }> }) {
try {
const body = await requestBody(request, reviseEventRequestSchema);
const context = await rectificationV4Context();
const values = await params;
const caseId = routeId(values.caseId);
const eventId = routeId(values.eventId);
const current = await context.service.loadCase(context.userId, caseId);
if (!current) return NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 });
const revision = appendEventRevision(current.events, {
eventId,
domain: body.domain,
eventKind: body.eventKind,
summary: body.summary,
rawText: body.rawText,
dateRange: body.dateRange,
scoreability: body.scoreability,
});
return NextResponse.json(await context.service.reviseEvent({
userId: context.userId,
caseId,
actionId: body.actionId,
expectedCaseVersion: body.expectedCaseVersion,
revision,
}), { status: 202 });
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,12 @@
import { rectificationV4Error } from "../../../_server";
import { transitionCase } from "../../_action";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
return await transitionCase(request, params, "pause");
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,12 @@
import { rectificationV4Error } from "../../../_server";
import { transitionCase } from "../../_action";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
return await transitionCase(request, params, "resume");
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server";
export const runtime = "nodejs";
export async function GET(_request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
const context = await rectificationV4Context();
const result = await context.service.loadCase(context.userId, routeId((await params).caseId));
return result ? NextResponse.json(result) : NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 });
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts";
import { rectificationV4Context, requestBody, routeId } from "../_server";
export async function transitionCase(
request: Request,
params: Promise<{ caseId: string }>,
kind: "pause" | "resume" | "abandon",
) {
const body = await requestBody(request, caseActionRequestSchema);
const context = await rectificationV4Context();
return NextResponse.json(await context.service.transition({
...body,
userId: context.userId,
caseId: routeId((await params).caseId),
kind,
}));
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { rectificationV4Context, rectificationV4Error } from "../../_server";
export const runtime = "nodejs";
export async function GET() {
try {
const context = await rectificationV4Context();
const result = await context.service.loadActive(context.userId);
return result ? NextResponse.json(result) : new NextResponse(null, { status: 204 });
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { createCaseRequestSchema } from "@/lib/rectification-v4/contracts";
import { calculationSpecForUser, rectificationV4Context, rectificationV4Error, requestBody } from "../_server";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
const body = await requestBody(request, createCaseRequestSchema);
const context = await rectificationV4Context();
return NextResponse.json(await context.service.createCase({
userId: context.userId,
actionId: body.actionId,
calculationSpec: await calculationSpecForUser(context.auth, context.userId),
}));
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -0,0 +1,30 @@
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { createRectificationV4HandoffService } from "@/lib/rectification-handoff-service";
import {
createRectificationV4HandoffHandlers,
type RectificationV4HandoffRouteDependencies,
} from "@/lib/rectification-v4/handoff-route";
export const runtime = "nodejs";
const dependencies: RectificationV4HandoffRouteDependencies = {
async authenticate() {
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
return error || !user ? null : { userId: user.id };
},
service() {
return createRectificationV4HandoffService(createAdminSupabaseClient());
},
};
const handlers = createRectificationV4HandoffHandlers(dependencies);
export async function GET(request: Request) {
return handlers.get(request);
}
export async function POST(request: Request) {
return handlers.post(request);
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server";
export const runtime = "nodejs";
export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) {
try {
const context = await rectificationV4Context();
const job = await context.service.loadJob(context.userId, routeId((await params).jobId));
return job ? NextResponse.json({ job }) : NextResponse.json({ error: "没有找到这次处理任务。" }, { status: 404 });
} catch (error) {
return rectificationV4Error(error);
}
}
+31
View File
@@ -1660,3 +1660,34 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
.birth-time-clock-menu .select-item { justify-content: flex-start; }
/* Birth-time rectification V4 */
.rectification-v4-panel { width: min(860px, 100%); margin: 0 auto; padding: clamp(20px, 4vw, 40px); display: grid; gap: 20px; overflow-y: auto; }
.rectification-v4-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.rectification-v4-header h2 { margin: 4px 0 8px; font-size: clamp(24px, 4vw, 36px); letter-spacing: -0.035em; }
.rectification-v4-header p, .rectification-v4-context p, .rectification-v4-processing p, .rectification-v4-result p { margin: 0; color: var(--color-ink-secondary); line-height: 1.65; }
.rectification-v4-eyebrow { color: var(--color-action) !important; font-size: var(--type-caption); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.rectification-v4-context, .rectification-v4-processing, .rectification-v4-result, .rectification-v4-notice { padding: 18px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.rectification-v4-context { display: grid; gap: 6px; }
.rectification-v4-processing { display: grid; gap: 8px; }
.rectification-v4-ranges { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0 18px; }
.rectification-v4-ranges > div { display: grid; gap: 5px; padding: 16px; border-radius: var(--radius-md); background: var(--color-surface); }
.rectification-v4-ranges span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.rectification-v4-ranges strong { font-size: 24px; letter-spacing: -.02em; }
.rectification-v4-evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.rectification-v4-evidence-grid h3 { margin: 0 0 8px; font-size: 15px; }
.rectification-v4-evidence-grid ul { margin: 0; padding-left: 18px; color: var(--color-ink-secondary); line-height: 1.6; }
.rectification-v4-uncertainty { margin-top: 16px !important; padding-top: 14px; border-top: 1px solid var(--color-border); font-size: var(--type-caption); }
.rectification-v4-actions, .rectification-v4-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; }
.rectification-v4-saved { display: inline-flex; align-items: center; gap: 7px; color: var(--color-action); font-weight: 700; }
.rectification-v4-saved svg, .rectification-v4-footer svg { width: 16px; height: 16px; }
.rectification-v4-composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; }
.rectification-v4-composer label { grid-column: 1 / -1; font-weight: 650; line-height: 1.55; }
.rectification-v4-composer textarea { min-height: 112px; resize: vertical; }
.rectification-v4-footer button { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.rectification-v4-footer button:disabled { cursor: default; opacity: .5; }
@media (max-width: 680px) {
.rectification-v4-panel { padding: 18px 14px 24px; }
.rectification-v4-header { display: grid; }
.rectification-v4-ranges, .rectification-v4-evidence-grid { grid-template-columns: 1fr; }
}
+45 -229
View File
@@ -15,6 +15,7 @@ import {
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import type { RectificationV4Continuation } from "@/components/rectification-v4-panel";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
@@ -53,16 +54,10 @@ import {
type RectificationCardAction,
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import {
ConversationalRectificationRequestError,
conversationalRectificationHistoryForTurn,
sendConversationalRectificationCommand,
} from "@/lib/conversational-rectification/client";
import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts";
import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client";
import {
createDurableRectificationQuestionHandoffClient,
createRectificationQuestionHandoffCoordinator,
DurableRectificationHandoffError,
} from "@/lib/rectification-question-handoff";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import type { ConversationalRectificationMessage } from "@/hooks/use-conversational-rectification";
@@ -210,10 +205,17 @@ type DailyStarlanguageApiResponse = {
};
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type ConsultationRectificationHandoff = Readonly<{
protocol?: "conversational-evidence-v3";
caseId: string;
turnVersion: number;
claimActionId: string;
requestId: string;
}> | Readonly<{
protocol: "rectification-evidence-v4";
caseId: string;
caseVersion: number;
claimActionId: string;
requestId: string;
}>;
type PendingConsultation = {
readonly requestId: string;
@@ -969,9 +971,6 @@ export default function Home() {
const activeOnboardingRequestIdentity = useRef("");
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator<Theme>());
const durableRectificationQuestionHandoff = useRef(
createDurableRectificationQuestionHandoffClient(),
);
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const rectificationContinuationInFlight = useRef(false);
@@ -1025,7 +1024,7 @@ export default function Home() {
|| !account
|| !modelCatalog
|| activeSession?.sessionType !== "birth_time_rectification"
|| visibleRectificationTurn
|| activeSession.id === rectificationSessionId
|| rectificationLoading
|| rectificationMutationPending
|| rectificationContinuationPending
@@ -1042,7 +1041,7 @@ export default function Home() {
rectificationError,
rectificationLoading,
rectificationMutationPending,
visibleRectificationTurn,
rectificationSessionId,
]);
useEffect(() => {
@@ -2061,217 +2060,39 @@ export default function Home() {
sourceSessionOverride: ChatSession | null = null,
) {
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|| rectificationMutationPending
|| rectificationContinuationInFlight.current) return;
|| rectificationMutationPending || rectificationContinuationInFlight.current) return;
const sourceSession = sourceSessionOverride ?? activeSession;
if (!sourceSession) return;
const action = resolveRectificationCardAction({
rectificationCase: account.rectificationCase,
hasConfirmedBirthTime: account.hasConfirmedBirthTime,
});
const sourceBoundCaseId = sourceSession.sessionType === "birth_time_rectification"
? sourceSession.rectificationCaseId
: null;
const accountResumeCase = action === "resume" ? account.rectificationCase : null;
const resumeTarget = sourceBoundCaseId
? {
caseId: sourceBoundCaseId,
turnVersion: accountResumeCase?.caseId === sourceBoundCaseId
? accountResumeCase.turnVersion
: 0,
}
: accountResumeCase;
const resumableSession = !sourceBoundCaseId && accountResumeCase
? sessions.find((session) => session.sessionType === "birth_time_rectification"
&& session.rectificationCaseId === accountResumeCase.caseId)
?? sessions.find((session) => session.sessionType === "birth_time_rectification"
&& session.rectificationCaseId === null)
?? null
: null;
const canReuseSourceRectificationSession = sourceSession.sessionType === "birth_time_rectification"
&& (sourceBoundCaseId !== null || accountResumeCase !== null);
const rectificationSession = canReuseSourceRectificationSession
const existing = sourceSession.sessionType === "birth_time_rectification"
? sourceSession
: resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification");
const reusingRectificationSession = canReuseSourceRectificationSession
|| resumableSession !== null;
const localHandoff = rectificationQuestionHandoff.current.peek();
const requestedQuestion = pendingConsultationQuestion
?? (reusingRectificationSession
? null
: localHandoff?.question)
?? null;
: sessions.find((session) => session.sessionType === "birth_time_rectification") ?? null;
const rectificationSession = existing ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification");
const requestedQuestion = pendingConsultationQuestion ?? rectificationQuestionHandoff.current.peek()?.question ?? null;
rectificationOpenInFlight.current = true;
setRectificationLoading(true);
setRectificationError("");
setRectificationInitialTurn(null);
setRectificationOpeningAssistantText("");
setRectificationPendingQuestion(requestedQuestion);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setRectificationPendingQuestion(requestedQuestion);
setRectificationInitialTurn(null);
setRectificationOpeningAssistantText("");
setRectificationError("");
setRectificationLoading(true);
if (!reusingRectificationSession) {
setSessions((current) => [
rectificationSession,
...current.filter((session) => session.id !== rectificationSession.id),
]);
}
if (sourceSession && sourceSession.id !== rectificationSession.id) {
setRectificationReturnSessionId(sourceSession.id);
}
if (sourceSession.id !== rectificationSession.id) setRectificationReturnSessionId(sourceSession.id);
setRectificationSessionId(rectificationSession.id);
activeSessionIdRef.current = rectificationSession.id;
setActiveSessionId(rectificationSession.id);
try {
let turn: ConversationalRectificationTurn;
if (!resumeTarget) {
const durable = requestedQuestion !== null || localHandoff !== null
? await durableRectificationQuestionHandoff.current.load()
: null;
if (durable && durable.status !== "consumed") {
turn = durable.turn;
} else {
try {
turn = await sendConversationalRectificationCommand({
type: "start",
actionId: globalThis.crypto.randomUUID(),
modelId: rectificationSession.modelId,
pendingConsultationQuestion: requestedQuestion,
}, {
onNarrativeDelta(text) {
setRectificationOpeningAssistantText((current) => current + text);
},
});
} catch (error) {
// A stale account snapshot can make an existing unfinished case look
// like a fresh start. The database rejects that second case with a
// 409 to protect billing; refresh and resume the durable case instead.
if (!(error instanceof ConversationalRectificationRequestError)
|| error.status !== 409) throw error;
const latest = await fetchAccount();
if (!latest.rectificationCase) throw error;
setAccount(latest);
turn = await sendConversationalRectificationCommand({
type: "resume",
caseId: latest.rectificationCase.caseId,
actionId: globalThis.crypto.randomUUID(),
turnVersion: latest.rectificationCase.turnVersion,
}, {
onNarrativeDelta(text) {
setRectificationOpeningAssistantText((current) => current + text);
},
});
}
}
} else {
let current = resumeTarget;
const canAttachQuestion = pendingConsultationQuestion
&& accountResumeCase?.caseId === current.caseId;
if (canAttachQuestion) {
try {
turn = await durableRectificationQuestionHandoff.current.attach({
caseId: current.caseId,
turnVersion: current.turnVersion,
question: pendingConsultationQuestion,
});
} catch (error) {
if (!(error instanceof DurableRectificationHandoffError)
|| error.status !== 409) throw error;
const latest = await fetchAccount();
if (!latest.rectificationCase
|| latest.rectificationCase.caseId !== current.caseId) throw error;
current = latest.rectificationCase;
setAccount(latest);
turn = await durableRectificationQuestionHandoff.current.attach({
caseId: current.caseId,
turnVersion: current.turnVersion,
question: pendingConsultationQuestion,
});
}
} else {
try {
turn = await sendConversationalRectificationCommand({
type: "resume",
caseId: current.caseId,
actionId: globalThis.crypto.randomUUID(),
turnVersion: current.turnVersion,
}, {
onNarrativeDelta(text) {
setRectificationOpeningAssistantText((value) => value + text);
},
});
} catch (error) {
if (!(error instanceof ConversationalRectificationRequestError)
|| error.status !== 409) throw error;
const latest = await fetchAccount();
if (!latest.rectificationCase
|| latest.rectificationCase.caseId !== current.caseId) throw error;
current = latest.rectificationCase;
setAccount(latest);
turn = await sendConversationalRectificationCommand({
type: "resume",
caseId: current.caseId,
actionId: globalThis.crypto.randomUUID(),
turnVersion: current.turnVersion,
}, {
onNarrativeDelta(text) {
setRectificationOpeningAssistantText((value) => value + text);
},
});
}
}
}
const recoveredConversation = conversationalRectificationHistoryForTurn(turn);
const firstSessionMessages = recoveredConversation.length > 0
? recoveredConversation.map(({ role, text }) => ({ role, text }))
: !reusingRectificationSession && rectificationSession.messages.length === 0
? [{ role: "assistant" as const, text: turn.narrative.trim() }]
: rectificationSession.messages;
const boundSession = {
...rectificationSession,
messages: firstSessionMessages,
rectificationCaseId: turn.caseId,
updatedAt: rectificationSession.rectificationCaseId === turn.caseId
&& firstSessionMessages === rectificationSession.messages
? rectificationSession.updatedAt
: timestamp(),
};
setRectificationInitialTurn(turn);
setRectificationOpeningAssistantText("");
synchronizeRectificationQuestion(turn, sourceSession);
setComposerNotice("");
if (!reusingRectificationSession) {
setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]);
void rectificationPersistence.current.enqueue(
boundSession.id,
() => persistSession(boundSession, "create"),
).catch(() => {
setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。");
});
} else if (boundSession !== rectificationSession) {
updateSession(rectificationSession.id, () => boundSession);
void rectificationPersistence.current.enqueue(
boundSession.id,
() => persistSession(boundSession),
).catch(() => {
setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。");
});
}
} catch (caught) {
const message = caught instanceof Error
? caught.message
: "生时校正暂时无法继续,请稍后重试。";
setRectificationError(message);
setComposerNotice(message);
if (!reusingRectificationSession) {
setSessions((current) => current.filter((session) => session.id !== rectificationSession.id));
setRectificationSessionId(null);
if (sourceSession) {
activeSessionIdRef.current = sourceSession.id;
setActiveSessionId(sourceSession.id);
}
try {
if (!existing) {
setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]);
await rectificationPersistence.current.enqueue(
rectificationSession.id,
() => persistSession(rectificationSession, "create"),
);
}
} catch {
setComposerNotice("生时校正已打开,但会话列表暂时未同步到云端。");
} finally {
rectificationOpenInFlight.current = false;
setRectificationLoading(false);
@@ -2711,7 +2532,8 @@ export default function Home() {
modelId: currentSession.modelId,
name: profile.name,
consultationMode: consultationRoute.mode,
...(consultationRoute.mode === "general_no_birth_time" ? {} : {
...(consultationRoute.mode === "general_no_birth_time"
|| rectificationHandoff?.protocol === "rectification-evidence-v4" ? {} : {
entrypoint: entrypoint ?? undefined,
year,
month,
@@ -2865,13 +2687,10 @@ export default function Home() {
}
async function continueRectificationOriginalQuestion(question: string) {
async function continueRectificationOriginalQuestion(continuation: RectificationV4Continuation) {
const question = continuation.question;
if (rectificationContinuationInFlight.current || rectificationMutationPending
|| rectificationLoading || !activeSession || !account) return;
const confirmedTurn = rectificationInitialTurn;
if (!confirmedTurn || confirmedTurn.status !== "completed"
|| confirmedTurn.pendingConsultationQuestion !== question
|| !confirmedTurn.actions.includes("continue_original_question")) return;
if (account.credits <= 0) {
openAccountDialog("redeem", creditTrigger.current);
return;
@@ -2898,9 +2717,9 @@ export default function Home() {
setRectificationContinuationPending(true);
setRectificationError("");
try {
const durableClaim = await durableRectificationQuestionHandoff.current.claim({
caseId: confirmedTurn.caseId,
turnVersion: confirmedTurn.turnVersion,
const durableClaim = await claimRectificationV4Handoff({
caseId: continuation.caseId,
caseVersion: continuation.caseVersion,
question,
});
if (durableClaim.status === "in_progress") {
@@ -2935,8 +2754,9 @@ export default function Home() {
null,
context.sessionId,
{
protocol: "rectification-evidence-v4",
caseId: durableClaim.caseId,
turnVersion: durableClaim.turnVersion,
caseVersion: durableClaim.caseVersion,
claimActionId: durableClaim.claimActionId,
requestId: durableClaim.requestId,
},
@@ -2945,9 +2765,7 @@ export default function Home() {
);
if (completed) {
setRectificationPendingQuestion(null);
setComposerNotice(confirmedTurn.candidate.status === "confirmed"
? "已使用新确认时间继续回答原问题。"
: "已保留候选范围,并按未确认出生分钟的边界继续回答原问题。");
setComposerNotice("已按候选范围边界继续回答原问题。");
} else {
setComposerNotice("原问题仍保留,可再次点击继续回答。");
}
@@ -3249,7 +3067,7 @@ export default function Home() {
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={`${rectificationCardLabel},固定费用 ${account.rectificationPriceCredits}`}
aria-label={rectificationCardLabel}
disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending}
onClick={() => void openBirthTimeRectification()}
/>
@@ -3261,9 +3079,7 @@ export default function Home() {
: "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>{rectificationCardAction === "resume"
? "继续同一案例,不重复收费。"
: `固定费用 ${account.rectificationPriceCredits} 点;首轮有效分析后收取。`}</small>
<small></small>
<span className="product-entrypoint-action" aria-hidden="true">{rectificationCardLabel} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
@@ -3330,7 +3146,7 @@ export default function Home() {
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)}
/>
))}
</div>