feat: complete minute birth-time rectification flow
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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、followUp;narrative 不得输出或讨论事件分类、领域标签,也不要重复输出候选状态、时间、分盘或引用字段。",
|
||||
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 };
|
||||
}
|
||||
@@ -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" };
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user