fix: close birth-time consultation boundaries

This commit is contained in:
Jesse_Chen
2026-07-21 07:54:04 +08:00
parent 8be39d5ad6
commit bccfb60e41
17 changed files with 827 additions and 121 deletions
+18 -8
View File
@@ -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();
}
+45 -45
View File
@@ -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),
+1 -1
View File
@@ -2638,7 +2638,7 @@ export default function Home() {
{birthTimeDisplay ? (
<>
<div><dt>{birthTimeDisplay.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}</dt><dd>{birthTimeDisplay.activeTime}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.kind === "candidate" ? "未确认,可临时选择使用" : "已确认"}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.kind === "candidate" ? "未确认;咨询时仅可临时使用原始填报时间" : "已确认"}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.reportedLabel}</dd></div>
</>
) : (
@@ -78,7 +78,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
</div>
</dl>
{displayState.kind === "candidate" && (
<p>使</p>
<p>使</p>
)}
</section>
)}
+36 -1
View File
@@ -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<Query> = 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: 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,
@@ -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}`;
@@ -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<Reservation> = Readonly<{
userId: string;
mode: ConsultationBirthTimeMode;
loadProfile: (userId: string) => Promise<unknown>;
reserve: () => Promise<Reservation>;
}>;
type RecordValue = Record<string, unknown>;
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<ConsultationBirthTimeMode, "general_no_birth_time">,
): 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<Reservation>(
input: PrepareConsultationRouteInput<Reservation>,
) {
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 });
}
+86 -8
View File
@@ -11,6 +11,78 @@ type StreamTextResponseOptions = StreamHooks & {
readonly transformText?: (text: string) => string;
};
const hiddenBlockOpeners = [
"<!--AYANAM_SUGGESTIONS:",
"<!--AYANAM_TITLE:",
] as const;
function longestOpenerPrefixSuffix(value: string) {
const maximum = Math.min(
value.length,
Math.max(...hiddenBlockOpeners.map((opener) => opener.length - 1)),
);
for (let length = maximum; length > 0; length -= 1) {
const suffix = value.slice(-length);
if (hiddenBlockOpeners.some((opener) => opener.startsWith(suffix))) return length;
}
return 0;
}
/** Sends only visible prose through the output guard and preserves metadata bytes. */
function createVisibleTextTransformer(transform: (text: string) => string) {
let buffered = "";
let hidden = false;
function consume(value: string, final: boolean) {
buffered += value;
let output = "";
while (buffered) {
if (hidden) {
const closeIndex = buffered.indexOf("-->");
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<number>((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<string>,
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) {
+28
View File
@@ -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;
}
-2
View File
@@ -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;
+76 -2
View File
@@ -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"]);
});
@@ -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 className="phrase-nowrap">当前排盘时间<\/span>并进入对话;<span className="phrase-nowrap">原始填报<\/span>和本次<span className="phrase-nowrap">候选结果<\/span><span className="phrase-nowrap">仍会保留<\/span>。/);
assert.match(pageSource, /未确认,可临时选择使用/);
assert.match(pageSource, /未确认;咨询时仅可临时使用原始填报时间/);
assert.match(pageSource, /<ConversationalBirthTimeRectification/);
assert.doesNotMatch(pageSource, /当前使用候选时间排盘/);
});
+1 -1
View File
@@ -256,5 +256,5 @@ test("candidate copy does not claim an unconfirmed minute is automatically in us
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /已用于当前排盘/);
assert.match(source, /普通咨询前可以选择临时使用或先校正/);
assert.match(source, /普通咨询只能临时使用上面的原始填报时间/);
});
@@ -6,9 +6,10 @@ import {
applyBirthTimeModeToWorkflowContext,
createBirthTimeModeOutputGuard,
consultationBirthTimeModeSchema,
serverProfileAllowsBirthTimeMode,
shouldRunBirthChartWorkflow,
} from "../src/lib/consultation-birth-time-mode.ts";
import { getGeneralJyotishAgent } from "../src/mastra/index.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
test("general-no-birth-time is an explicit server mode that never runs a chart workflow", () => {
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", () => {
@@ -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/);
});
@@ -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);
});
@@ -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 = '<!--AYANAM_SUGGESTIONS:["你一定会升职吗?","D9 是什么?","先完成生时校正"]-->';
const title = "<!--AYANAM_TITLE:一般占星咨询-->";
const longVisiblePrefix = "一般知识不依赖个人星盘。".repeat(100);
async function* reply() {
yield `${longVisiblePrefix}正文说你将在2027年`;
yield "3月15日一定会升职。\n<!";
yield "--AYANAM_SUGGESTIONS:[\"你一定会升职吗?\",\"D9 是什么?\",\"先完成生时校正\"]--";
yield ">\n<!--AYANAM_TIT";
yield "LE:一般占星咨询-->";
}
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, /<!\[/);
assert.deepEqual(parsed.suggestions, ["你一定会升职吗?", "D9 是什么?", "先完成生时校正"]);
assert.equal(parsed.title, "一般占星咨询");
});