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
+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;
}