Files
Jyotisha/frontend/src/lib/home-profile.ts
T

331 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}