feat: complete minute birth-time rectification flow

This commit is contained in:
Jesse_Chen
2026-07-25 01:15:48 +08:00
parent f90aba217f
commit 3ca30ed7de
93 changed files with 8347 additions and 1646 deletions
+33 -4
View File
@@ -44,6 +44,12 @@ export const accountProfilePatchSchema = z.object({
latitude: z.number().finite().min(-90).max(90).nullable().optional(),
longitude: z.number().finite().min(-180).max(180).nullable().optional(),
timezone_offset: z.number().finite().min(-12).max(14).nullable().optional(),
birth_place_label: nullableTrimmedString(240).optional(),
birth_place_type: nullableTrimmedString(40).optional(),
birth_place_provider: z.enum(["geoapify", "china_locations", "mapbox", "geonames"]).nullable().optional(),
birth_place_provider_id: nullableTrimmedString(160).optional(),
timezone_id: nullableTrimmedString(80).optional(),
timezone_source: z.literal("iana_historical").nullable().optional(),
}).strict().superRefine((value, context) => {
const source = value.birth_time_source;
const time = value.reported_birth_time;
@@ -65,10 +71,21 @@ export const accountProfilePatchSchema = z.object({
"uncertainty_after_minutes",
] as const;
const mutatesDeclaration = declarationKeys.some((key) => value[key] !== undefined);
const coordinates = [value.latitude, value.longitude, value.timezone_offset];
const concreteCoordinateCount = coordinates.filter((coordinate) => coordinate != null).length;
if (concreteCoordinateCount > 0 && concreteCoordinateCount < coordinates.length) {
addIssue("latitude", "出生地点坐标与时区必须完整提交");
const coordinateCount = [value.latitude, value.longitude].filter((coordinate) => coordinate != null).length;
if (coordinateCount === 1) {
addIssue("latitude", "出生地点经纬度必须完整提交");
}
const globalLocationFields = [
value.birth_place_label,
value.birth_place_type,
value.birth_place_provider,
value.birth_place_provider_id,
value.timezone_id,
value.timezone_source,
];
const hasGlobalLocation = globalLocationFields.some((field) => field != null);
if (hasGlobalLocation && (coordinateCount !== 2 || !value.timezone_id)) {
addIssue("timezone_id", "全球出生地点必须包含完整坐标与 IANA 时区");
}
if (source === undefined) {
if (mutatesDeclaration) addIssue("birth_time_source", "修改出生资料时必须同时说明时间来源");
@@ -151,6 +168,12 @@ type AccountBirthTimeState = Readonly<{
latitude?: number | null;
longitude?: number | null;
timezone_offset?: number | null;
birth_place_label?: string | null;
birth_place_type?: string | null;
birth_place_provider?: string | null;
birth_place_provider_id?: string | null;
timezone_id?: string | null;
timezone_source?: string | null;
}>;
const declarationFields = [
@@ -168,6 +191,12 @@ const declarationFields = [
"latitude",
"longitude",
"timezone_offset",
"birth_place_label",
"birth_place_type",
"birth_place_provider",
"birth_place_provider_id",
"timezone_id",
"timezone_source",
] as const;
const concurrencyFields = [
+31 -14
View File
@@ -4,6 +4,7 @@ import {
declaredBirthInputSchema,
type DeclaredBirthInput,
} from "./conversational-rectification/persistence-contracts.ts";
import { durableBirthCoordinate } from "./birth-profile-timezone.ts";
type RecordValue = Record<string, unknown>;
@@ -39,25 +40,34 @@ function integer(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function currentDeclaration(value: unknown): DeclaredBirthInput | null {
function currentDeclaration(value: unknown, fallbackTimezoneOffset?: number): DeclaredBirthInput | null {
const profile = record(value);
if (!profile) return null;
const birthDate = text(profile.birth_date);
const source = text(profile.birth_time_source);
const cityCode = text(profile.city_code);
const latitude = finiteNumber(profile.latitude);
const longitude = finiteNumber(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset);
if (!birthDate || !source || !cityCode || latitude === null || longitude === null
const city = text(profile.birth_place_label);
const placeId = text(profile.birth_place_provider_id);
const latitude = durableBirthCoordinate(profile.latitude);
const longitude = durableBirthCoordinate(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset) ?? fallbackTimezoneOffset ?? null;
if (!birthDate || !source || (!city && !cityCode && !placeId)
|| latitude === null || longitude === null
|| timezoneOffset === null) return null;
const birthplace = {
...(city ? { city } : {}),
...(placeId ? { placeId } : {}),
...(text(profile.birth_place_type) ? { placeType: text(profile.birth_place_type) } : {}),
...(text(profile.birth_place_provider) ? { provider: text(profile.birth_place_provider) } : {}),
...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}),
...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}),
cityCode,
...(cityCode ? { cityCode } : {}),
...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}),
latitude,
longitude,
...(text(profile.timezone_id) ? { timezoneId: text(profile.timezone_id) } : {}),
...(text(profile.timezone_source) ? { timezoneSource: text(profile.timezone_source) } : {}),
timezoneOffset,
};
const common = {
@@ -107,6 +117,7 @@ function currentDeclaration(value: unknown): DeclaredBirthInput | null {
function canonicalPlaceLabel(input: DeclaredBirthInput): string | null {
const place = input.birthplace;
if (place.city) return place.city;
const country = chinaLocations.country;
if (place.countryCode !== country.code || !place.provinceCode || !place.cityCode) return null;
const province = country.provinces.find((candidate) => candidate.code === place.provinceCode);
@@ -120,10 +131,10 @@ function canonicalPlaceLabel(input: DeclaredBirthInput): string | null {
.join(" · ");
}
function withoutOptionalPlaceLabel(input: DeclaredBirthInput): unknown {
const birthplace: Record<string, unknown> = { ...input.birthplace };
delete birthplace.city;
return { ...input, birthplace };
function withoutBirthplace(input: DeclaredBirthInput): unknown {
const declaration: Record<string, unknown> = { ...input };
delete declaration.birthplace;
return declaration;
}
function sameJson(left: unknown, right: unknown): boolean {
@@ -144,9 +155,15 @@ function sameJson(left: unknown, right: unknown): boolean {
}
function declarationMatches(current: DeclaredBirthInput, stored: DeclaredBirthInput) {
if (!sameJson(withoutOptionalPlaceLabel(current), withoutOptionalPlaceLabel(stored))) {
if (!sameJson(withoutBirthplace(current), withoutBirthplace(stored))) {
return false;
}
const currentPlace = current.birthplace as Record<string, unknown>;
const storedPlace = stored.birthplace as Record<string, unknown>;
for (const [key, value] of Object.entries(storedPlace)) {
if (key === "city") continue;
if (!sameJson(currentPlace[key], value)) return false;
}
if (!stored.birthplace.city) return true;
return stored.birthplace.city === canonicalPlaceLabel(current);
}
@@ -177,15 +194,15 @@ export function resolveAccountRectificationCase(
profile: unknown,
rows: readonly unknown[],
): AccountRectificationCaseState | null {
const current = currentDeclaration(profile);
if (!current) return null;
for (const value of rows) {
const row = record(value);
if (!row) continue;
const projected = project(row);
if (!projected) continue;
const declared = declaredBirthInputSchema.safeParse(row.declared_birth_input);
if (declared.success && declarationMatches(current, declared.data)) return projected;
if (!declared.success) continue;
const current = currentDeclaration(profile, declared.data.birthplace.timezoneOffset);
if (current && declarationMatches(current, declared.data)) return projected;
}
return null;
}
+102
View File
@@ -0,0 +1,102 @@
type RecordValue = Record<string, unknown>;
export class BirthProfileTimezoneError extends Error {
constructor() {
super("Unable to resolve historical birth timezone offset");
this.name = "BirthProfileTimezoneError";
}
}
function record(value: unknown): RecordValue | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as RecordValue
: null;
}
function valueFor(profile: RecordValue, snakeKey: string, camelKey: string): unknown {
return profile[snakeKey] ?? profile[camelKey];
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function finiteBirthNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function durableBirthCoordinate(value: unknown): number | null {
const coordinate = finiteBirthNumber(value);
if (coordinate === null) return null;
const rounded = Math.round(coordinate * 1_000_000) / 1_000_000;
return Object.is(rounded, -0) ? 0 : rounded;
}
export function birthProfileReferenceTime(value: unknown, preferredTime?: string | null): string {
const preferred = text(preferredTime)?.slice(0, 5);
if (preferred && /^([01]\d|2[0-3]):[0-5]\d$/.test(preferred)) return preferred;
const profile = record(value);
const reported = text(profile ? valueFor(profile, "reported_birth_time", "reportedTime") : null)?.slice(0, 5);
if (reported && /^([01]\d|2[0-3]):[0-5]\d$/.test(reported)) return reported;
const direct = text(profile ? valueFor(profile, "birth_time", "time") : null)?.slice(0, 5);
if (direct && /^([01]\d|2[0-3]):[0-5]\d$/.test(direct)) return direct;
const period = text(profile ? valueFor(profile, "birth_time_period", "birthTimePeriod") : null);
return {
early_morning: "06:00",
morning: "10:00",
afternoon: "15:00",
evening: "20:30",
late_night: "23:30",
}[period ?? ""] ?? "12:00";
}
type ResolveBirthTimezoneOptions = Readonly<{
fetchImpl?: typeof fetch;
apiBase?: string;
preferredTime?: string | null;
}>;
export async function resolveMissingBirthTimezoneOffset(
value: unknown,
options: ResolveBirthTimezoneOptions = {},
): Promise<unknown> {
const profile = record(value);
if (!profile) return value;
const existingOffset = finiteBirthNumber(valueFor(profile, "timezone_offset", "timezoneOffset"));
if (existingOffset !== null) return value;
const latitude = finiteBirthNumber(profile.latitude);
const longitude = finiteBirthNumber(profile.longitude);
const birthDate = text(valueFor(profile, "birth_date", "date"));
const timezoneId = text(valueFor(profile, "timezone_id", "timezoneId"));
if (latitude === null || longitude === null || !birthDate || !timezoneId) return value;
const fetchImpl = options.fetchImpl ?? fetch;
const apiBase = options.apiBase ?? process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
let response: Response;
try {
response = await fetchImpl(`${apiBase}/api/location/timezone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude,
longitude,
birthDate,
birthTime: birthProfileReferenceTime(profile, options.preferredTime),
}),
cache: "no-store",
});
} catch {
throw new BirthProfileTimezoneError();
}
if (!response.ok) throw new BirthProfileTimezoneError();
const payload = record(await response.json().catch(() => null));
const timezoneOffset = finiteBirthNumber(payload?.timezoneOffset);
if (payload?.available !== true || timezoneOffset === null) {
throw new BirthProfileTimezoneError();
}
return {
...profile,
timezone_offset: timezoneOffset,
timezoneOffset,
};
}
+6 -1
View File
@@ -93,6 +93,11 @@ const rectificationTechniqueReceiptSchema = z.object({
missingLayers: z.array(z.string()),
auxiliaryLayers: z.array(z.string()).default([]),
hardBlockers: z.array(z.string()),
externalEngines: z.object({
status: z.string(),
providers: z.array(z.string()),
validation: z.record(z.string(), z.unknown()).optional(),
}).strict().optional(),
canonicalInputHash: z.string().optional(),
confirmationAllowed: z.boolean().optional(),
decision: z.enum(["continue_rectification", "confirm_minute"]).optional(),
@@ -112,7 +117,7 @@ export const candidateResultSchema = z.object({
representativeTime: timeSchema,
widthMinutes: z.number().int().min(1).max(1_440),
}).strict().readonly().nullable(),
eventCount: z.number().int().min(0).max(10),
eventCount: z.number().int().min(0),
domainCount: z.number().int().min(0).max(6),
topScore: z.number(),
secondScore: z.number(),
+6 -4
View File
@@ -49,7 +49,8 @@ export type DeclaredBirthPlace = Readonly<{
label: string;
lat: number;
lon: number;
tz: number;
tz: number | null;
timezoneId?: string;
}>;
export function parseBirthDate(value: string): Date | undefined {
@@ -193,9 +194,10 @@ export function isDeclaredBirthProfileComplete(
&& Number.isFinite(place.lon)
&& place.lon >= -180
&& place.lon <= 180
&& Number.isFinite(place.tz)
&& place.tz >= -12
&& place.tz <= 14);
&& ((Number.isFinite(place.tz)
&& (place.tz as number) >= -12
&& (place.tz as number) <= 14)
|| Boolean(place.timezoneId?.trim())));
}
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
@@ -34,7 +34,7 @@ const profileSchema = z.object({
uncertainty_after_minutes: z.number().int().nullable().optional(),
latitude: z.number(),
longitude: z.number(),
timezone_offset: z.number(),
timezone_offset: z.number().nullable(),
});
const optionSchema = z.object({
@@ -132,6 +132,11 @@ const candidateResultApiSchema = z.object({
missing_layers: z.array(z.string()),
auxiliary_layers: z.array(z.string()).default([]),
hard_blockers: z.array(z.string()),
external_engines: z.object({
status: z.string(),
providers: z.array(z.string()),
validation: z.record(z.string(), z.unknown()).optional(),
}).strict().optional(),
canonical_input_hash: z.string().optional(),
confirmation_allowed: z.boolean().optional(),
decision: z.enum(["continue_rectification", "confirm_minute"]).optional(),
@@ -152,6 +157,13 @@ class UnexpectedProfileSourceError extends Error {
export function parseBirthTimeProfile(value: unknown): BirthTimeAssessment {
const profile = profileSchema.parse(value);
if (profile.timezone_offset === null) {
throw new z.ZodError([{
code: "custom",
path: ["timezone_offset"],
message: "Historical timezone offset must be resolved before parsing",
}]);
}
const location = {
lat: profile.latitude,
lon: profile.longitude,
@@ -279,6 +291,11 @@ function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>):
missingLayers: parsed.technique_contract.missing_layers,
auxiliaryLayers: parsed.technique_contract.auxiliary_layers,
hardBlockers: parsed.technique_contract.hard_blockers,
externalEngines: parsed.technique_contract.external_engines ? {
status: parsed.technique_contract.external_engines.status,
providers: parsed.technique_contract.external_engines.providers,
validation: parsed.technique_contract.external_engines.validation,
} : undefined,
canonicalInputHash: parsed.technique_contract.canonical_input_hash,
confirmationAllowed: parsed.technique_contract.confirmation_allowed,
decision: parsed.technique_contract.decision,
@@ -22,6 +22,7 @@ import {
parseDynamicPrivateRow,
} from "./birth-time-journey-dynamic-state.ts";
import { BirthTimeJourneyStoreError } from "./birth-time-journey-store-errors.ts";
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
type JourneyLoadResult = { readonly data: unknown; readonly error: unknown };
type JourneyLoadQuery = {
@@ -140,11 +141,17 @@ export async function loadStoredRectificationCase(
const parsed = storedCaseSchema.parse(data);
const { data: profile, error: profileError } = await client
.from("profiles")
.select("latitude,longitude,timezone_offset")
.select("birth_date,reported_birth_time,birth_time_period,latitude,longitude,timezone_id,timezone_offset")
.eq("id", userId)
.maybeSingle();
if (profileError || !profile) throw new BirthTimeJourneyStoreError("load_case");
const location = eventLocationSchema.parse(profile);
let resolvedProfile: unknown;
try {
resolvedProfile = await resolveMissingBirthTimezoneOffset(profile);
} catch {
throw new BirthTimeJourneyStoreError("load_case");
}
const location = eventLocationSchema.parse(resolvedProfile);
const scoring = Object.keys(parsed.scoring_result).length > 0
? parseRectificationScoring(parsed.scoring_result)
: undefined;
@@ -196,7 +196,7 @@ export function parseDynamicChoiceScoring(value: unknown): DynamicChoiceScoringR
topScore: parsed.top_score,
secondScore: parsed.second_score,
marginPercent: parsed.margin_percent,
reasons: [...new Set([...parsed.reasons, "minute_holdout_not_ready"])],
reasons: [...new Set([...parsed.reasons, "vedastro_validation_required"])],
evidence: [],
algorithmVersion: parsed.algorithm_version,
});
@@ -87,6 +87,7 @@ export function eventScorePayload(input: JourneyEventScoreInput) {
lat: input.lat,
lon: input.lon,
tz: input.tz,
high_rigor: true,
events: (input.events ?? []).map((event) => ({
id: event.id,
domain: event.domain,
@@ -16,6 +16,7 @@ export const conversationalRectificationTelemetryActionKinds = [
"start",
"resume",
"answer",
"regenerate",
"pause",
"abandon",
"confirm",
+60 -33
View File
@@ -1,5 +1,6 @@
import { chinaLocations } from "../data/china-locations.ts";
import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts";
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts";
export type ConsultationProfileTruthErrorCode =
@@ -42,11 +43,16 @@ export type ServerChartConsultation = Readonly<{
birthTimeStatus: string;
placeLabel: string;
placeCodes: Readonly<{
countryCode: string;
provinceCode: string;
cityCode: string;
countryCode: string | null;
provinceCode: string | null;
cityCode: string | null;
districtCode: string | null;
}>;
placeId: string | null;
placeType: string | null;
placeProvider: string | null;
timezoneId: string | null;
timezoneSource: string | null;
latitude: number;
longitude: number;
timezoneOffset: number;
@@ -57,6 +63,7 @@ type PrepareConsultationRouteInput<Reservation> = Readonly<{
userId: string;
mode: ConsultationBirthTimeMode;
loadProfile: (userId: string) => Promise<unknown>;
resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise<unknown>;
reserve: () => Promise<Reservation>;
}>;
@@ -110,8 +117,27 @@ function requiredFiniteNumber(profile: RecordValue, key: string, minimum: number
return value;
}
function sameCoordinate(left: number, right: number) {
return Math.abs(left - right) <= 0.000001;
function optionalText(profile: RecordValue, key: string): string | null {
const value = profile[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function legacyChinaPlaceLabel(profile: RecordValue): string | null {
const countryCode = optionalText(profile, "country_code");
const provinceCode = optionalText(profile, "province_code");
const cityCode = optionalText(profile, "city_code");
const districtCode = optionalText(profile, "district_code");
const country = chinaLocations.country;
if (countryCode !== country.code || !provinceCode || !cityCode) return null;
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 (!province || !city || (districtCode && !district)) return null;
return [country.name, province.name, city.name, district?.name]
.filter((label, index, labels) => Boolean(label) && labels.indexOf(label) === index)
.join(" · ");
}
function serverChartFromProfile(
@@ -137,37 +163,22 @@ function serverChartFromProfile(
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 countryCode = optionalText(profile, "country_code");
const provinceCode = optionalText(profile, "province_code");
const cityCode = optionalText(profile, "city_code");
const districtCode = optionalText(profile, "district_code");
const placeId = optionalText(profile, "birth_place_provider_id");
const placeType = optionalText(profile, "birth_place_type");
const placeProvider = optionalText(profile, "birth_place_provider");
const timezoneId = optionalText(profile, "timezone_id");
const timezoneSource = optionalText(profile, "timezone_source");
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(" · ");
const placeLabel = optionalText(profile, "birth_place_label")
?? legacyChinaPlaceLabel(profile)
?? placeId;
if (!placeLabel) throw new ConsultationProfileTruthError("profile_incomplete");
let selectedTime: string;
let selectedTimeKind: "reported" | "active";
@@ -215,6 +226,11 @@ function serverChartFromProfile(
cityCode,
districtCode,
}),
placeId,
placeType,
placeProvider,
timezoneId,
timezoneSource,
latitude,
longitude,
timezoneOffset,
@@ -238,6 +254,17 @@ export async function prepareConsultationRoute<Reservation>(
if (error instanceof ConsultationProfileTruthError) throw error;
throw new ConsultationProfileTruthError("profile_unavailable");
}
const profileValue = record(profile);
const selectedTime = input.mode === "verified_chart"
? nullableClock(profileValue ?? {}, "active_birth_time")
: nullableClock(profileValue ?? {}, "reported_birth_time");
try {
profile = await (input.resolveTimezoneOffset ?? ((value, time) => (
resolveMissingBirthTimezoneOffset(value, { preferredTime: time })
)))(profile, selectedTime ?? undefined);
} catch {
throw new ConsultationProfileTruthError("profile_unavailable");
}
serverChart = serverChartFromProfile(profile, input.mode);
}
const reservation = await input.reserve();
@@ -1,30 +1,38 @@
import { z } from "zod";
import { postJson } from "../birth-time-client-transport.ts";
import {
conversationalRectificationCommandSchema,
conversationalRectificationResponseSchema,
conversationalRectificationTurnSchema,
type ConversationalRectificationCommand,
type ConversationalRectificationResponse,
type ConversationalRectificationTurn,
} from "./contracts.ts";
export const CONVERSATIONAL_RECTIFICATION_UNAVAILABLE = "生时校正暂时无法继续,请稍后重试。";
export type ConversationalRectificationHistoryMessage = Readonly<{
role: "assistant" | "user";
text: string;
}>;
const conversationHistoryMessageSchema = z.object({
role: z.enum(["assistant", "user"]),
text: z.string().trim().min(1).max(12_000),
}).strict();
const conversationHistorySchema = z.array(conversationHistoryMessageSchema).max(500);
const conversationHistoryByTurn = new WeakMap<object, readonly ConversationalRectificationHistoryMessage[]>();
export function conversationalRectificationHistoryForTurn(
turn: ConversationalRectificationTurn,
): readonly ConversationalRectificationHistoryMessage[] {
return conversationHistoryByTurn.get(turn) ?? [];
}
const publicErrorSchema = z.object({
code: z.string(),
message: z.string(),
}).passthrough();
const streamEventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("delta"), text: z.string() }).strict(),
z.object({
type: z.literal("turn"),
turn: conversationalRectificationResponseSchema,
}).strict(),
]);
export type ConversationalRectificationStreamOptions = Readonly<{
onNarrativeDelta?: (text: string) => void;
}>;
export class ConversationalRectificationRequestError extends Error {
readonly name = "ConversationalRectificationRequestError";
readonly status: number;
@@ -106,75 +114,21 @@ function isRetryableTransportError(error: unknown): boolean {
);
}
async function readJsonPayload(response: Response): Promise<unknown> {
return response.json().catch(() => null);
}
async function readStreamedTurn(
response: Response,
options: ConversationalRectificationStreamOptions,
): Promise<ConversationalRectificationResponse> {
if (!response.body) throw new SyntaxError("missing rectification response stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
let turn: ConversationalRectificationResponse | null = null;
const consumeLine = (line: string) => {
if (!line.trim()) return;
const event = streamEventSchema.parse(JSON.parse(line));
if (event.type === "delta") options.onNarrativeDelta?.(event.text);
else turn = event.turn;
};
while (true) {
const { done, value } = await reader.read();
buffered += decoder.decode(value, { stream: !done });
let newline = buffered.indexOf("\n");
while (newline >= 0) {
consumeLine(buffered.slice(0, newline));
buffered = buffered.slice(newline + 1);
newline = buffered.indexOf("\n");
}
if (done) break;
}
consumeLine(buffered);
if (!turn) throw new SyntaxError("missing rectification turn event");
return turn;
}
async function postCommandWithOneReplay(
body: string,
options: ConversationalRectificationStreamOptions,
) {
async function postCommandWithOneReplay(body: string) {
for (let attempt = 0; attempt < 2; attempt += 1) {
let emittedNarrative = false;
try {
const response = await fetch("/api/birth-time-conversation", {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/x-ndjson, application/json",
"Content-Type": "application/json",
},
const result = await postJson({
url: "/api/birth-time-conversation",
body,
retryLostResponse: false,
});
if (!response.ok) {
const payload = await readJsonPayload(response);
const nonJsonFailure = payload === null;
if (attempt === 0 && (response.status === 502 || nonJsonFailure)) continue;
return { response, payload, turn: null };
}
if (response.headers.get("content-type")?.includes("application/x-ndjson")) {
const turn = await readStreamedTurn(response, {
onNarrativeDelta(text) {
emittedNarrative = true;
options.onNarrativeDelta?.(text);
},
});
return { response, payload: null, turn };
}
return { response, payload: await readJsonPayload(response), turn: null };
// postJson deliberately projects an unparseable non-ok body to null. Treating all null
// error payloads as replayable also covers proxies that mislabel HTML as application/json.
const nonJsonFailure = !result.response.ok && result.payload === null;
if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue;
return result;
} catch (error) {
if (attempt === 0 && !emittedNarrative && isRetryableTransportError(error)) continue;
if (attempt === 0 && isRetryableTransportError(error)) continue;
throw error;
}
}
@@ -183,12 +137,11 @@ async function postCommandWithOneReplay(
export async function sendConversationalRectificationCommand(
command: ConversationalRectificationCommand,
options: ConversationalRectificationStreamOptions = {},
): Promise<ConversationalRectificationResponse> {
): Promise<ConversationalRectificationTurn> {
const request = conversationalRectificationCommandSchema.parse(command);
const body = JSON.stringify(request);
try {
const { response, payload, turn } = await postCommandWithOneReplay(body, options);
const { response, payload } = await postCommandWithOneReplay(body);
if (!response.ok) {
const parsed = publicErrorSchema.safeParse(payload);
const safeServerMessage = response.status < 500 && parsed.success
@@ -200,7 +153,20 @@ export async function sendConversationalRectificationCommand(
safeServerMessage,
);
}
return turn ?? conversationalRectificationResponseSchema.parse(payload);
const payloadRecord = payload !== null && typeof payload === "object" && !Array.isArray(payload)
? payload as Readonly<Record<string, unknown>>
: null;
const history = conversationHistorySchema.safeParse(payloadRecord?.conversationMessages);
const turnPayload = payloadRecord && "conversationMessages" in payloadRecord
? Object.fromEntries(
Object.entries(payloadRecord).filter(([key]) => key !== "conversationMessages"),
)
: payload;
const turn = conversationalRectificationTurnSchema.parse(turnPayload);
if (history.success && history.data.length > 0) {
conversationHistoryByTurn.set(turn, history.data);
}
return turn;
} catch (error) {
if (error instanceof ConversationalRectificationRequestError) throw error;
throw new ConversationalRectificationRequestError(
@@ -3,6 +3,7 @@ import { boundedJson } from "./json-bounds.ts";
const actionIdSchema = z.string().uuid();
const caseIdSchema = z.string().uuid();
const modelIdSchema = z.string().trim().min(1).max(64);
const turnVersionSchema = z.number().int().nonnegative();
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const evidenceDomainSchema = z.enum([
@@ -35,6 +36,7 @@ export const conversationalRectificationCommandSchema = z.discriminatedUnion("ty
z.object({
type: z.literal("start"),
actionId: actionIdSchema,
modelId: modelIdSchema.optional(),
pendingConsultationQuestion: z.string().trim().min(1).max(500).nullable().optional(),
}).strict(),
actionCommandSchema.extend({
@@ -42,10 +44,15 @@ export const conversationalRectificationCommandSchema = z.discriminatedUnion("ty
}).strict(),
actionCommandSchema.extend({
type: z.literal("answer"),
modelId: modelIdSchema.optional(),
domain: evidenceDomainSchema.optional(),
answer: z.string().trim().min(1).max(4_000),
correctsEvidenceId: z.string().uuid().optional(),
}).strict(),
actionCommandSchema.extend({
type: z.literal("regenerate"),
modelId: modelIdSchema.optional(),
}).strict(),
actionCommandSchema.extend({
type: z.literal("pause"),
}).strict(),
@@ -78,6 +85,11 @@ const evidenceRequestSchema = boundedJson(z.object({
domains: z.array(evidenceDomainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
freeTextAllowed: z.literal(true),
// Optional for turns written before follow-up state was persisted.
followUp: z.object({
kind: z.enum(["new_event", "event_date", "event_detail"]),
evidenceId: z.string().uuid().nullable(),
}).strict().optional(),
}).strict(), 2_048);
const evidenceRecapEntrySchema = boundedJson(z.object({
@@ -1,13 +1,6 @@
import type { RectificationTechnicalPacket } from "./technical-packet.ts";
export const MINIMUM_SCOREABLE_EVENTS = 3;
export const MAXIMUM_SCOREABLE_EVENTS = 8;
export const MAXIMUM_PLATEAU_ROUNDS = 2;
export type RangeCompletionReason =
| "evidence_limit"
| "no_discriminating_question"
| "range_plateau";
const plateauNotePrefix = "range_plateau_count:";
@@ -43,30 +36,3 @@ export function convergenceNotes(candidate: CandidateProgress, plateauCount: num
`${plateauNotePrefix}${plateauCount}`,
];
}
export function rangeCompletionReason(input: Readonly<{
packet: RectificationTechnicalPacket;
scoreableEventCount: number;
plateauCount: number;
unansweredSuggestedDomainCount: number;
}>): RangeCompletionReason | null {
if (input.packet.candidate.status === "ready_for_confirmation") return null;
if (input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS) return null;
if (input.scoreableEventCount >= MAXIMUM_SCOREABLE_EVENTS
&& input.unansweredSuggestedDomainCount === 0) return "evidence_limit";
if (input.packet.suggestedDomains.length === 0) return "no_discriminating_question";
if (input.plateauCount >= MAXIMUM_PLATEAU_ROUNDS
&& input.unansweredSuggestedDomainCount === 0) return "range_plateau";
return null;
}
export function rangeCompletionCopy(reason: RangeCompletionReason): string {
switch (reason) {
case "evidence_limit":
return "已核对足够数量的真实经历,但现有证据仍不足以可靠确认某一分钟。";
case "no_discriminating_question":
return "当前候选之间已经没有可由真实经历继续区分的问题。";
case "range_plateau":
return "连续两轮补充经历后,候选范围没有继续稳定缩小。";
}
}
@@ -47,6 +47,12 @@ const errorDefinitions = {
message: "请先补全出生日期、时间和地点。",
retryable: false,
},
model_unavailable: {
status: 409,
error: "模型暂不可用",
message: "请选择其他模型后重新发送,本次不会扣除点数。",
retryable: false,
},
insufficient_credits: {
status: 409,
error: "校正点数不足",
@@ -25,18 +25,24 @@ type ParsedDate = {
readonly precision: "day" | "month" | "year";
};
const chineseDatePattern = /(?:1\d{3}|20\d{2})\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g;
const chineseDatePattern = /(?:1\d{3}|20\d{2}|\d{2})\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g;
const isoDatePattern = /(?:1\d{3}|20\d{2})-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?/g;
const unresolvedRelativeTimePattern = /(?:次年|第二年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/;
const leadingRelativeTimePattern = /^\s*(?:(?:次年|第二年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/;
const unresolvedRelativeTimePattern = /(?:来年|次年|第二年|翌年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/;
const leadingRelativeTimePattern = /^\s*(?:(?:来年|次年|第二年|翌年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/;
const missingEventSummary = "事件内容待补充";
function normalizedDate(value: string): ParsedDate | null {
const chinese = value.match(/^((?:1\d{3}|20\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/);
function normalizedDate(value: string, asOfDate: string): ParsedDate | null {
const chinese = value.match(/^((?:1\d{3}|20\d{2}|\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/);
const iso = value.match(/^((?:1\d{3}|20\d{2}))-(\d{2})(?:-(\d{2}))?$/);
const match = chinese ?? iso;
if (!match) return null;
const year = Number(match[1]);
const rawYear = match[1] ?? "";
const asOfYear = Number(asOfDate.slice(0, 4));
const currentCentury = Math.floor(asOfYear / 100) * 100;
const expandedYear = currentCentury + Number(rawYear);
const year = rawYear.length === 2
? expandedYear <= asOfYear ? expandedYear : expandedYear - 100
: Number(rawYear);
const rawMonth = match[2];
if (!rawMonth) return { value: String(year), precision: "year" };
const month = Number(rawMonth);
@@ -56,11 +62,11 @@ function normalizedDate(value: string): ParsedDate | null {
};
}
function datesIn(value: string): ParsedDate[] {
function datesIn(value: string, asOfDate: string): ParsedDate[] {
const matches = [...value.matchAll(chineseDatePattern), ...value.matchAll(isoDatePattern)]
.sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
return matches.flatMap((match) => {
const parsed = normalizedDate(match[0]);
const parsed = normalizedDate(match[0], asOfDate);
return parsed ? [parsed] : [];
});
}
@@ -177,10 +183,10 @@ export function extractLifeEventEvidence(
const events: ExtractedLifeEventEvidence[] = [];
for (const fragments of splitSentences(input.rawText.normalize("NFKC"))) {
const sentenceDates = datesIn(fragments.join("并"));
const sentenceDates = datesIn(fragments.join("并"), input.asOfDate);
const sharedDate = sentenceDates.length === 1 ? sentenceDates[0] ?? null : null;
for (const fragment of fragments) {
const ownDates = datesIn(fragment);
const ownDates = datesIn(fragment, input.asOfDate);
const unresolvedRelativeTime = ownDates.length === 0 && unresolvedRelativeTimePattern.test(fragment);
const date = ownDates.length === 1
? ownDates[0] ?? null
@@ -10,6 +10,7 @@ export type RectificationNarrativePhase = "first" | "intermediate" | "final";
export type RectificationNarrativeContext = Readonly<{
latestUserText?: string;
latestEvidence?: ReadonlyArray<{
id?: string;
dateLabel: string;
summary: string;
domain: RectificationEvidenceDomain;
@@ -36,7 +37,14 @@ export type RectificationNarrativeContext = Readonly<{
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const modelIdSchema = z.string().trim().min(1).max(120);
const validatorVersion = "rectification-narrative-grounding-v2";
// Keep the complete route within its 60 second platform budget. The first
// attempt gets enough time for the richer model, while the second is a short,
// independent recovery attempt that production routes to the Flash model.
const narrativeAttemptTimeoutMs = [38_000, 14_000] as const;
const domainSchema = z.enum(["career", "education", "finance", "health_pressure", "relocation", "relationship", "family", "other"]);
export const rectificationEvidenceDomainOutputSchema = z.object({
domain: domainSchema,
}).strict();
const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]|\.\.)\s*(?:19|20)\d{2}\s*年?/i;
const proposedYearAlternativesPattern = /(?:19|20)\d{2}\s*年?\s*(?:还是|或者|或是|或|、|,|)\s*(?:19|20)\d{2}\s*年?/i;
const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年|年份|年代|时间段|区间|时期)|哪个时间段|还是|选择|选项|更符合|更匹配|A\s*[.、:)]|B\s*[.、:)]|which\s+(?:year|period|range)|options?)/i;
@@ -70,10 +78,31 @@ export const rectificationNarrativeOutputSchema = z.object({
domains: z.array(domainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
prompt: z.string().trim().min(1).max(1_000),
followUp: z.object({
kind: z.enum(["new_event", "event_date", "event_detail"]),
evidenceId: z.string().uuid().nullable(),
}).strict().default({ kind: "new_event", evidenceId: null }),
}).strict().nullable(),
}).strict();
export type RectificationNarrativeModelOutput = z.infer<typeof rectificationNarrativeOutputSchema>;
export const rectificationNarrativeAuthoredOutputSchema = z.object({
narrative: z.string().trim().min(1).max(12_000),
evidenceRequest: z.object({
// Domain routing is private scoring metadata. Older providers may still
// return it, but the server always replaces it from the technical packet.
domains: z.array(domainSchema).min(1).max(4).optional(),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
prompt: z.string().trim().min(1).max(1_000),
followUp: z.object({
kind: z.enum(["new_event", "event_date", "event_detail"]),
evidenceId: z.string().uuid().nullable(),
}).strict().default({ kind: "new_event", evidenceId: null }),
}).strict().nullable(),
}).strict();
// Callers may construct a pre-parse model payload without the defaulted
// follow-up field; parsed runtime output always receives the schema default.
export type RectificationNarrativeModelOutput = z.input<typeof rectificationNarrativeOutputSchema>;
export type NarrativeValidation = {
readonly valid: boolean;
@@ -82,7 +111,20 @@ export type NarrativeValidation = {
export interface RectificationNarrativeGenerator {
readonly modelId: string;
generate(prompt: string): Promise<{ readonly text: string }>;
classifyEvidenceDomain?(
input: Readonly<{
text: string;
recentEvidence: readonly Readonly<{
summary: string;
domain: RectificationEvidenceDomain;
}>[];
}>,
options?: Readonly<{ signal?: AbortSignal }>,
): Promise<RectificationEvidenceDomain | null>;
generate(
prompt: string,
options?: Readonly<{ signal?: AbortSignal; attempt?: 1 | 2 }>,
): Promise<{ readonly text: string; readonly modelId?: string }>;
}
export type RectificationNarrativeResult = {
@@ -101,16 +143,70 @@ export type RectificationNarrativeResult = {
};
};
function unique(values: readonly string[]): string[] {
function unique<T extends string>(values: readonly T[]): T[] {
return [...new Set(values)];
}
function parseModelOutput(text: string): RectificationNarrativeModelOutput {
function groundedEvidenceDomains(
requested: readonly RectificationEvidenceDomain[] | undefined,
packet: RectificationTechnicalPacket,
): RectificationEvidenceDomain[] {
const suggested = packet.suggestedDomains.slice(0, 4).map((item) => item.domain);
if (!requested?.length) return suggested;
const allowed = new Set(suggested);
const grounded = unique(requested.filter((domain) => allowed.has(domain)));
return grounded.length > 0 ? grounded : suggested;
}
function completeAuthoredOutput(
output: z.infer<typeof rectificationNarrativeAuthoredOutputSchema>,
packet: RectificationTechnicalPacket,
): RectificationNarrativeModelOutput {
const evidenceRequest = output.evidenceRequest === null ? null : {
datePrecision: output.evidenceRequest.datePrecision,
prompt: output.evidenceRequest.prompt,
followUp: output.evidenceRequest.followUp,
domains: groundedEvidenceDomains(output.evidenceRequest.domains, packet),
};
return {
...output,
evidenceRequest,
candidateStatus: packet.candidate.status,
representativeTime: packet.candidate.representativeTime,
rangeStart: packet.candidate.range.startTime,
rangeEnd: packet.candidate.range.endTime,
useBoundary: packet.useBoundary,
stableLayers: packet.stableLayers.map((item) => item.layer),
sensitiveLayers: packet.sensitiveLayers.map((item) => item.layer),
referenceIds: [],
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
};
}
function parseModelOutput(
text: string,
packet: RectificationTechnicalPacket,
): RectificationNarrativeModelOutput {
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = normalized.indexOf("{");
const end = normalized.lastIndexOf("}");
if (start < 0 || end <= start) throw new TypeError("narrative output is not JSON");
return rectificationNarrativeOutputSchema.parse(JSON.parse(normalized.slice(start, end + 1)));
const parsed: unknown = JSON.parse(normalized.slice(start, end + 1));
const legacy = rectificationNarrativeOutputSchema.safeParse(parsed);
if (legacy.success) {
return {
...legacy.data,
evidenceRequest: legacy.data.evidenceRequest === null ? null : {
...legacy.data.evidenceRequest,
// The next conversational topic is authored by the model, but the
// scoring-domain allowlist remains server-owned. Do not let a useful
// answer fail merely because the model described that topic with a
// different internal domain label.
domains: groundedEvidenceDomains(legacy.data.evidenceRequest.domains, packet),
},
};
}
return completeAuthoredOutput(rectificationNarrativeAuthoredOutputSchema.parse(parsed), packet);
}
function narrativeTimes(value: string): string[] {
@@ -122,11 +218,9 @@ function narrativeLayers(value: string): string[] {
}
function narrativeReferences(value: string): string[] {
const bracketed = [...value.matchAll(/【([^】]+)】/g)]
return [...value.matchAll(/【([^】]+)】/g)]
.map((match) => match[1] ?? "")
.filter(Boolean);
const plainTechnicalIds = value.match(/\b[A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)+\b/g) ?? [];
return unique([...bracketed, ...plainTechnicalIds]);
}
function isGenericBroadYearChoiceQuestionnaire(value: string): boolean {
@@ -151,15 +245,12 @@ function proseFields(output: RectificationNarrativeModelOutput): readonly {
];
}
function pairKey(value: { readonly domain: RectificationEvidenceDomain; readonly layer: string }): string {
return `${value.domain}\0${value.layer}`;
}
export function validateNarrativeAgainstPacket(
output: RectificationNarrativeModelOutput,
packet: RectificationTechnicalPacket,
phase: RectificationNarrativePhase = "first",
): NarrativeValidation {
void phase;
const issues: string[] = [];
const candidate = packet.candidate;
if (output.candidateStatus !== candidate.status) {
@@ -186,24 +277,15 @@ export function validateNarrativeAgainstPacket(
if (!packet.referenceIds.includes(reference)) issues.push(`reference ${reference} is not packet-grounded`);
}
const allowedDomains = new Map(packet.suggestedDomains.map((item) => [item.domain, item.layer]));
const packetReasons = new Map(packet.suggestedDomains.map((item) => [pairKey(item), item.reason]));
for (const [index, reason] of output.domainReasons.entries()) {
const expectedReason = packetReasons.get(pairKey(reason));
if (!expectedReason) {
for (const reason of output.domainReasons) {
if (allowedDomains.get(reason.domain) !== reason.layer) {
issues.push(`domain reason ${reason.domain}/${reason.layer} is not packet-grounded`);
} else if (reason.reason !== expectedReason) {
issues.push(`domainReasons[${index}].reason must use the packet discrimination explanation`);
}
}
if (output.evidenceRequest) {
for (const domain of output.evidenceRequest.domains) {
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
}
if (!requestsPastDatedEvent(output.evidenceRequest.prompt)) {
issues.push("evidence request must ask for a real past event by year and month");
}
} else if (phase !== "final") {
issues.push("non-final turns require an evidence request");
}
const allowedTimes = [candidate.representativeTime, candidate.range.startTime, candidate.range.endTime];
@@ -225,40 +307,81 @@ export function validateNarrativeAgainstPacket(
issues.push(`${field.path} is a forbidden generic broad-year choice questionnaire`);
}
}
if (phase === "first") {
if (!output.narrative.includes(candidate.range.startTime)
|| !output.narrative.includes(candidate.range.endTime)
|| !/(?:待验证|候选|核对)/.test(output.narrative)) {
issues.push("first narrative must state the pending candidate range");
}
if (narrativeLayers(output.narrative).length > 0) {
issues.push("visible evidence narrative must not expose technical layer tokens");
}
if (!requestsPastDatedEvent(output.narrative)) {
issues.push("first narrative must request real past events by year and month");
}
if (!/(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(output.narrative)) {
issues.push("first narrative must state the candidate use boundary");
}
const authoredEventTable = markdownSection(output.narrative, "### 事件验证表");
if (authoredEventTable && /(?:\|\s*(?:得分|score)(?:\s*\/\s*状态)?\s*\||内部(?:分数|权重))/i.test(authoredEventTable)) {
issues.push("event validation table exposes a private score or weight");
}
const uniqueIssues = unique(issues);
return { valid: uniqueIssues.length === 0, issues: uniqueIssues };
}
function grounding(packet: RectificationTechnicalPacket) {
function grounding(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase) {
const projected = projectRectificationTechnicalPacket(packet);
return {
const base = {
calculationVersion: packet.calculationVersion,
candidate: projected.candidate,
useBoundary: packet.useBoundary,
sensitivityScope: projected.technicalReceipt.sensitivityScope,
stableLayers: packet.stableLayers,
sensitiveLayers: packet.sensitiveLayers,
scoredHistoricalEvidence: packet.scoredHistoricalEvidence,
suggestedDomains: packet.suggestedDomains,
referenceIds: packet.referenceIds,
futureWindows: projected.futureWindows,
expertWorkflow: packet.expertWorkflow,
stableLayers: packet.stableLayers.map(({ layer }) => layer),
sensitiveLayers: packet.sensitiveLayers.map(({ layer }) => layer),
};
if (phase === "first") return base;
return {
...base,
sensitivityScope: {
rangeStart: projected.technicalReceipt.sensitivityScope.rangeStart,
rangeEnd: projected.technicalReceipt.sensitivityScope.rangeEnd,
},
layerValues: {
stable: packet.stableLayers.map(({ layer, values }) => ({ layer, values })),
sensitive: packet.sensitiveLayers.map(({ layer, values }) => ({ layer, values })),
},
expertWorkflow: packet.expertWorkflow ? {
boundary: packet.expertWorkflow.boundary,
candidateWindows: packet.expertWorkflow.candidateWindows,
techniqueStates: packet.expertWorkflow.techniqueAuditTable
.filter((row) => phase === "final" || row.status === "used" || row.status === "partial")
.map((row) => ({
technique: row.technique,
status: row.status,
boundary: row.boundary,
})),
confirmationAllowed: phase === "final" ? packet.expertWorkflow.confirmationAllowed : undefined,
hardBlockers: phase === "final" ? packet.expertWorkflow.hardBlockers : undefined,
} : undefined,
};
}
function narrativeConversationContext(context: RectificationNarrativeContext) {
return {
latestUserText: context.latestUserText,
latestEvidence: context.latestEvidence?.map(({ id, dateLabel, summary }) => ({
id,
dateLabel,
summary,
})),
eventLedger: context.eventLedger?.map(({
id,
rawText,
dateLabel,
summary,
extractionStatus,
active,
correctsEvidenceIds,
}) => ({
id,
rawText,
dateLabel,
summary,
extractionStatus,
active,
correctsEvidenceIds,
})),
unresolvedEvidence: context.unresolvedEvidence?.map(({ id, rawText, summary, dateLabel }) => ({
id,
rawText,
summary,
dateLabel,
})),
};
}
@@ -273,67 +396,33 @@ function ensureSentence(value: string, sentence: string): string {
return trimmed ? `${trimmed}\n${sentence}` : sentence;
}
function hasVisibleEvidenceQuestion(value: string): boolean {
const withoutRhetoricalPrompts = value.replace(/(?:好吗|可以吗|行吗)[?]/g, "");
return /[?]/.test(withoutRhetoricalPrompts)
|| /请(?:先|再|补充|告诉|提供|确认|回忆)/.test(value)
|| /(?:先说一件|说说|告诉我)/.test(value);
function endsWithIncompletePrompt(value: string): boolean {
return /(?:[-—–:,,、]|\.\.\.|…|我需要(?:确认|了解|知道)|关键信息)\s*$/.test(value.trim());
}
function requestsPastDatedEvent(value: string): boolean {
const asksForDate = /(?:年|月|日期|时间|什么时候)/.test(value);
const refersToPastEvent = /(?:已经发生|已发生|过去|当时|后来|经历|发生|开始|毕业|入职|离职|结束|分手|事故|手术)/.test(value);
const asksOnlyAboutFuture = /(?:未来|预计|计划|打算)/.test(value) && !refersToPastEvent;
return asksForDate && refersToPastEvent && !asksOnlyAboutFuture;
function hasExplicitQuestion(value: string): boolean {
return /[?]/.test(value);
}
function repairRequiredSafetyLanguage(
output: RectificationNarrativeModelOutput,
packet: RectificationTechnicalPacket,
phase: RectificationNarrativePhase,
): RectificationNarrativeModelOutput {
let narrative = output.narrative;
let evidenceRequest = output.evidenceRequest;
const evidenceRequest = output.evidenceRequest;
const modelEvidencePrompt = output.evidenceRequest?.prompt.trim() ?? "";
if (phase !== "final" && evidenceRequest) {
if (!requestsPastDatedEvent(evidenceRequest.prompt)) {
evidenceRequest = {
...evidenceRequest,
prompt: `请以已经发生的真实事件为准,并尽量说明年份和月份。${evidenceRequest.prompt}`,
};
}
}
if (phase === "first") {
const candidate = packet.candidate;
const statesCandidateRange = narrative.includes(candidate.range.startTime)
&& narrative.includes(candidate.range.endTime)
&& /(?:待验证|候选|核对)/.test(narrative);
const statesUseBoundary = /(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(narrative);
if (!statesCandidateRange || !statesUseBoundary) {
narrative = [
`我们先在 ${candidate.range.startTime}${candidate.range.endTime} 内核对候选;这个范围不能直接当作已经确认的出生时间。`,
narrative.trim(),
].filter(Boolean).join("\n");
}
}
// evidenceRequest.prompt is an internal planning field and is intentionally not
// projected to the public turn. Keep the model-authored acknowledgement, but make
// sure the one concrete follow-up question is also visible in the chat bubble.
if (phase !== "final" && evidenceRequest && !hasVisibleEvidenceQuestion(narrative)) {
// Every collecting turn must end with one visible, model-authored question.
// evidenceRequest.prompt is generated in the same model call, so appending it
// repairs truncated or analysis-only prose without introducing a business
// template or changing the Agent's chosen conversational direction.
if (phase !== "final" && evidenceRequest && (
endsWithIncompletePrompt(narrative)
|| !hasExplicitQuestion(narrative)
)) {
narrative = ensureSentence(narrative, modelEvidencePrompt || evidenceRequest.prompt);
}
if (phase === "first" && !requestsPastDatedEvent(narrative)) {
narrative = ensureSentence(
narrative,
"请从已经发生的真实经历开始,尽量写明哪一年、哪一月。",
);
}
return {
...output,
narrative,
@@ -341,38 +430,119 @@ function repairRequiredSafetyLanguage(
};
}
type NarrativeDiagnosticIssueCode =
| "candidate_status_mismatch"
| "representative_time_mismatch"
| "candidate_range_mismatch"
| "use_boundary_mismatch"
| "ungrounded_domain_reason"
| "ungrounded_evidence_domain"
| "broad_year_questionnaire"
| "ungrounded_layer"
| "ungrounded_reference"
| "timeout"
| "schema_invalid"
| "generation_error"
| "narrative_validation_failed";
function diagnosticIssueCodes(issues: readonly string[]): NarrativeDiagnosticIssueCode[] {
const codes = issues.map((issue): NarrativeDiagnosticIssueCode => {
if (issue.startsWith("candidateStatus ")) return "candidate_status_mismatch";
if (issue.startsWith("representativeTime ")) return "representative_time_mismatch";
if (issue === "candidate range is not packet-grounded") return "candidate_range_mismatch";
if (issue === "useBoundary is not packet-grounded") return "use_boundary_mismatch";
if (issue.startsWith("domain reason ")) return "ungrounded_domain_reason";
if (issue.startsWith("evidence domain ")) return "ungrounded_evidence_domain";
if (issue.includes("broad-year choice questionnaire")) return "broad_year_questionnaire";
if (issue.includes(" layer ") || issue.startsWith("stable layer ") || issue.startsWith("sensitive layer ")) {
return "ungrounded_layer";
}
if (issue.includes(" reference ") || issue.startsWith("reference ")) return "ungrounded_reference";
if (issue === "TimeoutError" || issue === "AbortError") return "timeout";
if (/^(?:root|[\w.]+):/.test(issue)) return "schema_invalid";
if (/Error$/.test(issue) || issue === "NarrativeOutputError") return "generation_error";
return "narrative_validation_failed";
});
return [...new Set(codes)];
}
function logNarrativeGeneration(input: Readonly<{
phase: RectificationNarrativePhase;
retryCount: 0 | 1;
fallbackUsed: boolean;
source: "model" | "model_retry" | "fallback" | "failed";
issues: readonly string[];
startedAt: number;
}>): void {
const payload = {
phase: input.phase,
retryCount: input.retryCount,
fallbackUsed: input.fallbackUsed,
source: input.source,
issueCodes: diagnosticIssueCodes(input.issues),
elapsedMs: Math.max(0, Date.now() - input.startedAt),
};
if (input.fallbackUsed || input.source === "failed") {
console.warn("[rectification-narrative]", JSON.stringify(payload));
return;
}
console.info("[rectification-narrative]", JSON.stringify(payload));
}
function promptFor(
phase: RectificationNarrativePhase,
packet: RectificationTechnicalPacket,
context: RectificationNarrativeContext,
retryIssues: readonly string[] = [],
): string {
if (phase === "first") {
return JSON.stringify({
task: "用自然、有人味的中文开启生时校正;简短回应当前范围,再只问一个最有信息量的问题。不要使用固定模板。",
phase,
conversationContext: narrativeConversationContext(context),
packet: grounding(packet, phase),
output: "只返回 narrative,以及 evidenceRequest。evidenceRequest 可用 domains 作为不可见路由元数据,并包含 datePrecision、prompt、followUpnarrative 不得输出或讨论事件分类、领域标签,也不要重复输出候选状态、时间、分盘或引用字段。",
safety: "技术事实只能来自 packet;不能确认未经验证的分钟;不得展示内部权重、分数、事件分类或内部路由元数据。",
retryIssues: boundedReceiptIssues(retryIssues),
});
}
return JSON.stringify({
task: "write_grounded_rectification_narrative",
phase,
conversationContext: context,
packet: grounding(packet),
conversationContext: narrativeConversationContext(context),
packet: grounding(packet, phase),
outputContract: {
candidateFactsMustMatch: true,
returnOnlyNarrativeAndEvidenceRequest: true,
candidateFactsAreInjectedByServerAndMustNotBeRepeatedAsJsonFields: true,
onlyListedLayersAndReferences: true,
everyAuthoredStringMustBeGrounded: true,
keepTechnicalLayerValuesOutOfVisibleNarrative: phase !== "final",
askExactlyOneHighInformationQuestion: phase !== "final",
acknowledgeLatestEvidenceSpecificallyBeforeAsking: phase === "intermediate",
doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked: phase === "intermediate",
finishCurrentEventBeforeSwitchingDomains: phase === "intermediate",
resolveDateContradictionsBeforeScoring: phase === "intermediate",
mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate",
askForTheSingleMostInformativeMissingDetail: phase === "intermediate",
treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate",
useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate",
usePacketDomainReasonTextExactly: true,
requestRealPastEventsByYearAndMonth: phase !== "final",
everyAuthoredTechnicalClaimMustBeGrounded: true,
futureWindowsAreContextOnly: true,
genericBroadYearRangeQuestionnaireForbidden: true,
useExpertWorkflowAsTechniqueTruth: true,
blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed: true,
finalNarrativeIncludesAConciseTechniqueAuditTable: phase === "final",
technicalTablesMayAppearWhenRelevant: true,
completeTechnicalTableSummaryRequiredBeforeConfirmation: phase === "final",
unchangedTechnicalTablesShouldNotBeRepeated: true,
privateScoresAndCandidateWeightsMustNeverBeShown: true,
internalEventDomainsAndRoutingMustNeverBeShown: true,
},
conversationGuidance: {
preferOneHighInformationQuestion: phase !== "final",
everyNonFinalReplyMustEndWithExactlyOneVisibleQuestion: phase !== "final",
respondToLatestEvidenceBeforeAsking: phase === "intermediate",
neverMentionHowTheEventWasClassifiedOrLabeledInternally: phase === "intermediate",
doNotVolunteerNotEvaluatedOrBlockedTechniqueInventory: phase === "intermediate",
doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked: phase === "intermediate",
continueCurrentEventWhenItRemainsInformative: phase === "intermediate",
resolveDateContradictionsBeforeScoring: phase === "intermediate",
mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate",
treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate",
useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate",
askForDatesOnlyWhenNeededToIdentifyOrScoreTheEvent: phase !== "final",
persistFollowUpState: phase !== "final"
? "Treat followUp as advisory metadata: use event_detail or event_date with an existing evidenceId when clear; otherwise omit assumptions and use new_event with null evidenceId."
: false,
domainReasonsMayBeNaturallyParaphrased: true,
},
retryIssues: boundedReceiptIssues(retryIssues),
});
@@ -407,39 +577,177 @@ function fallbackOutput(
referenceIds: [],
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
evidenceRequest: phase === "final" ? null : {
domains: packet.suggestedDomains.slice(0, 1).map((item) => item.domain),
domains: packet.suggestedDomains.slice(0, 4).map((item) => item.domain),
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并尽量写明哪一年、哪一月以及发生了什么。",
followUp: { kind: "new_event", evidenceId: null },
},
};
}
function markdownCell(value: unknown): string {
return String(value ?? "")
.replace(/\|/g, "\\|")
.replace(/\r?\n/g, " ")
.trim() || "—";
}
function analysisTableSections(
packet: RectificationTechnicalPacket,
context: RectificationNarrativeContext,
): ReadonlyArray<{
readonly heading: string;
readonly header: string;
readonly markdown: string;
}> {
const workflow = packet.expertWorkflow;
const techniqueRows = workflow?.techniqueAuditTable ?? [];
const techniqueTable = [
"### Technique Audit Table",
"| 技法 | 状态 | 证据 | 使用边界 |",
"|---|---|---|---|",
...(techniqueRows.length > 0
? techniqueRows.map((row) => (
`| ${markdownCell(row.technique)} | ${markdownCell(row.status)} | ${markdownCell(row.evidence.join("、"))} | ${markdownCell(row.boundary)} |`
))
: ["| — | 待评估 | 尚无可展示证据 | 不得声称已运行 |"]),
].join("\n");
const scoreByEvidenceId = new Map(packet.scoredHistoricalEvidence.map((item) => [item.evidenceId, item]));
const activeEvents = (context.eventLedger ?? []).filter((item) => item.active);
const eventTable = [
"### 事件验证表",
"| 时间 | 事件 | 领域 | 验证状态 | 结论 |",
"|---|---|---|---|---|",
...(activeEvents.length > 0
? activeEvents.map((event) => {
const score = scoreByEvidenceId.get(event.id);
const status = score ? "已纳入验证" : "待验证";
const conclusion = score ? "已纳入当前候选比较" : "尚未完成候选比较";
return `| ${markdownCell(event.dateLabel)} | ${markdownCell(event.summary)} | ${markdownCell(domainLabels[event.domain])} | ${status} | ${conclusion} |`;
})
: ["| — | 尚无可评分事件 | — | 待验证 | 尚未完成候选比较 |"]),
].join("\n");
const candidateRows = [
...(workflow?.candidateWindows ?? []).map((window) => ({
range: `${window.startTime}${window.endTime}`,
layer: "候选窗口",
status: window.status,
evidence: packet.useBoundary,
})),
...packet.stableLayers.map((layer) => ({
range: `${packet.candidate.range.startTime}${packet.candidate.range.endTime}`,
layer: layer.layer,
status: "stable",
evidence: layer.values.join(" / "),
})),
...packet.sensitiveLayers.map((layer) => ({
range: `${packet.candidate.range.startTime}${packet.candidate.range.endTime}`,
layer: layer.layer,
status: "minute_sensitive",
evidence: layer.values.join(" / "),
})),
];
const candidateTable = [
"### 候选时间差异表",
"| 候选范围 | 层 | 状态 | 差异 / 证据 |",
"|---|---|---|---|",
...(candidateRows.length > 0
? candidateRows.map((row) => `| ${markdownCell(row.range)} | ${markdownCell(row.layer)} | ${markdownCell(row.status)} | ${markdownCell(row.evidence)} |`)
: ["| — | — | 待计算 | 尚无可展示差异 |"]),
].join("\n");
return [
{
heading: "### Technique Audit Table",
header: "| 技法 | 状态 | 证据 | 使用边界 |",
markdown: techniqueTable,
},
{
heading: "### 事件验证表",
header: "| 时间 | 事件 | 领域 | 验证状态 | 结论 |",
markdown: eventTable,
},
{
heading: "### 候选时间差异表",
header: "| 候选范围 | 层 | 状态 | 差异 / 证据 |",
markdown: candidateTable,
},
];
}
function markdownSection(narrative: string, heading: string): string | null {
const start = narrative.indexOf(heading);
if (start < 0) return null;
const nextHeading = narrative.indexOf("\n### ", start + heading.length);
return narrative.slice(start, nextHeading < 0 ? undefined : nextHeading);
}
function hasCompleteAnalysisTable(
narrative: string,
section: Readonly<{ readonly heading: string; readonly header: string }>,
): boolean {
const authoredSection = markdownSection(narrative, section.heading);
if (!authoredSection || !authoredSection.includes(section.header)) return false;
const tableLines = authoredSection
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("|") && line.endsWith("|"));
return tableLines.length >= 3;
}
function appendFinalAnalysisTables(
narrative: string,
packet: RectificationTechnicalPacket,
context: RectificationNarrativeContext,
): string {
const missing = analysisTableSections(packet, context)
.filter((section) => !hasCompleteAnalysisTable(narrative, section))
.map((section) => section.markdown);
return missing.length > 0 ? [narrative.trim(), ...missing].filter(Boolean).join("\n\n") : narrative;
}
export async function generateRectificationNarrative(input: {
readonly phase: RectificationNarrativePhase;
readonly packet: RectificationTechnicalPacket;
readonly generator: RectificationNarrativeGenerator;
readonly context?: RectificationNarrativeContext;
}): Promise<RectificationNarrativeResult> {
const modelId = modelIdSchema.parse(input.generator.modelId);
const startedAt = Date.now();
const defaultModelId = modelIdSchema.parse(input.generator.modelId);
let issues: readonly string[] = [];
for (const attempt of [1, 2] as const) {
try {
const signal = AbortSignal.timeout(narrativeAttemptTimeoutMs[attempt - 1]);
const generated = await input.generator.generate(promptFor(
input.phase,
input.packet,
input.context ?? {},
issues,
));
), { signal, attempt });
const modelId = modelIdSchema.parse(generated.modelId ?? defaultModelId);
const output = repairRequiredSafetyLanguage(
parseModelOutput(generated.text),
input.packet,
parseModelOutput(generated.text, input.packet),
input.phase,
);
const validation = validateNarrativeAgainstPacket(output, input.packet, input.phase);
if (validation.valid) {
const narrative = input.phase === "final"
? appendFinalAnalysisTables(output.narrative, input.packet, input.context ?? {})
: output.narrative;
const finalOutput = narrative === output.narrative ? output : { ...output, narrative };
logNarrativeGeneration({
phase: input.phase,
retryCount: attempt === 1 ? 0 : 1,
fallbackUsed: false,
source: attempt === 1 ? "model" : "model_retry",
issues,
startedAt,
});
return {
narrative: output.narrative,
output,
narrative,
output: finalOutput,
attempts: attempt,
fallbackUsed: false,
allowEvidenceScoringAdvance: true,
@@ -453,30 +761,48 @@ export async function generateRectificationNarrative(input: {
},
};
}
issues = validation.issues;
issues = [...issues, ...validation.issues];
} catch (error) {
issues = error instanceof z.ZodError
const attemptIssues = error instanceof z.ZodError
? error.issues.map((issue) => `${issue.path.join(".") || "root"}:${issue.code}`)
: [error instanceof Error ? error.name : "NarrativeOutputError"];
issues = [...issues, ...attemptIssues];
}
}
if (input.phase !== "final") {
logNarrativeGeneration({
phase: input.phase,
retryCount: 1,
fallbackUsed: false,
source: "failed",
issues: boundedReceiptIssues(issues),
startedAt,
});
throw new Error("RectificationNarrativeUnavailable");
}
const output = fallbackOutput(input.packet, input.phase);
console.warn("[rectification-narrative-fallback]", JSON.stringify({
const narrative = input.phase === "final"
? appendFinalAnalysisTables(output.narrative, input.packet, input.context ?? {})
: output.narrative;
const finalOutput = narrative === output.narrative ? output : { ...output, narrative };
logNarrativeGeneration({
phase: input.phase,
modelId,
attempts: 2,
retryCount: 1,
fallbackUsed: true,
source: "fallback",
issues: boundedReceiptIssues(issues),
}));
startedAt,
});
return {
narrative: output.narrative,
output,
narrative,
output: finalOutput,
attempts: 2,
fallbackUsed: true,
// The fallback is rendered entirely from the validated deterministic packet.
// A prose-model failure must not discard scoreable evidence or block narrowing.
allowEvidenceScoringAdvance: true,
validationReceipt: {
modelId,
modelId: defaultModelId,
schemaValidated: false,
validatorVersion,
retryCount: 1,
@@ -3,7 +3,6 @@ import {
conversationalRectificationCommandSchema,
conversationalRectificationTurnSchema,
type ConversationalRectificationCommand,
type ConversationalRectificationResponse,
type ConversationalRectificationTurn,
} from "./contracts.ts";
import { ConversationalRectificationError } from "./errors.ts";
@@ -24,16 +23,12 @@ import {
} from "./narrative-agent.ts";
import {
projectRectificationTechnicalPacket,
type RectificationEvidenceDomain,
type RectificationTechnicalPacket,
} from "./technical-packet.ts";
import {
convergenceNotes,
MINIMUM_SCOREABLE_EVENTS,
nextPlateauCount,
rangeCompletionCopy,
rangeCompletionReason,
type RangeCompletionReason,
} from "./convergence.ts";
import type { ConversationalRectificationBilling } from "./billing.ts";
import {
@@ -103,6 +98,7 @@ export type ConversationalRectificationService = Readonly<{
start(userId: string, command: CommandOf<"start">): Promise<ConversationalRectificationTurn>;
resume(userId: string, command: CommandOf<"resume">): Promise<ConversationalRectificationTurn>;
answer(userId: string, command: CommandOf<"answer">): Promise<ConversationalRectificationTurn>;
regenerate(userId: string, command: CommandOf<"regenerate">): Promise<ConversationalRectificationTurn>;
pause(userId: string, command: CommandOf<"pause">): Promise<ConversationalRectificationTurn>;
abandon(userId: string, command: CommandOf<"abandon">): Promise<ConversationalRectificationTurn>;
confirm(userId: string, command: CommandOf<"confirm">): Promise<ConversationalRectificationTurn>;
@@ -127,6 +123,9 @@ export function conversationalRectificationTelemetryOutcome(
const transitionValidatorVersion = "conversational-rectification-orchestrator-v1";
const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/;
const genericUncertaintyPattern = /(?:不知道|不确定)/;
const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/;
const contextualBareMonthDayPattern = /^\s*(\d{1,2})\s*月\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/;
const contextualBareDayPattern = /^\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/;
export function evidencePredatesBirthDate(
evidence: Pick<LifeEventEvidence, "dateValue" | "datePrecision">,
@@ -143,6 +142,21 @@ export function evidencePredatesBirthDate(
return boundary !== null && evidence.dateValue < boundary;
}
function evidencePostdatesAsOfDate(
evidence: Pick<LifeEventEvidence, "dateValue" | "datePrecision">,
asOfDate: string,
): boolean {
if (!evidence.dateValue) return false;
const boundary = evidence.datePrecision === "year"
? asOfDate.slice(0, 4)
: evidence.datePrecision === "month"
? asOfDate.slice(0, 7)
: evidence.datePrecision === "day"
? asOfDate
: null;
return boundary !== null && evidence.dateValue > boundary;
}
function evidenceForDeclaredBirthDate(
evidence: readonly LifeEventEvidence[],
birthDate: string,
@@ -171,7 +185,7 @@ function parseCommand<Type extends ConversationalRectificationCommand["type"]>(
}
type MutableCommand = Extract<ConversationalRectificationCommand, {
readonly type: "answer" | "pause" | "abandon" | "confirm";
readonly type: "answer" | "regenerate" | "pause" | "abandon" | "confirm";
}>;
function commandFingerprint(command: MutableCommand): string {
@@ -184,12 +198,34 @@ function commandFingerprint(command: MutableCommand): string {
return createHash("sha256").update(JSON.stringify(identity), "utf8").digest("hex");
}
function sameDeclaredBirthInput(
left: unknown,
right: unknown,
): boolean {
const leftParsed = declaredBirthInputSchema.safeParse(left);
const rightParsed = declaredBirthInputSchema.safeParse(right);
return leftParsed.success
&& rightParsed.success
&& stableJson(leftParsed.data) === stableJson(rightParsed.data);
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value !== null && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function visibleEvidenceSummary(value: string): string {
const cleaned = value.replace(/(?:发生时间|事件详情)\s*[:]\s*/g, "").trim();
return cleaned || value;
}
function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationResponse {
function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn {
const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn);
if (!parsed.success) throw new ConversationalRectificationError("store_unavailable");
const evidenceDomains = new Map(
@@ -197,7 +233,6 @@ function publicTurn(value: StoredConversationalRectificationCase): Conversationa
);
return {
...parsed.data,
...(value.messageHistory ? { messageHistory: [...value.messageHistory] } : {}),
evidenceRecap: parsed.data.evidenceRecap.map((item) => ({
...item,
summary: visibleEvidenceSummary(item.summary),
@@ -239,6 +274,35 @@ export function effectiveLifeEventEvidence<
return evidence.filter((item) => !correctedIds.has(item.id));
}
function normalizedEventSemantics(value: string): string {
return value
.normalize("NFKC")
.toLocaleLowerCase("zh-CN")
.replace(/^(?:|||(?:)?|(?:|||:)*)+/u, "")
.replace(/[\p{P}\p{S}\s]+/gu, "");
}
function uniqueScoreableLifeEventEvidence(
evidence: ReadonlyArray<LifeEventEvidenceInput>,
birthDate: string,
): ReadonlyArray<LifeEventEvidenceInput> {
const seen = new Set<string>();
return effectiveLifeEventEvidence(evidence)
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, birthDate))
.filter((item) => {
const identity = [
item.dateValue ?? "",
item.domain,
normalizedEventSemantics(item.eventSummary),
].join("|");
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
}
function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
return effectiveLifeEventEvidence(evidence).slice(-20).map((item) => ({
id: item.id,
@@ -263,6 +327,7 @@ function narrativeConversationContext(input: Readonly<{
return {
latestUserText: input.latestUserText.trim().slice(0, 4_000),
latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({
id: item.id,
dateLabel: item.dateLabel,
summary: item.summary,
domain: item.domain,
@@ -290,92 +355,6 @@ function narrativeConversationContext(input: Readonly<{
};
}
const progressDomainLabels = {
career: "事业",
education: "学业",
finance: "财务",
health_pressure: "健康与重大压力",
relocation: "搬迁",
relationship: "重要关系",
family: "家庭",
other: "其他关键经历",
} as const satisfies Readonly<Record<RectificationEvidenceDomain, string>>;
function evidenceProgressNarrative(input: Readonly<{
previousCandidate: PrivateCandidateInput;
packet: RectificationTechnicalPacket;
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
scoreableEventCount: number;
willContinue: boolean;
authoredNarrative?: string | null;
}>): string {
const recorded = evidenceRecap(input.newEvidence);
const acknowledgement = recorded.length === 0
? "这段经历已经保存。"
: `已记录:${recorded.map((item) => `${item.dateLabel} · ${item.summary}`).join("")}`;
const previousStart = input.previousCandidate.rangeStart;
const previousEnd = input.previousCandidate.rangeEnd;
const nextStart = input.packet.candidate.range.startTime;
const nextEnd = input.packet.candidate.range.endTime;
const rangeChanged = previousStart !== nextStart || previousEnd !== nextEnd;
const progress = input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS
? `当前累计 ${input.scoreableEventCount} 条可评分经历;系统至少需要 ${MINIMUM_SCOREABLE_EVENTS} 条时间明确的经历才开始事件排序,所以本轮候选范围暂时保持 ${nextStart}${nextEnd}`
: rangeChanged
? `候选范围已从 ${previousStart ?? "原范围"}${previousEnd ?? "原范围"} 更新为 ${nextStart}${nextEnd}`
: `本轮已纳入 ${input.scoreableEventCount} 条可评分经历,但候选范围暂未稳定缩小;这不是提交失败。`;
const suggested = nextEvidenceDomains(input.packet, input.allEvidence)
.map((item) => progressDomainLabels[item.domain]);
const nextStep = input.willContinue && suggested.length > 0
? `下一步:请优先补充一件${suggested.join("或")}领域已经发生的事件,并选择大致年月。`
: "";
const differenceBasis = input.packet.suggestedDomains.length > 0
? `本轮区分重点:${input.packet.suggestedDomains.slice(0, 2)
.map((item) => `${progressDomainLabels[item.domain]}事件用于比较 ${item.layer}`)
.join("")}`
: "";
const authored = input.authoredNarrative?.trim();
if (authored) return authored.slice(0, 12_000);
return [acknowledgement, authored, progress, nextStep, differenceBasis]
.filter(Boolean)
.join("\n")
.slice(0, 12_000);
}
function unansweredEvidenceDomains(
packet: RectificationTechnicalPacket,
evidence: ReadonlyArray<LifeEventEvidenceInput>,
) {
const answeredDomains = new Set(effectiveLifeEventEvidence(evidence)
.filter((item) => item.extractionStatus !== "needs_clarification")
.map((item) => item.domain));
return packet.suggestedDomains.filter((item) => !answeredDomains.has(item.domain));
}
function nextEvidenceDomains(
packet: RectificationTechnicalPacket,
evidence: ReadonlyArray<LifeEventEvidenceInput>,
) {
const unanswered = unansweredEvidenceDomains(packet, evidence);
return (unanswered.length > 0 ? unanswered : packet.suggestedDomains).slice(0, 2);
}
function evidenceRequestForProgress(input: Readonly<{
packet: RectificationTechnicalPacket;
evidence: ReadonlyArray<LifeEventEvidenceInput>;
willContinue: boolean;
}>): RectificationNarrativeResult["output"]["evidenceRequest"] {
if (!input.willContinue) return null;
const suggested = nextEvidenceDomains(input.packet, input.evidence);
if (suggested.length === 0) return null;
const labels = suggested.map((item) => progressDomainLabels[item.domain]);
return {
domains: suggested.map((item) => item.domain),
datePrecision: "month_preferred",
prompt: `请说一件${labels.join("或")}方面已经发生的事,尽量写明哪一年、哪一月以及发生了什么。`,
};
}
function exactTechnicalReceipt(packet: RectificationTechnicalPacket) {
const projected = projectRectificationTechnicalPacket(packet);
return {
@@ -421,6 +400,7 @@ function turnFromNarrative(input: {
domains: input.narrative.output.evidenceRequest.domains,
datePrecision: input.narrative.output.evidenceRequest.datePrecision,
freeTextAllowed: true as const,
followUp: input.narrative.output.evidenceRequest.followUp,
}
: null;
const candidate = {
@@ -482,23 +462,6 @@ function privateCandidateFromPacket(input: {
return parsed.data;
}
function completedRangeTurn(
turn: ConversationalRectificationTurn,
reason: RangeCompletionReason,
): ConversationalRectificationTurn {
const suffix = `${rangeCompletionCopy(reason)} 当前证据没有收敛到可确认分钟,因此本次不计费,已退回暂扣点数。候选范围仅作记录,代表时间不会替换当前排盘时间。`;
const parsed = conversationalRectificationTurnSchema.safeParse({
...turn,
status: "completed",
narrative: boundedNarrative(turn.narrative, suffix),
candidate: { ...turn.candidate, status: "pending_validation" },
evidenceRequest: null,
actions: turn.pendingConsultationQuestion ? ["continue_original_question"] : [],
});
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
return parsed.data;
}
function changedTurn(input: {
readonly current: LoadedConversationalRectificationCase;
readonly status: "paused" | "abandoned" | "completed";
@@ -527,11 +490,6 @@ function changedTurn(input: {
return { turn: parsed.data, receipt: input.receipt ?? transitionReceipt() };
}
function boundedNarrative(previous: string, suffix: string): string {
const room = Math.max(1, 12_000 - suffix.length - 2);
return `${previous.slice(0, room)}\n\n${suffix}`.slice(0, 12_000);
}
function midpointOfRange(range: Readonly<{ startTime: string; endTime: string }>): string {
const minute = (value: string) => {
const [hour = 0, part = 0] = value.split(":").map(Number);
@@ -547,76 +505,60 @@ function midpointOfRange(range: Readonly<{ startTime: string; endTime: string }>
return clock(Math.round((start + end) / 2));
}
function domainsForClarification(
current: ConversationalRectificationTurn,
hint: RectificationEvidenceDomain | undefined,
): readonly RectificationEvidenceDomain[] {
const values = [
hint,
...(current.evidenceRequest?.domains ?? []),
"career" as const,
"relationship" as const,
].filter((value): value is RectificationEvidenceDomain => Boolean(value));
return [...new Set(values)].slice(0, 4).length >= 2
? [...new Set(values)].slice(0, 4)
: ["career", "relationship"];
}
type CorrectionResetReason =
| "needs_clarification"
| "non_scoreable"
| "direction_change"
| "validation_fallback";
| "direction_change";
function nonScoringTurn(input: {
readonly current: LoadedConversationalRectificationCase;
readonly newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
readonly domain?: RectificationEvidenceDomain;
readonly directionChange: boolean;
readonly latestUserText: string;
readonly authoredNarrative?: RectificationNarrativeResult | null;
readonly correctionReset?: Readonly<{
packet: RectificationTechnicalPacket;
reason: CorrectionResetReason;
}>;
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification"
&& item.scoreable === false && item.dateValue !== null);
const latestIncomplete = input.newEvidence
.filter((item) => item.extractionStatus === "needs_clarification")
.at(-1);
const clarificationNarrative = latestIncomplete?.dateValue === null
&& latestIncomplete.eventSummary !== "事件内容待补充"
? `你提到“${visibleEvidenceSummary(latestIncomplete.eventSummary)}”,具体内容我已经记下了。它大致是什么年月?只记得年份也可以。`
: latestIncomplete?.dateValue
&& latestIncomplete.eventSummary === "事件内容待补充"
? `我已经记下 ${latestIncomplete.dateValue} 这个时间。那时具体发生了什么重要事情?`
: null;
const correctionNarrative = input.correctionReset?.reason === "validation_fallback"
? "这条更正已保存,原记录已经停止参与候选评分,候选范围也已重新计算。为避免只凭一次修订直接确认出生分钟,本轮先保持待验证;请继续补充另一件已经发生的真实经历。"
: input.correctionReset?.reason === "direction_change"
? "这条更正已保存,原记录已经停止参与候选评分。我们会从声明范围重新开始核对,你可以换一个真实事件方向并尽量写明年月;本轮不会沿用旧候选推进确认。"
: input.correctionReset?.reason === "non_scoreable"
? "这条更正已保存,原记录已经停止参与候选评分。更正后的内容目前不能作为已经发生的评分证据,候选已从声明范围重新计算;请再补充一件已发生并带有年月的事件。"
: "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,候选已从声明范围重新计算;请补充大约年份、月份和发生了什么。";
const narrative = input.correctionReset
? correctionNarrative
: input.directionChange
? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。"
: hasFuture
? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。"
: clarificationNarrative
?? "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
const authoredNarrative = input.authoredNarrative;
const latestSummary = input.newEvidence.at(-1)?.eventSummary;
const fallbackSubject = latestSummary && latestSummary !== "事件内容待补充"
? latestSummary
: input.latestUserText.trim().slice(0, 80);
const narrative = authoredNarrative?.narrative
?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,但这次分析暂时没有完成。内容会保留,你可以继续补充它的时间和经过,或直接说下一件已经发生的经历。`;
const status = input.correctionReset
? "active" as const
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
const actions = actionsFor(status);
const evidenceRequest = status === "confirming" && input.current.latestTurn.evidenceRequest === null
const clarificationFollowUp = latestIncomplete?.dateValue === null
&& latestIncomplete.eventSummary !== "事件内容待补充"
? { kind: "event_date" as const, evidenceId: latestIncomplete.id }
: latestIncomplete?.dateValue
&& latestIncomplete.eventSummary === "事件内容待补充"
? { kind: "event_detail" as const, evidenceId: latestIncomplete.id }
: null;
const authoredRequest = authoredNarrative?.output.evidenceRequest;
const priorRequest = input.current.latestTurn.evidenceRequest;
const evidenceRequest = status === "confirming" && priorRequest === null
? null
: {
domains: domainsForClarification(input.current.latestTurn, input.domain),
datePrecision: "month_preferred" as const,
freeTextAllowed: true as const,
};
: authoredRequest
? {
domains: authoredRequest.domains,
datePrecision: authoredRequest.datePrecision,
freeTextAllowed: true as const,
followUp: clarificationFollowUp ?? authoredRequest.followUp,
}
: priorRequest
? {
...priorRequest,
followUp: clarificationFollowUp ?? priorRequest.followUp,
}
: null;
const parsed = conversationalRectificationTurnSchema.safeParse({
...input.current.latestTurn,
status,
@@ -636,7 +578,16 @@ function nonScoringTurn(input: {
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
return {
turn: parsed.data,
receipt: transitionReceipt("deterministic-evidence-clarification"),
receipt: authoredNarrative
? validationReceiptSchema.parse(authoredNarrative.validationReceipt)
: validationReceiptSchema.parse({
modelId: "deterministic-evidence-failure-continuation",
schemaValidated: false,
validatorVersion: transitionValidatorVersion,
retryCount: 0,
fallbackUsed: true,
issues: ["technical_or_narrative_generation_failed"],
}),
};
}
@@ -827,16 +778,61 @@ export function createConversationalRectificationService(
}
}
function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] {
function contextualizedAnswer(
command: CommandOf<"answer">,
current: LoadedConversationalRectificationCase,
): string {
const followUp = current.latestTurn.evidenceRequest?.followUp;
if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") {
return command.answer;
}
const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence);
const target = followUp.evidenceId
? activeEvidence.find((item) => item.id === followUp.evidenceId)
: null;
const anchor = target?.dateValue
? target
: activeEvidence.filter((item) => item.dateValue !== null).at(-1);
const anchorYear = Number(anchor?.dateValue?.slice(0, 4));
if (!Number.isInteger(anchorYear)) return command.answer;
const bareMonthDay = followUp.kind === "event_date"
? command.answer.match(contextualBareMonthDayPattern)
: null;
if (bareMonthDay) {
return `${anchorYear}${Number(bareMonthDay[1])}${Number(bareMonthDay[2])}`;
}
const bareDay = followUp.kind === "event_date"
? command.answer.match(contextualBareDayPattern)
: null;
const anchorMonth = Number(anchor?.dateValue?.slice(5, 7));
if (bareDay && Number.isInteger(anchorMonth)) {
return `${anchorYear}${anchorMonth}${Number(bareDay[1])}`;
}
const match = command.answer.match(contextualRelativeMonthPattern);
if (!match) return command.answer;
const month = Number(match[1]);
if (month < 1 || month > 12) return command.answer;
const sameYear = /(?:同年|当年|那年)/.test(match[0]);
return command.answer.replace(match[0], `${sameYear ? anchorYear : anchorYear + 1}${month}`);
}
async function extractedEvidence(
command: CommandOf<"answer">,
current: LoadedConversationalRectificationCase,
): Promise<readonly LifeEventEvidence[]> {
let extracted: readonly LifeEventEvidence[];
try {
const answerForExtraction = contextualizedAnswer(command, current);
extracted = extractLifeEventEvidence({
rawText: command.answer,
rawText: answerForExtraction,
sourceTurnId: command.actionId,
asOfDate: ports.asOfDate(),
correctsEvidenceId: command.correctsEvidenceId,
}).map((item) => ({
...item,
rawText: command.answer,
correctsEvidenceIds: [...item.correctsEvidenceIds],
domain: item.domain === "other" && command.domain && command.domain !== "other"
? command.domain
@@ -849,6 +845,26 @@ export function createConversationalRectificationService(
if (command.correctsEvidenceId && extracted.length !== 1) {
throw new ConversationalRectificationError("invalid_command");
}
const ambiguous = extracted.length === 1 && extracted[0]?.domain === "other"
? extracted[0]
: null;
if (ambiguous && ports.narrativeGenerator.classifyEvidenceDomain) {
try {
const domain = await ports.narrativeGenerator.classifyEvidenceDomain({
text: command.answer,
recentEvidence: effectiveLifeEventEvidence(current.eventEvidence).slice(-6).map((item) => ({
summary: item.eventSummary,
domain: item.domain,
})),
}, { signal: AbortSignal.timeout(8_000) });
if (domain && domain !== "other") {
return [{ ...ambiguous, domain }];
}
} catch {
// Semantic classification is advisory. Keep the deterministic fallback
// instead of blocking the user's event when the model is unavailable.
}
}
return extracted;
}
@@ -858,29 +874,59 @@ export function createConversationalRectificationService(
extracted: readonly LifeEventEvidence[];
}>): readonly LifeEventEvidence[] {
if (input.command.correctsEvidenceId || input.extracted.length !== 1) return input.extracted;
const pending = effectiveLifeEventEvidence(input.current.eventEvidence)
.filter((item) => item.extractionStatus === "needs_clarification")
.at(-1);
const activeEvidence = effectiveLifeEventEvidence(input.current.eventEvidence);
const declaredFollowUp = input.current.latestTurn.evidenceRequest?.followUp;
const supplied = input.extracted[0];
if (declaredFollowUp?.kind === "new_event") return input.extracted;
const followUp = declaredFollowUp;
const pending = followUp?.evidenceId
? activeEvidence.find((item) => item.id === followUp.evidenceId)
: activeEvidence.filter((item) => item.extractionStatus === "needs_clarification").at(-1);
if (!pending || !supplied) return input.extracted;
const pendingHasSummary = pending.eventSummary !== "事件内容待补充";
const suppliedHasSummary = supplied.eventSummary !== "事件内容待补充";
const fillsMissingDate = pending.dateValue === null
const suppliedMatchesPending = !suppliedHasSummary
|| supplied.domain === "other"
|| supplied.domain === pending.domain;
const fillsMissingDate = followUp?.kind !== "event_detail"
&& pending.dateValue === null
&& supplied.dateValue !== null
&& !evidencePostdatesAsOfDate(supplied, ports.asOfDate())
&& pendingHasSummary
&& !suppliedHasSummary;
const fillsMissingSummary = pending.dateValue !== null
&& suppliedMatchesPending;
const refinesKnownDate = followUp?.kind === "event_date"
&& pending.dateValue !== null
&& supplied.dateValue !== null
&& supplied.dateValue.startsWith(`${pending.dateValue}-`)
&& !evidencePostdatesAsOfDate(supplied, ports.asOfDate())
&& pendingHasSummary
&& suppliedMatchesPending;
const fillsMissingSummary = followUp?.kind !== "event_date"
&& pending.dateValue !== null
&& supplied.dateValue === null
&& !pendingHasSummary
&& suppliedHasSummary;
if (!fillsMissingDate && !fillsMissingSummary) return input.extracted;
const addsEventDetail = followUp?.kind === "event_detail"
&& pending.dateValue !== null
&& suppliedHasSummary
&& (supplied.dateValue === null || supplied.dateValue === pending.dateValue);
if (!fillsMissingDate && !refinesKnownDate && !fillsMissingSummary && !addsEventDetail) {
return input.extracted;
}
const dateValue = supplied.dateValue ?? pending.dateValue;
const summary = suppliedHasSummary ? supplied.eventSummary : pending.eventSummary;
const summary = fillsMissingDate || refinesKnownDate
? pending.eventSummary
: addsEventDetail
? [...new Set([pending.eventSummary, supplied.eventSummary])].join("")
: suppliedHasSummary ? supplied.eventSummary : pending.eventSummary;
if (!dateValue || summary === "事件内容待补充") return input.extracted;
// Re-parse the original event as one dated sentence. Detail replies may
// contain punctuation that the extractor treats as separate boundaries;
// apply the composed summary after deriving the single event metadata.
const merged = extractLifeEventEvidence({
rawText: `${dateValue} ${summary}`,
rawText: `${dateValue} ${pending.eventSummary}`,
sourceTurnId: input.command.actionId,
asOfDate: ports.asOfDate(),
correctsEvidenceId: pending.id,
@@ -889,6 +935,7 @@ export function createConversationalRectificationService(
return merged.map((item) => ({
...item,
rawText: `${pending.rawText}\n补充:${input.command.answer}`,
eventSummary: summary,
domain: pending.domain === "other" ? item.domain : pending.domain,
correctsEvidenceIds: [...item.correctsEvidenceIds],
}));
@@ -969,6 +1016,25 @@ export function createConversationalRectificationService(
}
return publicTurn(existing);
}
// A new action id is not a new rectification session. The durable
// reserve RPC rejects a second unfinished case for the same account,
// so resolve that invariant before spending time or credits. This is
// intentionally limited to the same declared birth input; a changed
// profile must not silently continue an old calculation.
let existingForUser: LoadedConversationalRectificationCase | null;
try {
existingForUser = await ports.store.loadCase({ userId });
} catch (error) {
throw safeFailure(error);
}
if (existingForUser && existingForUser.caseId !== caseId) {
observeCase(existingForUser);
if (sameDeclaredBirthInput(existingForUser.declaredBirthInput, declared.data)) {
return publicTurn(existingForUser);
}
throw new ConversationalRectificationError("action_conflict");
}
if (ports.allowNewCaseCreation === false) {
throw new ConversationalRectificationError("service_unavailable");
}
@@ -1034,6 +1100,7 @@ export function createConversationalRectificationService(
observeCase(created, "charged");
return publicTurn(created);
} catch (error) {
const failure = safeFailure(error);
if (reserved) {
try {
await ports.billing.release({
@@ -1051,7 +1118,21 @@ export function createConversationalRectificationService(
throw new ConversationalRectificationError("billing_failed");
}
}
throw safeFailure(error);
// A concurrent start can win between the account-level read above
// and the reserve/create RPC. Re-read the account after releasing our
// reservation and return the winner when it uses the same profile.
if (failure.code === "action_conflict") {
try {
const winner = await ports.store.loadCase({ userId });
if (winner && sameDeclaredBirthInput(winner.declaredBirthInput, declared.data)) {
observeCase(winner);
return publicTurn(winner);
}
} catch {
// Preserve the original stable conflict below.
}
}
throw failure;
}
},
@@ -1074,7 +1155,7 @@ export function createConversationalRectificationService(
completeLatestClarification({
command,
current,
extracted: extractedEvidence(command),
extracted: await extractedEvidence(command, current),
}),
current.declaredBirthInput.birthDate,
);
@@ -1087,7 +1168,6 @@ export function createConversationalRectificationService(
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
userMessage: command.answer,
turn: current.latestTurn,
evidence,
validationReceipt: latestReceipt(current),
@@ -1111,10 +1191,10 @@ export function createConversationalRectificationService(
const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer);
const directionChange = explicitDirectionChange
|| (scoreableEvidence.length === 0 && genericUncertaintyPattern.test(command.answer));
const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence])
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
const allScoreable = uniqueScoreableLifeEventEvidence(
[...current.eventEvidence, ...evidence],
current.declaredBirthInput.birthDate,
);
if (command.correctsEvidenceId) {
try {
@@ -1141,11 +1221,21 @@ export function createConversationalRectificationService(
? "needs_clarification"
: replacement.scoreable !== true ? "non_scoreable" : null;
if (resetReason) {
const authoredNarrative = await generateRectificationNarrative({
phase: "intermediate",
packet: gatedPacket,
generator: ports.narrativeGenerator,
context: narrativeConversationContext({
latestUserText: command.answer,
allEvidence: [...current.eventEvidence, ...evidence],
newEvidence: evidence,
}),
});
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange: false,
latestUserText: command.answer,
authoredNarrative,
correctionReset: { packet: computed.packet, reason: resetReason },
});
const privateCandidate = privateCandidateFromPacket({
@@ -1160,7 +1250,6 @@ export function createConversationalRectificationService(
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
userMessage: command.answer,
turn: next.turn,
evidence,
validationReceipt: next.receipt,
@@ -1182,37 +1271,6 @@ export function createConversationalRectificationService(
newEvidence: evidence,
}),
});
if (narrative.fallbackUsed) {
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange: false,
correctionReset: {
packet: gatedPacket,
reason: "validation_fallback",
},
});
const privateCandidate = privateCandidateFromPacket({
packet: gatedPacket,
resultId: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: true,
});
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
userMessage: command.answer,
turn: next.turn,
evidence,
validationReceipt: narrative.validationReceipt,
privateCandidate,
});
return publicTurn(saved);
}
const privateCandidate = privateCandidateFromPacket({
packet: gatedPacket,
resultId: gatedPacket.candidate.status === "ready_for_confirmation"
@@ -1235,7 +1293,6 @@ export function createConversationalRectificationService(
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
userMessage: command.answer,
turn,
evidence,
validationReceipt: narrative.validationReceipt,
@@ -1247,11 +1304,40 @@ export function createConversationalRectificationService(
}
}
if (directionChange || scoreableEvidence.length === 0) {
let authoredNarrative: RectificationNarrativeResult | null = null;
try {
const computed = await ports.buildTechnicalPacket({
userId,
caseId: command.caseId,
asOfDate: ports.asOfDate(),
declaredBirthInput: current.declaredBirthInput,
privateCandidate: current.privateCandidate,
evidence: allScoreable,
});
const gatedPacket = confirmationGatedPacket(
computed.packet,
allScoreable.length,
);
authoredNarrative = await generateRectificationNarrative({
phase: "intermediate",
packet: gatedPacket,
generator: ports.narrativeGenerator,
context: narrativeConversationContext({
latestUserText: command.answer,
allEvidence: [...current.eventEvidence, ...evidence],
newEvidence: evidence,
}),
});
} catch (error) {
console.warn("[rectification-non-scoring-narrative-fallback]", JSON.stringify({
error: error instanceof Error ? error.name : "UnknownError",
}));
}
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange,
latestUserText: command.answer,
authoredNarrative,
});
try {
const saved = await ports.store.saveTurn({
@@ -1260,7 +1346,6 @@ export function createConversationalRectificationService(
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
userMessage: command.answer,
turn: next.turn,
evidence,
validationReceipt: next.receipt,
@@ -1299,37 +1384,6 @@ export function createConversationalRectificationService(
}),
});
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
const completionReason = rangeCompletionReason({
packet: gatedPacket,
scoreableEventCount: allScoreable.length,
plateauCount,
unansweredSuggestedDomainCount: unansweredEvidenceDomains(
gatedPacket,
[...current.eventEvidence, ...evidence],
).length,
});
const narrativeWithProgress = {
...narrative,
narrative: evidenceProgressNarrative({
previousCandidate: current.privateCandidate,
packet: gatedPacket,
newEvidence: evidence,
allEvidence: [...current.eventEvidence, ...evidence],
scoreableEventCount: allScoreable.length,
willContinue: completionReason === null
&& gatedPacket.candidate.status !== "ready_for_confirmation",
authoredNarrative: narrative.fallbackUsed ? null : narrative.narrative,
}),
output: {
...narrative.output,
evidenceRequest: evidenceRequestForProgress({
packet: gatedPacket,
evidence: [...current.eventEvidence, ...evidence],
willContinue: completionReason === null
&& gatedPacket.candidate.status !== "ready_for_confirmation",
}),
},
} satisfies RectificationNarrativeResult;
const privateCandidate = privateCandidateFromPacket({
packet: gatedPacket,
resultId: gatedPacket.candidate.status === "ready_for_confirmation"
@@ -1344,20 +1398,16 @@ export function createConversationalRectificationService(
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
packet: gatedPacket,
narrative: narrativeWithProgress,
narrative,
evidence: [...current.eventEvidence, ...evidence],
});
const turn = completionReason
? completedRangeTurn(narratedTurn, completionReason)
: narratedTurn;
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
userMessage: command.answer,
turn,
turn: narratedTurn,
evidence,
validationReceipt: narrative.validationReceipt,
privateCandidate,
@@ -1368,6 +1418,74 @@ export function createConversationalRectificationService(
}
},
async regenerate(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("regenerate", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "save_turn", fingerprint);
if (receipt) return receipt;
const current = await load(userId, command.caseId);
requireMutable(current);
requireExactVersion(current, command.turnVersion);
if (!current.latestTurn.actions.includes("answer")) {
throw new ConversationalRectificationError("invalid_transition");
}
try {
const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence);
const latestEvidence = activeEvidence.at(-1);
const scoreableEvidence = uniqueScoreableLifeEventEvidence(
current.eventEvidence,
current.declaredBirthInput.birthDate,
);
const computed = await ports.buildTechnicalPacket({
userId,
caseId: command.caseId,
asOfDate: ports.asOfDate(),
declaredBirthInput: current.declaredBirthInput,
privateCandidate: current.privateCandidate,
evidence: scoreableEvidence,
preserveCandidateRange: true,
});
const gatedPacket = confirmationGatedPacket(computed.packet, scoreableEvidence.length);
const phase = gatedPacket.candidate.status === "ready_for_confirmation"
? "final" as const
: activeEvidence.length === 0 ? "first" as const : "intermediate" as const;
const narrative = await generateRectificationNarrative({
phase,
packet: gatedPacket,
generator: ports.narrativeGenerator,
context: latestEvidence ? narrativeConversationContext({
latestUserText: latestEvidence.rawText,
allEvidence: current.eventEvidence,
newEvidence: [latestEvidence],
}) : undefined,
});
const turn = turnFromNarrative({
caseId: command.caseId,
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
packet: gatedPacket,
narrative,
evidence: current.eventEvidence,
});
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn,
evidence: [],
validationReceipt: narrative.validationReceipt,
privateCandidate: current.privateCandidate,
});
return publicTurn(saved);
} catch (error) {
throw safeFailure(error);
}
},
async pause(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("pause", rawCommand);
@@ -1398,7 +1516,7 @@ export function createConversationalRectificationService(
const next = changedTurn({
current,
status: "paused",
narrative: boundedNarrative(current.latestTurn.narrative, "校正已暂停,现有证据和候选已保存;继续时不会重复扣点。"),
narrative: current.latestTurn.narrative,
});
try {
return publicTurn(await ports.store.pause({
@@ -1442,7 +1560,7 @@ export function createConversationalRectificationService(
const next = changedTurn({
current,
status: "abandoned",
narrative: boundedNarrative(current.latestTurn.narrative, "本次校正已放弃;再次校正期间原有确认时间始终没有被替换。"),
narrative: current.latestTurn.narrative,
});
try {
return publicTurn(await ports.store.abandon({
@@ -1498,12 +1616,7 @@ export function createConversationalRectificationService(
const next = changedTurn({
current,
status: "completed",
narrative: boundedNarrative(
current.latestTurn.narrative,
current.pendingConsultationQuestion
? "你已明确确认这个候选时间。现在可以使用新确认时间继续回答原问题。"
: "你已明确确认这个候选时间,账户当前排盘时间已原子更新。",
),
narrative: current.latestTurn.narrative,
});
try {
return publicTurn(await ports.store.confirm({
@@ -24,16 +24,21 @@ const birthDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) =
const locationCodeSchema = boundedText(80);
const birthplaceSchema = boundedJson(z.object({
city: boundedText(120).optional(),
placeId: boundedText(240).optional(),
placeType: boundedText(80).optional(),
provider: boundedText(80).optional(),
countryCode: z.string().regex(/^[A-Z0-9-]{1,8}$/).optional(),
provinceCode: locationCodeSchema.optional(),
cityCode: locationCodeSchema.optional(),
districtCode: locationCodeSchema.optional(),
latitude: z.number().finite().min(-90).max(90).optional(),
longitude: z.number().finite().min(-180).max(180).optional(),
timezoneId: boundedText(120).optional(),
timezoneSource: boundedText(80).optional(),
timezoneOffset: z.number().finite().min(-12).max(14),
}).strict().superRefine((value, context) => {
if (!value.city && !value.cityCode) {
context.addIssue({ code: "custom", message: "city or cityCode is required" });
if (!value.city && !value.cityCode && !value.placeId) {
context.addIssue({ code: "custom", message: "city, cityCode, or placeId is required" });
}
if ((value.latitude === undefined) !== (value.longitude === undefined)) {
context.addIssue({ code: "custom", message: "coordinates must be supplied as a pair" });
@@ -1,6 +1,5 @@
import { z } from "zod";
import {
type ConversationalRectificationMessageHistoryEntry,
conversationalRectificationTurnSchema,
type ConversationalRectificationTurn,
} from "./contracts.ts";
@@ -58,7 +57,6 @@ export type StoredConversationalRectificationCase = Readonly<{
pendingConsultationQuestion: string | null;
billingState: "reserved" | "charged" | "released" | "migration_waived" | null;
latestTurn: ConversationalRectificationTurn;
messageHistory?: ReadonlyArray<ConversationalRectificationMessageHistoryEntry>;
declaredBirthInput?: DeepReadonly<DeclaredBirthInput>;
privateCandidate?: DeepReadonly<PrivateCandidate>;
eventEvidence?: ReadonlyArray<LifeEventEvidenceInput>;
@@ -106,7 +104,6 @@ export type CreateConversationalRectificationCaseInput = MutationIdentity & Read
export type LifeEventEvidenceInput = DeepReadonly<LifeEventEvidence>;
export type SaveConversationalRectificationTurnInput = CommandMutationIdentity & Readonly<{
userMessage: string;
turn: ConversationalRectificationTurnInput;
evidence: ReadonlyArray<LifeEventEvidenceInput>;
validationReceipt: ValidationReceiptInput;
@@ -193,8 +190,6 @@ function parseStoredCase(data: unknown, allowNull = false): StoredConversational
pendingConsultationQuestion: value.pending_consultation_question,
billingState: value.billing_state,
latestTurn: value.latest_turn,
...(value.message_history === undefined
? {} : { messageHistory: value.message_history }),
...(value.declared_birth_input === undefined
? {} : { declaredBirthInput: value.declared_birth_input }),
...(value.private_candidate === undefined
@@ -212,6 +207,22 @@ function requirePublicTurn(turn: ConversationalRectificationTurnInput): Conversa
return parsed.data;
}
/**
* A new-event marker is only an explicit form of the legacy default. Older
* databases reject that optional field and surface a misleading
* action_conflict, so omit it at the durable boundary. The follow-up migration
* remains required for event_date/event_detail turns.
*/
function turnForDurableContract(
turn: ConversationalRectificationTurnInput,
): ConversationalRectificationTurn {
const parsed = requirePublicTurn(turn);
if (parsed.evidenceRequest?.followUp?.kind !== "new_event") return parsed;
const evidenceRequest = { ...parsed.evidenceRequest };
delete evidenceRequest.followUp;
return { ...parsed, evidenceRequest };
}
function invalidDurableInput(): never {
throw new ConversationalRectificationError("action_conflict");
}
@@ -293,7 +304,9 @@ export class ConversationalRectificationStore {
): Promise<StoredConversationalRectificationCase | null> {
try {
const { data, error } = await this.supabase.rpc(functionName, args);
if (error) throw mapConversationalRectificationStoreError(error);
if (error) {
throw mapConversationalRectificationStoreError(error);
}
return parseStoredCase(data, allowNull);
} catch (error) {
if (error instanceof ConversationalRectificationError) throw error;
@@ -312,7 +325,7 @@ export class ConversationalRectificationStore {
p_revision_of_case_id: input.revisionOfCaseId,
p_pending_consultation_question: input.pendingConsultationQuestion,
p_declared_birth_input: requireDeclaredBirthInput(input.declaredBirthInput),
p_first_turn: requirePublicTurn(input.firstTurn),
p_first_turn: turnForDurableContract(input.firstTurn),
p_validation_receipt: requireValidationReceipt(input.validationReceipt),
p_private_candidate: requirePrivateCandidate(input.privateCandidate),
});
@@ -324,7 +337,7 @@ export class ConversationalRectificationStore {
userId: string;
caseId?: string;
}>): Promise<LoadedConversationalRectificationCase | null> {
const loaded = await this.callCaseRpc("load_conversational_rectification_case_with_history", {
const loaded = await this.callCaseRpc("load_conversational_rectification_case", {
p_user_id: input.userId,
p_case_id: input.caseId ?? null,
}, true);
@@ -354,13 +367,12 @@ export class ConversationalRectificationStore {
input: SaveConversationalRectificationTurnInput,
): Promise<StoredConversationalRectificationCase> {
const functionName = input.turn.status === "completed"
? "complete_conversational_rectification_with_range_and_history"
: "save_conversational_rectification_turn_with_history";
? "complete_conversational_rectification_with_range"
: "save_conversational_rectification_turn";
const result = await this.callCaseRpc(functionName, {
...mutationArgs(input),
p_command_fingerprint: commandFingerprint(input),
p_user_message: input.userMessage,
p_turn: requirePublicTurn(input.turn),
p_turn: turnForDurableContract(input.turn),
p_evidence: requireEvidence(input.evidence),
p_validation_receipt: requireValidationReceipt(input.validationReceipt),
p_private_candidate: requirePrivateCandidate(input.privateCandidate),
@@ -235,17 +235,31 @@ function buildExpertWorkflow(
evidence: [],
boundary: "当前服务端生时校正评分合同未提供可审计的 KP cusp 结果,禁止声称已使用。",
},
{
technique: "VedAstro official validation",
status: receipt?.externalEngines?.status === "pass"
? "used"
: receipt?.externalEngines?.status === "fail" ? "blocked" : "not_evaluated",
evidence: [
String(receipt?.externalEngines?.validation?.vedastro_status ?? "not_evaluated"),
String(receipt?.externalEngines?.status ?? "not_evaluated"),
String(receipt?.externalEngines?.validation?.candidate_time ?? ""),
].filter((item) => item.length > 0),
boundary: receipt?.externalEngines?.status === "pass"
? "VedAstro 已返回官方结果,且胜出分钟在已发生事件扫描中严格领先次优分钟;仍需与本地、PyJHora、jyotishganit 及稳定性结果同时成立。"
: "候选未通过本地稳定性前不调用付费验证;进入分钟确认阶段后 VedAstro 官方验证为必跑项。",
},
{
technique: "Minute confirmation",
status: receipt?.confirmationAllowed === true ? "used" : "blocked",
evidence: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
evidence: receipt?.hardBlockers ?? ["evidence_and_external_validation_pending"],
boundary: receipt?.confirmationAllowed === true
? "仍须用户明确确认后才能替换当前排盘时间。"
: "公开 AA 分钟 holdout 与盲测门禁未通过前,不得自动确认分钟。",
: "继续采集事件,直到本轮稳定性、必需技法、本地三引擎一致性与 VedAstro 官方验证全部通过。",
},
],
confirmationAllowed: receipt?.confirmationAllowed === true,
hardBlockers: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
hardBlockers: receipt?.hardBlockers ?? ["evidence_and_external_validation_pending"],
gates: receipt?.gates ?? {},
};
}
@@ -0,0 +1,210 @@
import type {
BirthLocationSearchQuery,
BirthLocationSearchResult,
NormalizedBirthLocation,
} from "./location-contract";
import { chinaLocations } from "../data/china-locations";
type FetchLike = typeof fetch;
type JsonRecord = Record<string, unknown>;
function record(value: unknown): JsonRecord {
return value && typeof value === "object" ? value as JsonRecord : {};
}
function text(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function findChinaMatches(query: string, limit: number) {
const needle = query.replace(/\s+/g, "").toLowerCase();
const matches: Array<{
code: string;
type: "province" | "city" | "district";
label: string;
provinceCode: string;
provinceName: string;
cityName: string | null;
districtName: string | null;
latitude: number;
longitude: number;
}> = [];
for (const province of chinaLocations.country.provinces) {
if (province.name.toLowerCase().includes(needle)) matches.push({
code: province.code,
type: "province",
label: `中国 · ${province.name}`,
provinceCode: province.code,
provinceName: province.name,
cityName: null,
districtName: null,
latitude: province.center[1],
longitude: province.center[0],
});
for (const city of province.cities) {
if (city.name !== province.name && city.name.toLowerCase().includes(needle)) matches.push({
code: city.code,
type: "city",
label: `中国 · ${province.name} · ${city.name}`,
provinceCode: province.code,
provinceName: province.name,
cityName: city.name,
districtName: null,
latitude: city.center[1],
longitude: city.center[0],
});
for (const district of city.districts) {
if (!district.name.toLowerCase().includes(needle)) continue;
matches.push({
code: district.code,
type: "district",
label: `中国 · ${province.name} · ${city.name} · ${district.name}`,
provinceCode: province.code,
provinceName: province.name,
cityName: city.name,
districtName: district.name,
latitude: district.center[1],
longitude: district.center[0],
});
}
}
}
return matches.slice(0, limit);
}
async function resolveTimezone(
fetchImpl: FetchLike,
apiBase: string,
latitude: number,
longitude: number,
query: BirthLocationSearchQuery,
) {
const response = await fetchImpl(`${apiBase}/api/location/timezone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude,
longitude,
...(query.birthDate ? { birthDate: query.birthDate } : {}),
...(query.birthTime ? { birthTime: query.birthTime } : {}),
}),
cache: "no-store",
});
if (!response.ok) throw new Error("timezone_service_unavailable");
const payload = record(await response.json());
if (payload.available !== true || !text(payload.timezoneId)) {
throw new Error("timezone_service_unavailable");
}
return payload;
}
export async function searchGlobalBirthLocations(
query: BirthLocationSearchQuery,
options: {
apiKey?: string;
apiBase?: string;
fetchImpl?: FetchLike;
} = {},
): Promise<BirthLocationSearchResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const apiBase = options.apiBase ?? process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const chinaMatches = findChinaMatches(query.q, query.limit);
if (chinaMatches.length > 0) {
try {
const locations = await Promise.all(chinaMatches.map(async (match): Promise<NormalizedBirthLocation> => {
const timezone = await resolveTimezone(fetchImpl, apiBase, match.latitude, match.longitude, query);
return {
provider: "china_locations",
providerPlaceId: match.code,
placeType: match.type,
label: match.label,
countryCode: "CN",
countryName: "中国",
regionCode: match.provinceCode,
regionName: match.provinceName,
localityName: match.cityName,
districtName: match.districtName,
latitude: match.latitude,
longitude: match.longitude,
timezoneId: String(timezone.timezoneId),
timezoneOffset: typeof timezone.timezoneOffset === "number" ? timezone.timezoneOffset : null,
timezoneSource: "iana_historical",
localTimeStatus: ["resolved", "not_provided", "ambiguous", "nonexistent"].includes(String(timezone.localTimeStatus))
? timezone.localTimeStatus as NormalizedBirthLocation["localTimeStatus"]
: "not_provided",
};
}));
return { status: "ok", locations };
} catch {
return { status: "unavailable", reason: "timezone_service_unavailable" };
}
}
const apiKey = options.apiKey?.trim() || process.env.GEOAPIFY_API_KEY?.trim();
if (!apiKey) return { status: "unavailable", reason: "geoapify_not_configured" };
const params = new URLSearchParams({
text: query.q,
apiKey,
limit: String(query.limit),
lang: query.locale.split("-")[0].toLowerCase(),
format: "geojson",
});
let response: Response;
try {
response = await fetchImpl(`https://api.geoapify.com/v1/geocode/autocomplete?${params}`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
} catch {
return { status: "unavailable", reason: "provider_unavailable" };
}
if (!response.ok) return { status: "unavailable", reason: "provider_unavailable" };
const payload = record(await response.json());
const features = Array.isArray(payload.features) ? payload.features : [];
const normalized: NormalizedBirthLocation[] = [];
for (const rawFeature of features) {
const feature = record(rawFeature);
const properties = record(feature.properties);
const geometry = record(feature.geometry);
const coordinates = Array.isArray(geometry.coordinates) ? geometry.coordinates.map(Number) : [];
const longitude = coordinates[0] ?? Number(properties.lon);
const latitude = coordinates[1] ?? Number(properties.lat);
const providerPlaceId = text(properties.place_id);
const label = text(properties.formatted) ?? text(properties.address_line1) ?? text(properties.name);
const placeType = text(properties.result_type) ?? "locality";
if (!providerPlaceId || !label || !Number.isFinite(latitude) || !Number.isFinite(longitude)) continue;
try {
const timezone = await resolveTimezone(fetchImpl, apiBase, latitude, longitude, query);
normalized.push({
provider: "geoapify",
providerPlaceId,
placeType,
label,
countryCode: text(properties.country_code)?.toUpperCase() ?? null,
countryName: text(properties.country),
regionCode: text(properties.state_code),
regionName: text(properties.state),
localityName: text(properties.city)
?? text(properties.town)
?? text(properties.village)
?? text(properties.municipality)
?? text(properties.suburb)
?? text(properties.name),
districtName: text(properties.county) ?? text(properties.district),
latitude,
longitude,
timezoneId: String(timezone.timezoneId),
timezoneOffset: typeof timezone.timezoneOffset === "number" ? timezone.timezoneOffset : null,
timezoneSource: "iana_historical",
localTimeStatus: ["resolved", "not_provided", "ambiguous", "nonexistent"].includes(String(timezone.localTimeStatus))
? timezone.localTimeStatus as NormalizedBirthLocation["localTimeStatus"]
: "not_provided",
});
} catch {
return { status: "unavailable", reason: "timezone_service_unavailable" };
}
}
return { status: "ok", locations: normalized };
}
+53
View File
@@ -0,0 +1,53 @@
import { z } from "zod";
function isIsoCalendarDate(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return false;
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
return date.getUTCFullYear() === Number(match[1])
&& date.getUTCMonth() === Number(match[2]) - 1
&& date.getUTCDate() === Number(match[3]);
}
export const birthLocationSearchQuerySchema = z.object({
q: z.string().trim().min(2).max(160),
birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
birthTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/).optional(),
locale: z.string().trim().min(2).max(16).default("zh"),
limit: z.coerce.number().int().min(1).max(8).default(5),
}).superRefine((value, context) => {
if (value.birthTime && !value.birthDate) context.addIssue({
code: z.ZodIssueCode.custom,
path: ["birthDate"],
message: "birthTime requires birthDate",
});
if (value.birthDate && !isIsoCalendarDate(value.birthDate)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["birthDate"], message: "invalid birthDate" });
}
});
export const normalizedBirthLocationSchema = z.object({
provider: z.enum(["geoapify", "china_locations", "mapbox", "geonames"]),
providerPlaceId: z.string().min(1),
placeType: z.string().min(1),
label: z.string().min(1),
countryCode: z.string().nullable(),
countryName: z.string().nullable(),
regionCode: z.string().nullable(),
regionName: z.string().nullable(),
localityName: z.string().nullable(),
districtName: z.string().nullable(),
latitude: z.number().finite().min(-90).max(90),
longitude: z.number().finite().min(-180).max(180),
timezoneId: z.string().min(1),
timezoneOffset: z.number().finite().min(-12).max(14).nullable(),
timezoneSource: z.literal("iana_historical"),
localTimeStatus: z.enum(["resolved", "not_provided", "ambiguous", "nonexistent"]),
});
export type BirthLocationSearchQuery = z.infer<typeof birthLocationSearchQuerySchema>;
export type NormalizedBirthLocation = z.infer<typeof normalizedBirthLocationSchema>;
export type BirthLocationSearchResult =
| { status: "ok"; locations: NormalizedBirthLocation[] }
| { status: "unavailable"; reason: "geoapify_not_configured" | "timezone_service_unavailable" | "provider_unavailable" };
+37 -21
View File
@@ -30,6 +30,11 @@ export type OnboardingProfileRow = {
readonly country_code: string | null;
readonly province_code: string | null;
readonly city_code: string | null;
readonly latitude: number | null;
readonly longitude: number | null;
readonly timezone_offset: number | null;
readonly birth_place_label: string | null;
readonly timezone_id: string | null;
readonly onboarding_payload: unknown;
readonly onboarding_version: string | null;
readonly onboarding_generated_at: string | null;
@@ -92,28 +97,39 @@ function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean {
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (persistedTime ? "confirmed" : "");
const clock = (value: string | null) => value ? value.slice(0, 5) : "";
const birthDraft = {
date: profile.birth_date ?? "",
time: clock(persistedTime),
reportedTime: clock(profile.reported_birth_time)
|| (source === "legacy_import" ? clock(persistedTime) : ""),
birthTimeSource: source,
birthTimePeriod: profile.birth_time_period === "early_morning"
|| profile.birth_time_period === "morning"
|| profile.birth_time_period === "afternoon"
|| profile.birth_time_period === "evening"
|| profile.birth_time_period === "late_night"
? profile.birth_time_period
: "",
birthTimeClue: profile.birth_time_clue ?? "",
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
birthTimeStatus: status,
} as const;
const globalPlace = profile.birth_place_label && profile.timezone_id
? {
label: profile.birth_place_label,
lat: profile.latitude ?? Number.NaN,
lon: profile.longitude ?? Number.NaN,
tz: profile.timezone_offset,
timezoneId: profile.timezone_id,
}
: null;
const placeComplete = globalPlace
? isDeclaredBirthProfileComplete(birthDraft, globalPlace)
: Boolean(profile.country_code && profile.province_code && profile.city_code);
return Boolean(profile.name
&& profile.country_code
&& profile.province_code
&& profile.city_code
&& isDeclaredBirthProfileComplete({
date: profile.birth_date ?? "",
time: clock(persistedTime),
reportedTime: clock(profile.reported_birth_time)
|| (source === "legacy_import" ? clock(persistedTime) : ""),
birthTimeSource: source,
birthTimePeriod: profile.birth_time_period === "early_morning"
|| profile.birth_time_period === "morning"
|| profile.birth_time_period === "afternoon"
|| profile.birth_time_period === "evening"
|| profile.birth_time_period === "late_night"
? profile.birth_time_period
: "",
birthTimeClue: profile.birth_time_clue ?? "",
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
birthTimeStatus: status,
}));
&& placeComplete
&& isDeclaredBirthProfileComplete(birthDraft));
}
export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise<Response> {