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 & { 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; }