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, /