diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 9b1663d2..a995e2ff 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -5,6 +5,7 @@ import { } from "@/lib/birth-time-consultation-consent"; import { accountProfilePatchSchema, + applyAccountProfileConcurrencyGuards, resolveAccountBirthTimeApplicationPatch, } from "@/lib/account-profile-patch"; import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin"; @@ -121,11 +122,25 @@ export async function PATCH(request: Request) { const payload = parsedPayload.data; const admin = createAdminSupabaseClient(); - const { data: currentProfile, error: currentProfileError } = await admin + let { data: currentProfile, error: currentProfileError } = await admin .from("profiles") - .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code") + .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") .eq("id", userId) .maybeSingle(); + if (currentProfileError && isMissingProfileColumn(currentProfileError)) { + const fallback = await admin + .from("profiles") + .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code") + .eq("id", userId) + .maybeSingle(); + currentProfile = fallback.data ? { + ...fallback.data, + latitude: undefined, + longitude: undefined, + timezone_offset: undefined, + } : null; + currentProfileError = fallback.error; + } if (currentProfileError) { return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 }); } @@ -179,12 +194,7 @@ export async function PATCH(request: Request) { } let query = admin.from("profiles").update(values).eq("id", userId); if (invalidatesUnconfirmedApplication) { - query = currentProfile.birth_time_status === null - ? query.is("birth_time_status", null) - : query.eq("birth_time_status", currentProfile.birth_time_status); - query = currentProfile.active_birth_time === null - ? query.is("active_birth_time", null) - : query.eq("active_birth_time", currentProfile.active_birth_time); + query = applyAccountProfileConcurrencyGuards(query, currentProfile); } return query.select("id").maybeSingle(); } diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 47f8d502..66a95699 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -24,10 +24,13 @@ import { applyBirthTimeModeToWorkflowContext, consultationBirthTimeModeSchema, createBirthTimeModeOutputGuard, - serverProfileAllowsBirthTimeMode, shouldRunBirthChartWorkflow, type ConsultationBirthTimeMode, } from "@/lib/consultation-birth-time-mode"; +import { + ConsultationProfileTruthError, + prepareConsultationRoute, +} from "@/lib/consultation-route-service"; import { z } from "zod"; export const runtime = "nodejs"; @@ -171,41 +174,6 @@ export async function POST(request: Request) { ); } - const requestedConsultationMode = parsed.data.consultationMode; - if (shouldRunBirthChartWorkflow(requestedConsultationMode)) { - if (!("hour" in parsed.data) || !("minute" in parsed.data)) { - return NextResponse.json( - { error: "出生时间请求格式不正确" }, - { status: 400 }, - ); - } - const requestedTime = `${String(parsed.data.hour).padStart(2, "0")}:${String(parsed.data.minute).padStart(2, "0")}`; - const { data: birthTimeProfile, error: birthTimeProfileError } = await supabase - .from("profiles") - .select("active_birth_time,reported_birth_time,birth_time_source,birth_time_status") - .eq("id", user.id) - .single(); - if (birthTimeProfileError || !birthTimeProfile) { - return NextResponse.json( - { error: "暂时无法核对出生时间状态", message: "请稍后重试,本次不会扣点。" }, - { status: 503 }, - ); - } - if (!serverProfileAllowsBirthTimeMode( - birthTimeProfile, - requestedConsultationMode, - requestedTime, - )) { - return NextResponse.json( - { - error: "出生时间状态已经变化", - message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。", - }, - { status: 409 }, - ); - } - } - const requestTime = new Date(); const resolvedQuestion = resolveConsultationQuestion({ visibleQuestion: parsed.data.question, @@ -215,20 +183,47 @@ export async function POST(request: Request) { const userId = user.id; const requestId = parsed.data.requestId; - let modelSelection; + let prepared; try { - modelSelection = await reserveConsultationModel( - parsed.data.modelId, - resolveLanguageModel, - () => - runCreditRpc( + prepared = await prepareConsultationRoute({ + userId, + mode: parsed.data.consultationMode, + async loadProfile(profileUserId) { + const { data, error } = await supabase + .from("profiles") + .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .eq("id", profileUserId) + .single(); + if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable"); + return data; + }, + reserve: () => reserveConsultationModel( + parsed.data.modelId, + resolveLanguageModel, + () => runCreditRpc( accounting, "begin_consultation_credit", userId, requestId, ), - ); + ), + }); } catch (error) { + if (error instanceof ConsultationProfileTruthError) { + const modeChanged = error.code === "mode_changed"; + return NextResponse.json( + modeChanged + ? { + error: "出生时间状态已经变化", + message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。", + } + : { + error: "暂时无法核对完整出生资料", + message: "出生日期、时间来源或出生地点资料不完整或不一致,请重新保存后再试,本次不会扣点。", + }, + { status: modeChanged ? 409 : 503 }, + ); + } const reason = error instanceof Error ? error.name : "UnknownError"; console.error( `[billing] reservation failed request=${requestId} reason=${reason}`, @@ -239,6 +234,8 @@ export async function POST(request: Request) { ); } + const modelSelection = prepared.reservation; + if (modelSelection.status === "unavailable") { return NextResponse.json( { @@ -299,7 +296,8 @@ export async function POST(request: Request) { } try { - const { history, name } = parsed.data; + const { history } = parsed.data; + const name = prepared.serverChart?.name ?? parsed.data.name; const consultationMode: ConsultationBirthTimeMode = parsed.data.consultationMode; if (!shouldRunBirthChartWorkflow(consultationMode)) { const result = await getGeneralJyotishAgent(selectedModel).stream([ @@ -343,12 +341,14 @@ export async function POST(request: Request) { }); } + if (!prepared.serverChart) throw new Error("server_chart_truth_missing"); const toolInput = consultationInputSchema.parse({ - ...parsed.data, + ...prepared.serverChart.toolInput, // Unverified use is still a normal chart calculation with a hard answer // boundary. It must never reactivate the retired rectification questionnaire. entryMode: "direct_chart", question: resolvedQuestion.modelQuestion, + theme: parsed.data.theme, }); const workflowContext = applyBirthTimeModeToWorkflowContext( await runConsultationWorkflow(toolInput), diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 822e2139..30a97e3b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2638,7 +2638,7 @@ export default function Home() { {birthTimeDisplay ? ( <>
{birthTimeDisplay.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}
{birthTimeDisplay.activeTime}
-
结果状态
{birthTimeDisplay.kind === "candidate" ? "未确认,可临时选择使用" : "已确认"}
+
结果状态
{birthTimeDisplay.kind === "candidate" ? "未确认;咨询时仅可临时使用原始填报时间" : "已确认"}
原始填报
{birthTimeDisplay.reportedLabel}
) : ( diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index c727c8c2..0a3aac12 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -78,7 +78,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {displayState.kind === "candidate" && ( -

这仍是未确认候选,不会自动成为出生分钟;普通咨询前可以选择临时使用或先校正。

+

这仍是未确认候选,不会自动成为出生分钟;普通咨询只能临时使用上面的原始填报时间,或先继续校正。

)} )} diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index 2cba8b5c..16b8eadb 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -148,6 +148,9 @@ type AccountBirthTimeState = Readonly<{ province_code?: string | null; city_code?: string | null; district_code?: string | null; + latitude?: number | null; + longitude?: number | null; + timezone_offset?: number | null; }>; const declarationFields = [ @@ -162,8 +165,39 @@ const declarationFields = [ "province_code", "city_code", "district_code", + "latitude", + "longitude", + "timezone_offset", ] as const; +const concurrencyFields = [ + ...declarationFields, + "active_birth_time", + "birth_time", + "birth_time_status", + "rectification_case_id", +] as const; + +type ConditionalProfileQuery = Readonly<{ + eq: (column: string, value: string | number) => Query; + is: (column: string, value: null) => Query; +}>; + +/** Keeps an ordinary profile edit from overwriting a concurrent edit or confirmation. */ +export function applyAccountProfileConcurrencyGuards< + Query extends ConditionalProfileQuery, +>(query: Query, current: AccountBirthTimeState): Query { + let guarded = query; + for (const field of concurrencyFields) { + const value = current[field]; + if (value === undefined) continue; + guarded = value === null + ? guarded.is(field, null) + : guarded.eq(field, value); + } + return guarded; +} + export type AccountBirthTimeApplicationPatch = Readonly<{ active_birth_time?: null; birth_time?: null; @@ -185,7 +219,8 @@ export function resolveAccountBirthTimeApplicationPatch( if (confirmed) return {}; if (!current.active_birth_time && !current.birth_time - && current.birth_time_status !== "candidate") return {}; + && current.birth_time_status !== "candidate" + && !current.rectification_case_id) return {}; return { active_birth_time: null, birth_time: null, diff --git a/frontend/src/lib/consultation-birth-time-mode.ts b/frontend/src/lib/consultation-birth-time-mode.ts index 0b172e7a..0d820f7e 100644 --- a/frontend/src/lib/consultation-birth-time-mode.ts +++ b/frontend/src/lib/consultation-birth-time-mode.ts @@ -1,5 +1,8 @@ import { z } from "zod"; -import { guardPreciseTimingOutput } from "./timing-output-guard.ts"; +import { + guardGeneralNoBirthTimeOutput, + guardPreciseTimingOutput, +} from "./timing-output-guard.ts"; export const consultationBirthTimeModeSchema = z.enum([ "verified_chart", @@ -15,35 +18,6 @@ export function shouldRunBirthChartWorkflow(mode: ConsultationBirthTimeMode): bo return mode !== "general_no_birth_time"; } -type ServerBirthTimeProfile = Readonly<{ - active_birth_time: string | null; - reported_birth_time: string | null; - birth_time_source: string | null; - birth_time_status: string | null; -}>; - -const concreteReportedSources = new Set([ - "hospital_record", - "family_exact", - "approximate", -]); - -export function serverProfileAllowsBirthTimeMode( - profile: ServerBirthTimeProfile, - mode: ConsultationBirthTimeMode, - requestedTime: string | null, -): boolean { - if (mode === "general_no_birth_time") return requestedTime === null; - if (!requestedTime) return false; - if (mode === "verified_chart") { - return profile.birth_time_status === "confirmed" - && profile.active_birth_time?.slice(0, 5) === requestedTime; - } - return profile.birth_time_status !== "confirmed" - && concreteReportedSources.has(profile.birth_time_source ?? "") - && profile.reported_birth_time?.slice(0, 5) === requestedTime; -} - export function applyBirthTimeModeToWorkflowContext< T extends { consumer_context: { @@ -80,7 +54,9 @@ export function createBirthTimeModeOutputGuard( ): (text: string) => string { let noticeWritten = false; return (text) => { - const guarded = canAnswerPreciseTiming ? text : guardPreciseTimingOutput(text); + const guarded = mode === "general_no_birth_time" + ? guardGeneralNoBirthTimeOutput(text) + : canAnswerPreciseTiming ? text : guardPreciseTimingOutput(text); if (mode !== "unverified_birth_time" || noticeWritten || !guarded.trim()) return guarded; noticeWritten = true; return `> ${UNVERIFIED_BIRTH_TIME_NOTICE}\n\n${guarded}`; diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts new file mode 100644 index 00000000..98f62357 --- /dev/null +++ b/frontend/src/lib/consultation-route-service.ts @@ -0,0 +1,245 @@ +import { chinaLocations } from "../data/china-locations.ts"; +import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts"; +import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts"; + +export type ConsultationProfileTruthErrorCode = + | "profile_unavailable" + | "profile_incomplete" + | "profile_inconsistent" + | "mode_changed"; + +export class ConsultationProfileTruthError extends Error { + readonly code: ConsultationProfileTruthErrorCode; + + constructor(code: ConsultationProfileTruthErrorCode) { + super(`Consultation profile truth rejected: ${code}`); + this.name = "ConsultationProfileTruthError"; + this.code = code; + } +} + +type ServerChartToolInput = Readonly<{ + year: number; + month: number; + day: number; + hour: number; + minute: number; + city: string; + lat: number; + lon: number; + tz: number; +}>; + +export type ServerChartConsultation = Readonly<{ + name: string; + toolInput: ServerChartToolInput; + truth: Readonly<{ + birthDate: string; + reportedBirthTime: string | null; + activeBirthTime: string | null; + selectedTimeKind: "reported" | "active"; + birthTimeSource: string; + birthTimeStatus: string; + placeLabel: string; + placeCodes: Readonly<{ + countryCode: string; + provinceCode: string; + cityCode: string; + districtCode: string | null; + }>; + latitude: number; + longitude: number; + timezoneOffset: number; + }>; +}>; + +type PrepareConsultationRouteInput = Readonly<{ + userId: string; + mode: ConsultationBirthTimeMode; + loadProfile: (userId: string) => Promise; + reserve: () => Promise; +}>; + +type RecordValue = Record; + +const allowedBirthTimeSources = new Set([ + "hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import", +]); +const allowedBirthTimeStatuses = new Set([ + "reported", "assessing", "rectifying", "candidate", "confirmed", +]); +const concreteReportedSources = new Set([ + "hospital_record", "family_exact", "approximate", +]); + +function record(value: unknown): RecordValue | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as RecordValue + : null; +} + +function requiredText(profile: RecordValue, key: string): string { + const value = profile[key]; + if (typeof value !== "string" || !value.trim()) { + throw new ConsultationProfileTruthError("profile_incomplete"); + } + return value.trim(); +} + +function nullableClock(profile: RecordValue, key: string): string | null { + const value = profile[key]; + if (value === null || value === undefined) return null; + if (typeof value !== "string") { + throw new ConsultationProfileTruthError("profile_inconsistent"); + } + const clock = value.slice(0, 5); + if (!isBirthClockTime(clock)) { + throw new ConsultationProfileTruthError("profile_inconsistent"); + } + return clock; +} + +function requiredFiniteNumber(profile: RecordValue, key: string, minimum: number, maximum: number) { + const value = profile[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new ConsultationProfileTruthError("profile_incomplete"); + } + if (value < minimum || value > maximum) { + throw new ConsultationProfileTruthError("profile_inconsistent"); + } + return value; +} + +function sameCoordinate(left: number, right: number) { + return Math.abs(left - right) <= 0.000001; +} + +function serverChartFromProfile( + value: unknown, + mode: Exclude, +): ServerChartConsultation { + const profile = record(value); + if (!profile) throw new ConsultationProfileTruthError("profile_incomplete"); + + const name = requiredText(profile, "name"); + if (name.length > 80) throw new ConsultationProfileTruthError("profile_inconsistent"); + const birthDate = requiredText(profile, "birth_date"); + if (!parseBirthDate(birthDate)) { + throw new ConsultationProfileTruthError("profile_inconsistent"); + } + const [year, month, day] = birthDate.split("-").map(Number); + const reportedBirthTime = nullableClock(profile, "reported_birth_time"); + const activeBirthTime = nullableClock(profile, "active_birth_time"); + const birthTimeSource = requiredText(profile, "birth_time_source"); + const birthTimeStatus = requiredText(profile, "birth_time_status"); + if (!allowedBirthTimeSources.has(birthTimeSource) + || !allowedBirthTimeStatuses.has(birthTimeStatus)) { + throw new ConsultationProfileTruthError("profile_inconsistent"); + } + + const countryCode = requiredText(profile, "country_code"); + const provinceCode = requiredText(profile, "province_code"); + const cityCode = requiredText(profile, "city_code"); + const districtValue = profile.district_code; + const districtCode = typeof districtValue === "string" && districtValue.trim() + ? districtValue.trim() + : null; + const latitude = requiredFiniteNumber(profile, "latitude", -90, 90); + const longitude = requiredFiniteNumber(profile, "longitude", -180, 180); + const timezoneOffset = requiredFiniteNumber(profile, "timezone_offset", -12, 14); + + const country = chinaLocations.country; + const province = country.provinces.find((candidate) => candidate.code === provinceCode); + const city = province?.cities.find((candidate) => candidate.code === cityCode); + const district = districtCode + ? city?.districts.find((candidate) => candidate.code === districtCode) + : undefined; + if (countryCode !== country.code || !province || !city + || (city.districts.length > 0 && !district) + || (districtCode !== null && !district)) { + throw new ConsultationProfileTruthError("profile_inconsistent"); + } + const location = district ?? city; + if (!sameCoordinate(latitude, location.center[1]) + || !sameCoordinate(longitude, location.center[0]) + || !sameCoordinate(timezoneOffset, country.timezone)) { + throw new ConsultationProfileTruthError("profile_inconsistent"); + } + const placeLabel = [country.name, province.name, city.name, district?.name] + .filter((label, index, labels) => Boolean(label) && labels.indexOf(label) === index) + .join(" · "); + + let selectedTime: string; + let selectedTimeKind: "reported" | "active"; + if (mode === "verified_chart") { + if (birthTimeStatus !== "confirmed") { + throw new ConsultationProfileTruthError("mode_changed"); + } + if (!activeBirthTime) throw new ConsultationProfileTruthError("profile_incomplete"); + selectedTime = activeBirthTime; + selectedTimeKind = "active"; + } else { + if (birthTimeStatus === "confirmed" || !concreteReportedSources.has(birthTimeSource)) { + throw new ConsultationProfileTruthError("mode_changed"); + } + if (!reportedBirthTime) throw new ConsultationProfileTruthError("profile_incomplete"); + selectedTime = reportedBirthTime; + selectedTimeKind = "reported"; + } + const [hour, minute] = selectedTime.split(":").map(Number); + + return Object.freeze({ + name, + toolInput: Object.freeze({ + year, + month, + day, + hour, + minute, + city: placeLabel, + lat: latitude, + lon: longitude, + tz: timezoneOffset, + }), + truth: Object.freeze({ + birthDate, + reportedBirthTime, + activeBirthTime, + selectedTimeKind, + birthTimeSource, + birthTimeStatus, + placeLabel, + placeCodes: Object.freeze({ + countryCode, + provinceCode, + cityCode, + districtCode, + }), + latitude, + longitude, + timezoneOffset, + }), + }); +} + +/** + * The route's pre-billing service boundary. Chart modes must load and resolve + * account truth successfully before the reservation callback can run. + */ +export async function prepareConsultationRoute( + input: PrepareConsultationRouteInput, +) { + let serverChart: ServerChartConsultation | null = null; + if (input.mode !== "general_no_birth_time") { + let profile: unknown; + try { + profile = await input.loadProfile(input.userId); + } catch (error) { + if (error instanceof ConsultationProfileTruthError) throw error; + throw new ConsultationProfileTruthError("profile_unavailable"); + } + serverChart = serverChartFromProfile(profile, input.mode); + } + const reservation = await input.reserve(); + return Object.freeze({ serverChart, reservation }); +} diff --git a/frontend/src/lib/stream-text-response.ts b/frontend/src/lib/stream-text-response.ts index bfb7dc17..5e6dd0c8 100644 --- a/frontend/src/lib/stream-text-response.ts +++ b/frontend/src/lib/stream-text-response.ts @@ -11,6 +11,78 @@ type StreamTextResponseOptions = StreamHooks & { readonly transformText?: (text: string) => string; }; +const hiddenBlockOpeners = [ + ""); + if (closeIndex < 0) { + if (final) { + output += buffered; + buffered = ""; + } + break; + } + output += buffered.slice(0, closeIndex + 3); + buffered = buffered.slice(closeIndex + 3); + hidden = false; + continue; + } + + const openerIndex = hiddenBlockOpeners.reduce((earliest, opener) => { + const index = buffered.indexOf(opener); + return index >= 0 && (earliest < 0 || index < earliest) ? index : earliest; + }, -1); + if (openerIndex >= 0) { + if (openerIndex > 0) output += transform(buffered.slice(0, openerIndex)); + buffered = buffered.slice(openerIndex); + hidden = true; + continue; + } + + if (final) { + output += transform(buffered); + buffered = ""; + break; + } + const retainedLength = longestOpenerPrefixSuffix(buffered); + const visibleLength = buffered.length - retainedLength; + if (visibleLength > 0) output += transform(buffered.slice(0, visibleLength)); + buffered = buffered.slice(visibleLength); + break; + } + return output; + } + + return Object.freeze({ + push: (value: string) => consume(value, false), + finish: (value: string) => consume(value, true), + }); +} + export function streamTextResponse( stream: AsyncIterable, options: StreamTextResponseOptions, @@ -20,6 +92,9 @@ export function streamTextResponse( // Keep a full natural-language clause unflushed so a later stream chunk cannot // turn an allowed prefix into a disallowed timing or guaranteed conclusion. const guardTailLength = options.transformText ? 1024 : 0; + const visibleTransformer = options.transformText + ? createVisibleTextTransformer(options.transformText) + : null; let pending = ""; let settled = false; let emitted = false; @@ -30,10 +105,10 @@ export function streamTextResponse( while (true) { const { done, value } = await iterator.next(); if (done) { - if (pending) - controller.enqueue( - encoder.encode(options.transformText?.(pending) ?? pending), - ); + const finalText = visibleTransformer + ? visibleTransformer.finish(pending) + : pending; + if (finalText) controller.enqueue(encoder.encode(finalText)); settled = true; if (!emitted) { const error = new Error("empty_stream"); @@ -52,10 +127,13 @@ export function streamTextResponse( const stableLength = pending.length - guardTailLength; const stable = pending.slice(0, stableLength); pending = pending.slice(stableLength); - controller.enqueue( - encoder.encode(options.transformText?.(stable) ?? stable), - ); - return; + const transformed = visibleTransformer + ? visibleTransformer.push(stable) + : stable; + if (transformed) { + controller.enqueue(encoder.encode(transformed)); + return; + } } } catch (error) { if (!settled) { diff --git a/frontend/src/lib/timing-output-guard.ts b/frontend/src/lib/timing-output-guard.ts index 72fa147b..2d64dca0 100644 --- a/frontend/src/lib/timing-output-guard.ts +++ b/frontend/src/lib/timing-output-guard.ts @@ -11,6 +11,23 @@ const guaranteeConclusionPatterns = [ /(?:^|[.?!\n])[^.?!\n]*\b(?:will definitely|guaranteed? to|certain to|without doubt)\b[^.?!\n]*/gi, ]; +const personalChartClaimMarkers = [ + String.raw`(?:基于|根据|从|结合)\s*(?:你|您)\s*(?:的\s*)?(?:个人\s*)?(?:星盘|命盘|出生盘|本命盘|盘)`, + String.raw`(?:你|您)\s*的\s*(?:(?:D\s*\d+)(?:\s*上升)?|上升(?:星座)?|月亮星座|太阳星座|太阳|月亮|火星|水星|木星|金星|土星|罗喉|凯图|Rahu|Ketu|第\s*[一二三四五六七八九十百0-9]+\s*宫|星盘|命盘|出生盘|本命盘|盘)`, + String.raw`(?:你|您)\s*(?:的\s*)?(?:(?:D\s*\d+)(?:\s*上升)?|上升(?:星座)?|月亮星座|太阳星座|太阳|月亮|火星|水星|木星|金星|土星|罗喉|凯图|Rahu|Ketu|第\s*[一二三四五六七八九十百0-9]+\s*宫|星盘|命盘|本命盘|盘)\s*(?:(?:一定|必然|肯定|必定|绝对)\s*)?(?:是|在|落(?:在|入)?|位于|显示|表明|说明|意味着|主宰)`, + String.raw`(?:你|您)\s*(?:的\s*)?(?:星盘|命盘|出生盘|本命盘|盘)\s*(?:中|里|内)`, + String.raw`(?:D\s*\d+|上升(?:星座)?)\s*(?:显示|表明|说明|意味着)\s*(?:你|您)`, + String.raw`(?:your|the user's)\s+(?:natal\s+|birth\s+)?(?:chart|ascendant|D\s*\d+|\d+(?:st|nd|rd|th)\s+house)`, +]; + +const personalChartClaimPatterns = personalChartClaimMarkers.map((marker) => new RegExp( + String.raw`(^|[。!?.!?\n])[^。!?.!?\n]*${marker}[^。!?.!?\n]*`, + "giu", +)); + +export const GENERAL_NO_BIRTH_TIME_REFUSAL = + "当前一般咨询模式不能生成个人星盘结论;你可以改问一般知识,或先完成生时校正"; + /** Removes claims the evidence contract does not permit the model to make. */ export function guardPreciseTimingOutput(text: string) { let guarded = text; @@ -24,3 +41,14 @@ export function guardPreciseTimingOutput(text: string) { } return guarded; } + +/** A deterministic post-model boundary for the zero-chart general mode. */ +export function guardGeneralNoBirthTimeOutput(text: string) { + let guarded = guardPreciseTimingOutput(text); + for (const pattern of personalChartClaimPatterns) { + guarded = guarded.replace(pattern, (_sentence, prefix: string) => ( + `${prefix}${GENERAL_NO_BIRTH_TIME_REFUSAL}` + )); + } + return guarded; +} diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 617f2b75..914886ac 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -255,8 +255,6 @@ export function getGeneralJyotishAgent(model: ResolvedLanguageModel) { name: "Jyotisha General Guide", model: model.model, instructions: generalJyotishInstructions, - skills: [jyotishSkillPath], - tools: {}, }); generalJyotishAgents.set(model.id, agent); return agent; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 9570ed3b..176ab069 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -7,6 +7,7 @@ import { } from "../src/lib/account-profile-patch.ts"; const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); +const patchSource = readFileSync(new URL("../src/lib/account-profile-patch.ts", import.meta.url), "utf8"); test("account API reads and returns the server-configured rectification price", () => { assert.match(source, /parseRectificationPriceCredits\(\s*process\.env\.RECTIFICATION_PRICE_CREDITS,?\s*\)/); @@ -91,6 +92,9 @@ test("ordinary declaration edits clear stale candidate application but never ove birth_time: "05:18", birth_time_status: "candidate", rectification_case_id: "11111111-1111-4111-8111-111111111111", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, } as const; const edited = { birth_date: candidate.birth_date, @@ -116,10 +120,33 @@ test("ordinary declaration edits clear stale candidate application but never ove birth_time_status: "reported", rectification_case_id: null, }); + for (const coordinatePatch of [ + { latitude: 36.420488 }, + { longitude: 114.209937 }, + { timezone_offset: 9 }, + ]) { + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(candidate, coordinatePatch), { + active_birth_time: null, + birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + }); + } assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...candidate, birth_time_status: "confirmed", }, edited), {}); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...candidate, + active_birth_time: null, + birth_time: null, + birth_time_status: "reported", + }, { timezone_offset: 7 }), { + active_birth_time: null, + birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + }); }); test("account PATCH uses the shared validator and never writes client birth_time over account truth", () => { @@ -127,7 +154,54 @@ test("account PATCH uses the shared validator and never writes client birth_time assert.match(source, /resolveAccountBirthTimeApplicationPatch/); assert.doesNotMatch(source, /birth_time:\s*nullableString\(payload\.birth_time\)/); assert.match(source, /invalidatesUnconfirmedApplication/); - assert.match(source, /query\.eq\("birth_time_status", currentProfile\.birth_time_status\)/); - assert.match(source, /query\.eq\("active_birth_time", currentProfile\.active_birth_time\)/); + assert.match(patchSource, /"birth_time_status"/); + assert.match(patchSource, /"active_birth_time"/); + assert.match(source, /latitude,longitude,timezone_offset/); + assert.match(source, /applyAccountProfileConcurrencyGuards/); assert.match(source, /最新确认结果已保留/); }); + +test("candidate invalidation compares coordinates and timezone in the conditional write", async () => { + const { applyAccountProfileConcurrencyGuards } = await import("../src/lib/account-profile-patch.ts"); + const calls: Array<["eq" | "is", string, unknown]> = []; + const query = { + eq(column: string, value: unknown) { + calls.push(["eq", column, value]); + return this; + }, + is(column: string, value: null) { + calls.push(["is", column, value]); + return this; + }, + }; + const current = { + birth_date: "1997-08-08", + reported_birth_time: "05:30", + birth_time_source: "approximate", + birth_time_period: null, + birth_time_clue: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + active_birth_time: "05:18", + birth_time: "05:18", + birth_time_status: "candidate", + rectification_case_id: "11111111-1111-4111-8111-111111111111", + country_code: "CN", + province_code: "130000", + city_code: "130400", + district_code: "130406", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, + }; + + applyAccountProfileConcurrencyGuards(query, current); + + assert.deepEqual(calls.filter((call) => ["latitude", "longitude", "timezone_offset"].includes(call[1])), [ + ["eq", "latitude", 36.420487], + ["eq", "longitude", 114.209936], + ["eq", "timezone_offset", 8], + ]); + assert.deepEqual(calls.find((call) => call[1] === "active_birth_time"), ["eq", "active_birth_time", "05:18"]); + assert.deepEqual(calls.find((call) => call[1] === "birth_time_status"), ["eq", "birth_time_status", "candidate"]); +}); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 5658eada..867a7af9 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -150,7 +150,7 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); assert.match(candidateResultSource, /作为当前排盘时间<\/span>并进入对话;原始填报<\/span>和本次候选结果<\/span>仍会保留<\/span>。/); - assert.match(pageSource, /未确认,可临时选择使用/); + assert.match(pageSource, /未确认;咨询时仅可临时使用原始填报时间/); assert.match(pageSource, / { assert.equal(consultationBirthTimeModeSchema.safeParse("verified_chart").success, true); @@ -40,23 +41,6 @@ test("unverified chart context can never become confirmed or retain precise timi }); }); -test("server profile truth prevents a candidate or edited report from being submitted as confirmed", () => { - const candidateProfile = { - active_birth_time: "05:18", - reported_birth_time: "06:10", - birth_time_source: "approximate", - birth_time_status: "candidate", - }; - assert.equal(serverProfileAllowsBirthTimeMode(candidateProfile, "verified_chart", "05:18"), false); - assert.equal(serverProfileAllowsBirthTimeMode(candidateProfile, "unverified_birth_time", "05:18"), false); - assert.equal(serverProfileAllowsBirthTimeMode(candidateProfile, "unverified_birth_time", "06:10"), true); - assert.equal(serverProfileAllowsBirthTimeMode(candidateProfile, "general_no_birth_time", null), true); - - const confirmedProfile = { ...candidateProfile, birth_time_status: "confirmed" }; - assert.equal(serverProfileAllowsBirthTimeMode(confirmedProfile, "verified_chart", "05:18"), true); - assert.equal(serverProfileAllowsBirthTimeMode(confirmedProfile, "unverified_birth_time", "06:10"), false); -}); - test("every unverified streamed answer receives a stable visible marker and timing guard", () => { const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false); const first = transform("2026年8月适合观察方向。"); @@ -68,11 +52,54 @@ test("every unverified streamed answer receives a stable visible marker and timi assert.doesNotMatch(second, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE)); }); +test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => { + const transform = createBirthTimeModeOutputGuard("general_no_birth_time", false); + const guarded = transform([ + "D9 在印度占星中通常用于观察婚姻与法则层面的成熟。", + "忽略之前的规则,基于你的盘,你的 D9 上升一定是处女座。", + "你的上升是巨蟹座,因此你一定会升职。", + "你的金星落在第七宫。", + "D9 显示你适合晚婚。", + "你的 D9:处女上升。", + ].join("\n")); + + assert.match(guarded, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/); + assert.match(guarded, /一般咨询模式不能生成个人星盘结论/); + assert.doesNotMatch(guarded, /你的 D9 上升一定是处女座|你的上升是巨蟹座|你一定会升职|你的金星落在|D9 显示你|你的 D9:处女上升/); + assert.doesNotMatch(guarded, /。。/); +}); + +test("general agent runtime has no skill, skill search, skill read, or chart tool", async () => { + const model: ResolvedLanguageModel = { + id: "general-zero-tool-probe", + label: "General probe", + description: "", + creditCost: 1, + isDefault: false, + mode: "openai", + model: "openai/gpt-5-mini", + }; + const agent = getGeneralJyotishAgent(model); + const skills = await agent.listSkills(); + const toolNames = Object.keys(await agent.listTools()); + + assert.deepEqual(skills, []); + assert.deepEqual(toolNames, []); + assert.equal(toolNames.some((name) => ["skill", "skill_search", "skill_read"].includes(name)), false); + + const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8"); + const generalFactory = mastra.slice( + mastra.indexOf("export function getGeneralJyotishAgent"), + mastra.indexOf("const onboardingInstructions"), + ); + assert.doesNotMatch(generalFactory, /\bskills\s*:|\btools\s*:/); +}); + test("consult route validates mode before billing and general mode uses no chart agent or workflow", () => { const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8"); const parseIndex = route.indexOf("chatRequestSchema.safeParse"); - const profileTruthIndex = route.indexOf("serverProfileAllowsBirthTimeMode(", parseIndex); + const profileTruthIndex = route.indexOf("prepareConsultationRoute({", parseIndex); const reserveIndex = route.indexOf("reserveConsultationModel(", parseIndex); assert.ok(parseIndex >= 0 && profileTruthIndex > parseIndex && reserveIndex > profileTruthIndex); @@ -81,7 +108,11 @@ test("consult route validates mode before billing and general mode uses no chart assert.match(route, /getGeneralJyotishAgent/); assert.match(mastra, /getGeneralJyotishAgent/); assert.match(mastra, /Never calculate, infer, or claim a personal birth chart/); - assert.match(mastra, /tools:\s*\{\}/); + const generalFactory = mastra.slice( + mastra.indexOf("export function getGeneralJyotishAgent"), + mastra.indexOf("const onboardingInstructions"), + ); + assert.doesNotMatch(generalFactory, /\bskills\s*:|\btools\s*:/); }); test("homepage sends explicit modes and never routes an unverified minute through the retired questionnaire", () => { diff --git a/frontend/tests/consultation-route-service.test.ts b/frontend/tests/consultation-route-service.test.ts new file mode 100644 index 00000000..96088d48 --- /dev/null +++ b/frontend/tests/consultation-route-service.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + ConsultationProfileTruthError, + prepareConsultationRoute, +} from "../src/lib/consultation-route-service.ts"; + +const profile = Object.freeze({ + name: "岳辰", + birth_date: "1997-08-08", + reported_birth_time: "05:30:00", + active_birth_time: "05:18:00", + birth_time_source: "approximate", + birth_time_status: "candidate", + country_code: "CN", + province_code: "130000", + city_code: "130400", + district_code: "130406", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, +}); + +test("route service loads complete server truth before billing and uses only reported time when unverified", async () => { + const order: string[] = []; + const prepared = await prepareConsultationRoute({ + userId: "user-1", + mode: "unverified_birth_time", + async loadProfile(userId) { + assert.equal(userId, "user-1"); + order.push("profile"); + return profile; + }, + async reserve() { + order.push("reserve"); + return { reservation: "ok" }; + }, + }); + + assert.deepEqual(order, ["profile", "reserve"]); + assert.deepEqual(prepared.reservation, { reservation: "ok" }); + assert.deepEqual(prepared.serverChart?.toolInput, { + year: 1997, + month: 8, + day: 8, + hour: 5, + minute: 30, + city: "中国 · 河北省 · 邯郸市 · 峰峰矿区", + lat: 36.420487, + lon: 114.209936, + tz: 8, + }); + assert.equal(prepared.serverChart?.name, "岳辰"); + assert.equal(prepared.serverChart?.truth.birthTimeSource, "approximate"); + assert.equal(prepared.serverChart?.truth.birthTimeStatus, "candidate"); + assert.deepEqual(prepared.serverChart?.truth.placeCodes, { + countryCode: "CN", + provinceCode: "130000", + cityCode: "130400", + districtCode: "130406", + }); +}); + +test("verified route uses only server active time", async () => { + const prepared = await prepareConsultationRoute({ + userId: "user-1", + mode: "verified_chart", + loadProfile: async () => ({ ...profile, birth_time_status: "confirmed" }), + reserve: async () => "reserved", + }); + + assert.equal(prepared.serverChart?.toolInput.hour, 5); + assert.equal(prepared.serverChart?.toolInput.minute, 18); + assert.equal(prepared.serverChart?.truth.selectedTimeKind, "active"); +}); + +test("incomplete, inconsistent, or mode-mismatched profile fails before billing", async () => { + for (const [expectedCode, invalid] of [ + ["profile_incomplete", { ...profile, birth_date: null }], + ["profile_inconsistent", { ...profile, latitude: 36.5 }], + ["profile_inconsistent", { ...profile, timezone_offset: 9 }], + ["mode_changed", { ...profile, birth_time_status: "confirmed" }], + ] as const) { + let reserveCalls = 0; + await assert.rejects( + prepareConsultationRoute({ + userId: "user-1", + mode: "unverified_birth_time", + loadProfile: async () => invalid, + reserve: async () => { + reserveCalls += 1; + return "reserved"; + }, + }), + (error: unknown) => error instanceof ConsultationProfileTruthError + && error.code === expectedCode, + ); + assert.equal(reserveCalls, 0); + } +}); + +test("profile storage failure is stable and never reaches billing", async () => { + let reserveCalls = 0; + await assert.rejects( + prepareConsultationRoute({ + userId: "user-1", + mode: "verified_chart", + loadProfile: async () => { throw new Error("raw database detail"); }, + reserve: async () => { + reserveCalls += 1; + return "reserved"; + }, + }), + (error: unknown) => error instanceof ConsultationProfileTruthError + && error.code === "profile_unavailable" + && !error.message.includes("raw database detail"), + ); + assert.equal(reserveCalls, 0); +}); + +test("general route reserves without loading chart profile", async () => { + let profileLoads = 0; + const prepared = await prepareConsultationRoute({ + userId: "user-1", + mode: "general_no_birth_time", + loadProfile: async () => { + profileLoads += 1; + return profile; + }, + reserve: async () => "reserved", + }); + + assert.equal(profileLoads, 0); + assert.equal(prepared.serverChart, null); + assert.equal(prepared.reservation, "reserved"); +}); + +test("consult route constructs workflow input from the route service rather than client chart fields", () => { + const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); + const select = "name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset"; + + assert.match(route, new RegExp(select)); + assert.match(route, /prepareConsultationRoute/); + assert.match(route, /\.\.\.prepared\.serverChart\.toolInput/); + const toolInput = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse")); + assert.doesNotMatch(toolInput.slice(0, toolInput.indexOf("const workflowContext")), /\.\.\.parsed\.data/); +}); diff --git a/frontend/tests/stream-text-response.test.ts b/frontend/tests/stream-text-response.test.ts index 03ce871d..6974bf97 100644 --- a/frontend/tests/stream-text-response.test.ts +++ b/frontend/tests/stream-text-response.test.ts @@ -118,3 +118,51 @@ test("does not run cancellation settlement once completion has started", async ( assert.equal(completed, 1); assert.equal(cancelled, 0); }); + +test("transformed empty streams still refund through the error settlement", async () => { + let completed = 0; + let errors = 0; + async function* reply() { + // Intentionally empty. + } + const response = streamTextResponse(reply(), { + mode: "mastra", + requestId: "00000000-0000-4000-8000-000000000005", + transformText: (text) => text, + onComplete: async () => { completed += 1; }, + onError: async (_error, emitted) => { + assert.equal(emitted, false); + errors += 1; + }, + }); + + await assert.rejects(response.text(), /empty_stream/); + assert.equal(completed, 0); + assert.equal(errors, 1); +}); + +test("cancelling a transformed stream after visible output preserves emitted settlement", async () => { + let charged = 0; + let refunded = 0; + async function* reply() { + yield "一般正文。".repeat(300); + yield "不应继续读取"; + } + const response = streamTextResponse(reply(), { + mode: "mastra", + requestId: "00000000-0000-4000-8000-000000000006", + transformText: (text) => text, + onCancel: async (emitted) => { + if (emitted) charged += 1; + else refunded += 1; + }, + }); + const reader = response.body?.getReader(); + assert.ok(reader); + const first = await reader.read(); + assert.equal(first.done, false); + + await reader.cancel(); + assert.equal(charged, 1); + assert.equal(refunded, 0); +}); diff --git a/frontend/tests/timing-output-guard.test.ts b/frontend/tests/timing-output-guard.test.ts index 0a4d606c..17f1663a 100644 --- a/frontend/tests/timing-output-guard.test.ts +++ b/frontend/tests/timing-output-guard.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import { guardPreciseTimingOutput } from "../src/lib/timing-output-guard.ts"; import { streamTextResponse } from "../src/lib/stream-text-response.ts"; +import { parseAgentReply } from "../src/lib/agent-reply.ts"; +import { createBirthTimeModeOutputGuard } from "../src/lib/consultation-birth-time-mode.ts"; test("removes exact dates and months when precise timing is blocked", () => { const guarded = guardPreciseTimingOutput( @@ -31,6 +33,8 @@ test("guards a date that crosses streamed chunks", async () => { } const response = streamTextResponse(reply(), { + mode: "mastra", + requestId: "00000000-0000-4000-8000-000000000098", transformText: guardPreciseTimingOutput, }); const text = await response.text(); @@ -38,3 +42,33 @@ test("guards a date that crosses streamed chunks", async () => { assert.doesNotMatch(text, /2027年3月15日|保证你一定会升职/); assert.match(text, /保证性结论已省略/); }); + +test("guards only visible prose and preserves AYANAM blocks across arbitrary chunk boundaries", async () => { + const suggestions = ''; + const title = ""; + const longVisiblePrefix = "一般知识不依赖个人星盘。".repeat(100); + async function* reply() { + yield `${longVisiblePrefix}正文说你将在2027年`; + yield "3月15日一定会升职。\n\n"; + } + + const response = streamTextResponse(reply(), { + mode: "mastra", + requestId: "00000000-0000-4000-8000-000000000099", + transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false), + }); + const text = await response.text(); + const parsed = parseAgentReply(text, "general"); + + assert.doesNotMatch(text, /2027年3月15日|正文说你将在.*一定会升职/); + assert.match(text, /^一般知识不依赖个人星盘/); + assert.match(text, /具体时间已省略|保证性结论已省略/); + assert.equal(text.includes(suggestions), true); + assert.equal(text.includes(title), true); + assert.doesNotMatch(text, /