fix: close birth-time consultation boundaries
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user