331 lines
16 KiB
TypeScript
331 lines
16 KiB
TypeScript
import type { ProvinceNode } from "@/data/china-locations";
|
||
import {
|
||
declaredBirthInputChanged,
|
||
describeBirthTimeDraft,
|
||
hydrateDeclaredWindowDraft,
|
||
isBirthTimeDraftReady,
|
||
isDeclaredBirthProfileComplete,
|
||
normalizePersistedBirthDate,
|
||
type BirthTimeSource,
|
||
} from "@/lib/birth-time-intake-model";
|
||
import {
|
||
birthLocationKeys,
|
||
china,
|
||
emptyProfile,
|
||
presetOnboardingMessage,
|
||
timestamp,
|
||
type BirthPlace,
|
||
type ChartLibraryRecord,
|
||
type ChartRelationship,
|
||
type ChatProfileBinding,
|
||
type ChatSession,
|
||
type Message,
|
||
type OnboardingStep,
|
||
type Profile,
|
||
type SynastryRelationshipType,
|
||
} from "@/lib/home-types";
|
||
import { resolveAyanamsa } from "@/lib/ayanamsa";
|
||
|
||
export function findProvince(code: string) {
|
||
return china.provinces.find((province) => province.code === code);
|
||
}
|
||
|
||
export function findCity(province: ProvinceNode | undefined, code: string) {
|
||
return province?.cities.find((city) => city.code === code);
|
||
}
|
||
|
||
export function selectedBirthPlace(profile: Profile): BirthPlace | null {
|
||
if (profile.birthPlaceLabel
|
||
&& Number.isFinite(profile.latitude)
|
||
&& Number.isFinite(profile.longitude)
|
||
&& profile.timezoneId.trim()
|
||
&& (profile.timezoneOffset === null || Number.isFinite(profile.timezoneOffset))) {
|
||
return {
|
||
label: profile.birthPlaceLabel,
|
||
lat: profile.latitude as number,
|
||
lon: profile.longitude as number,
|
||
tz: profile.timezoneOffset,
|
||
timezoneId: profile.timezoneId,
|
||
};
|
||
}
|
||
|
||
const province = findProvince(profile.provinceCode);
|
||
const city = findCity(province, profile.cityCode);
|
||
if (!province || !city) return null;
|
||
|
||
const district = city.districts.find((item) => item.code === profile.districtCode);
|
||
if (city.districts.length > 0 && !district) return null;
|
||
|
||
const location = district ?? city;
|
||
const label = [china.name, province.name, city.name, district?.name]
|
||
.filter((name, index, names) => Boolean(name) && names.indexOf(name) === index)
|
||
.join(" · ");
|
||
|
||
return {
|
||
label,
|
||
lat: location.center[1],
|
||
lon: location.center[0],
|
||
tz: china.timezone,
|
||
timezoneId: "Asia/Shanghai",
|
||
};
|
||
}
|
||
export function profileReadyForLibrary(profile: Profile) {
|
||
return !missingProfileStep(profile);
|
||
}
|
||
|
||
export function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
|
||
return { id: "self", role: "self", profile: { ...profile, chartRelationship: "self" }, relationship: "self", updatedAt: timestamp() };
|
||
}
|
||
|
||
export function chartSnapshotForSession(
|
||
chartId: string,
|
||
library: readonly ChartLibraryRecord[],
|
||
fallbackProfile: Profile,
|
||
): ChatProfileBinding {
|
||
const record = library.find((item) => item.id === chartId);
|
||
if (record) {
|
||
return {
|
||
chartProfileId: record.id,
|
||
chartProfileName: record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料"),
|
||
chartProfileRole: record.role,
|
||
};
|
||
}
|
||
if (chartId === "self") {
|
||
return { chartProfileId: "self", chartProfileName: fallbackProfile.name.trim() || "我", chartProfileRole: "self" };
|
||
}
|
||
return { chartProfileId: chartId || null, chartProfileName: "未命名资料", chartProfileRole: chartId ? "other" : null };
|
||
}
|
||
|
||
export function sessionChartLabel(session: ChatSession, library: readonly ChartLibraryRecord[]) {
|
||
if (!session.chartProfileId) return "未关联资料";
|
||
const current = session.chartProfileId === "self" || library.some((record) => record.id === session.chartProfileId);
|
||
const name = session.chartProfileName?.trim() || (session.chartProfileRole === "self" ? "我" : "未命名资料");
|
||
return current ? name : `资料已删除 · ${name}`;
|
||
}
|
||
|
||
export function sessionSidebarTitle(session: ChatSession, _library?: readonly ChartLibraryRecord[]) {
|
||
return session.title?.trim() || "新对话";
|
||
}
|
||
|
||
export function sessionSidebarSubtitle(session: ChatSession, library: readonly ChartLibraryRecord[]) {
|
||
if (session.chartProfileRole === "self" || !session.chartProfileId) return null;
|
||
return sessionChartLabel(session, library);
|
||
}
|
||
|
||
export function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
|
||
if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self");
|
||
const others = library.filter((record) => record.role !== "self");
|
||
return [buildSelfChartRecord(profile), ...others];
|
||
}
|
||
export function profilePlaceLabel(profile: Profile) {
|
||
return selectedBirthPlace(profile)?.label || "地点未完整";
|
||
}
|
||
|
||
export function profileBirthTimeLabel(profile: Profile) {
|
||
if (profile.time) return profile.time;
|
||
if (profile.birthTimePeriod) return `${profile.birthTimePeriod}(时分待确认)`;
|
||
return "出生时间待补全";
|
||
}
|
||
|
||
export function profileBirthTimeStatusLabel(profile: Profile) {
|
||
if (profile.birthTimeStatus === "confirmed") return "时间已确认";
|
||
if (profile.birthTimeStatus === "candidate" || profile.birthTimeStatus === "accepted") return "时间为候选";
|
||
if (profile.birthTimeStatus === "rectifying" || profile.birthTimeStatus === "assessing") return "正在评估时间";
|
||
return "时间待确认";
|
||
}
|
||
|
||
export function chartRelationshipLabel(relationship: ChartRelationship) {
|
||
return relationship === "partner" ? "伴侣" : relationship === "family" ? "家人" : relationship === "friend" ? "朋友" : relationship === "client" ? "客户" : relationship === "self" ? "本人" : "其他";
|
||
}
|
||
|
||
export function formatChartUpdatedAt(updatedAt: number) {
|
||
if (!Number.isFinite(updatedAt) || updatedAt <= 0) return "刚刚更新";
|
||
return `更新于 ${new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(new Date(updatedAt))}`;
|
||
}
|
||
|
||
export function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, relationshipType: SynastryRelationshipType) {
|
||
const relationshipLabel = relationshipType === "business" ? "商业合作" : relationshipType === "family" ? "亲友/家庭" : relationshipType === "general" ? "其他关系" : "婚恋";
|
||
const evidenceRequest = relationshipType === "business"
|
||
? "请先说明 D2/D10/D11 已用层与 A10、双方 Dasha/Narayana、功能吉凶等缺失层;不得给出合作成败、收益保证或精确时点。"
|
||
: relationshipType === "romance"
|
||
? "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。"
|
||
: "请先说明当前缺少专用合盘计算合同,只基于可验证资料提出需要补充的现实关系信息,不作确定性判断。";
|
||
return [
|
||
`请用印度占星分析我和${partnerProfile.name || "对方"}的${relationshipLabel}关系。`,
|
||
`我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`,
|
||
`对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`,
|
||
evidenceRequest,
|
||
].join("\n");
|
||
}
|
||
export function missingProfileStep(profile: Profile): OnboardingStep | null {
|
||
if (!profile.name.trim()) return "name";
|
||
if (!isDeclaredBirthProfileComplete(profile)) return "birth";
|
||
if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place";
|
||
return null;
|
||
}
|
||
|
||
export function missingOtherProfileStep(profile: Profile): "name" | "birth" | "place" | null {
|
||
if (!profile.name.trim()) return "name";
|
||
if (!isBirthTimeDraftReady(profile)) return "birth";
|
||
if (!selectedBirthPlace(profile)) return "place";
|
||
return null;
|
||
}
|
||
|
||
export function birthQuestion(name: string) {
|
||
return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`;
|
||
}
|
||
|
||
export function formatBirthMoment(profile: Profile) {
|
||
return describeBirthTimeDraft(profile);
|
||
}
|
||
|
||
export function placeQuestion(profile: Profile) {
|
||
return `记下了:${formatBirthMoment(profile)}。最后一个问题,你出生在哪里?`;
|
||
}
|
||
|
||
export function completedOnboardingMessage(name: string) {
|
||
return `${name},我们可以开始了。你可以从下面三个方向选择,也可以直接告诉我现在最想问的事。`;
|
||
}
|
||
|
||
export function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] {
|
||
const name = profile.name.trim();
|
||
const birthPlace = selectedBirthPlace(profile);
|
||
if (!name || !isDeclaredBirthProfileComplete(profile) || !birthPlace) return [];
|
||
|
||
return [
|
||
{ role: "assistant", text: presetOnboardingMessage },
|
||
{ role: "user", text: name },
|
||
{ role: "assistant", text: birthQuestion(name) },
|
||
{ role: "user", text: formatBirthMoment(profile) },
|
||
{ role: "assistant", text: placeQuestion(profile) },
|
||
{ role: "user", text: birthPlace.label },
|
||
{ role: "assistant", text: greeting || completedOnboardingMessage(name) },
|
||
];
|
||
}
|
||
|
||
export function readProfile(value: unknown): Profile {
|
||
if (!value || typeof value !== "object") return emptyProfile;
|
||
const profile = value as Partial<Profile> & {
|
||
birth_date?: unknown;
|
||
birth_time?: unknown;
|
||
reported_birth_time?: unknown;
|
||
active_birth_time?: unknown;
|
||
birth_time_source?: unknown;
|
||
birth_time_period?: unknown;
|
||
declared_window_start?: unknown;
|
||
declared_window_end?: unknown;
|
||
birth_time_clue?: unknown;
|
||
uncertainty_before_minutes?: unknown;
|
||
uncertainty_after_minutes?: unknown;
|
||
birth_time_status?: unknown;
|
||
rectification_case_id?: unknown;
|
||
country_code?: unknown;
|
||
province_code?: unknown;
|
||
city_code?: unknown;
|
||
district_code?: unknown;
|
||
birth_place_label?: unknown;
|
||
birth_place_type?: unknown;
|
||
birth_place_provider?: unknown;
|
||
birth_place_provider_id?: unknown;
|
||
timezone_id?: unknown;
|
||
timezone_source?: unknown;
|
||
latitude?: unknown;
|
||
longitude?: unknown;
|
||
timezone_offset?: unknown;
|
||
chartRelationship?: unknown;
|
||
};
|
||
const date = normalizePersistedBirthDate(
|
||
typeof profile.birth_date === "string" ? profile.birth_date : profile.date,
|
||
);
|
||
const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time;
|
||
const time = typeof profile.active_birth_time === "string"
|
||
? profile.active_birth_time.slice(0, 5)
|
||
: legacyTime;
|
||
const persistedReportedTime = typeof profile.reported_birth_time === "string"
|
||
? profile.reported_birth_time.slice(0, 5)
|
||
: "";
|
||
const knownSources: readonly BirthTimeSource[] = [
|
||
"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import",
|
||
];
|
||
const source = knownSources.find((item) => item === profile.birth_time_source)
|
||
?? (time ? "legacy_import" : "");
|
||
const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : "");
|
||
const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
|
||
const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? "";
|
||
const windowStart = typeof profile.declared_window_start === "string" ? profile.declared_window_start.slice(0, 5) : "";
|
||
const windowEnd = typeof profile.declared_window_end === "string" ? profile.declared_window_end.slice(0, 5) : "";
|
||
const declaredWindow = hydrateDeclaredWindowDraft({
|
||
period,
|
||
start: windowStart,
|
||
end: windowEnd,
|
||
});
|
||
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const;
|
||
const status = knownStatuses.find((item) => item === profile.birth_time_status)
|
||
?? (time ? "confirmed" : "");
|
||
const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode;
|
||
const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode;
|
||
const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode;
|
||
const countryCode = typeof profile.country_code === "string" ? profile.country_code : profile.countryCode;
|
||
const birthPlaceLabel = typeof profile.birth_place_label === "string" ? profile.birth_place_label : profile.birthPlaceLabel;
|
||
const birthPlaceType = typeof profile.birth_place_type === "string" ? profile.birth_place_type : profile.birthPlaceType;
|
||
const birthPlaceProvider = typeof profile.birth_place_provider === "string" ? profile.birth_place_provider : profile.birthPlaceProvider;
|
||
const birthPlaceProviderId = typeof profile.birth_place_provider_id === "string" ? profile.birth_place_provider_id : profile.birthPlaceProviderId;
|
||
const timezoneId = typeof profile.timezone_id === "string" ? profile.timezone_id : profile.timezoneId;
|
||
const timezoneSource = typeof profile.timezone_source === "string" ? profile.timezone_source : profile.timezoneSource;
|
||
const latitude = typeof profile.latitude === "number" && Number.isFinite(profile.latitude) ? profile.latitude : null;
|
||
const longitude = typeof profile.longitude === "number" && Number.isFinite(profile.longitude) ? profile.longitude : null;
|
||
const timezoneOffset = typeof profile.timezone_offset === "number" && Number.isFinite(profile.timezone_offset)
|
||
? profile.timezone_offset
|
||
: typeof profile.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset)
|
||
? profile.timezoneOffset
|
||
: null;
|
||
const chartRelationships: readonly ChartRelationship[] = ["self", "partner", "family", "friend", "client", "other"];
|
||
const chartRelationship = chartRelationships.find((item) => item === profile.chartRelationship);
|
||
|
||
return {
|
||
name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "",
|
||
date: typeof date === "string" ? date : "",
|
||
time: typeof time === "string" ? time : "",
|
||
reportedTime: typeof reportedTime === "string" ? reportedTime : "",
|
||
birthTimeSource: source,
|
||
birthTimePeriod: declaredWindow.birthTimePeriod,
|
||
declaredWindowStart: declaredWindow.declaredWindowStart,
|
||
declaredWindowEnd: declaredWindow.declaredWindowEnd,
|
||
birthTimeClue: "",
|
||
uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null,
|
||
uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null,
|
||
birthTimeStatus: status,
|
||
rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "",
|
||
countryCode: typeof countryCode === "string" && countryCode ? countryCode : "CN",
|
||
provinceCode: typeof provinceCode === "string" ? provinceCode : "",
|
||
cityCode: typeof cityCode === "string" ? cityCode : "",
|
||
districtCode: typeof districtCode === "string" ? districtCode : "",
|
||
birthPlaceLabel: typeof birthPlaceLabel === "string" ? birthPlaceLabel : "",
|
||
birthPlaceType: typeof birthPlaceType === "string" ? birthPlaceType : "",
|
||
birthPlaceProvider: typeof birthPlaceProvider === "string" ? birthPlaceProvider : "",
|
||
birthPlaceProviderId: typeof birthPlaceProviderId === "string" ? birthPlaceProviderId : "",
|
||
timezoneId: typeof timezoneId === "string" ? timezoneId : "",
|
||
timezoneSource: typeof timezoneSource === "string" ? timezoneSource : "",
|
||
latitude,
|
||
longitude,
|
||
timezoneOffset,
|
||
ayanamsa: resolveAyanamsa(profile),
|
||
...(chartRelationship ? { chartRelationship } : {}),
|
||
};
|
||
}
|
||
export function birthProfileDeclarationChanged(current: Profile, next: Profile) {
|
||
return declaredBirthInputChanged(current, next)
|
||
|| birthLocationKeys.some((key) => current[key] !== next[key]);
|
||
}
|
||
|
||
export function invalidateCandidateAfterLocationChange(current: Profile, next: Profile): Profile {
|
||
const locationChanged = birthLocationKeys.some((key) => current[key] !== next[key]);
|
||
if (!locationChanged
|
||
|| current.birthTimeStatus === "confirmed"
|
||
|| (current.birthTimeStatus !== "candidate" && !current.time)) return next;
|
||
return { ...next, time: "", birthTimeStatus: "reported" };
|
||
}
|
||
export function isProfileComplete(profile: Profile) {
|
||
return missingProfileStep(profile) === null;
|
||
}
|