初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,240 @@
|
||||
"""时态快照:把「现在此地」解读成完整的时态语义对象。
|
||||
|
||||
消费 lunar 与 duration 纯函数,组装 ``TemporalSnapshot``。零 IO——纪念日、
|
||||
相遇锚点、最后活跃时间均由调用方以值传入(``SnapshotInput``)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.modules.time_sense.duration import (
|
||||
AcquaintanceDuration,
|
||||
OfflineDuration,
|
||||
OfflineThresholds,
|
||||
classify_offline_band,
|
||||
compute_acquaintance_duration,
|
||||
)
|
||||
from app.modules.time_sense.lunar import (
|
||||
Festival,
|
||||
LunarDate,
|
||||
SolarTermInfo,
|
||||
get_day_period,
|
||||
get_festivals,
|
||||
get_shichen,
|
||||
get_solar_term,
|
||||
solar_to_lunar,
|
||||
)
|
||||
|
||||
|
||||
class LocationContext(BaseModel):
|
||||
"""位置上下文(v1 来源为档案/会话手动配置)。"""
|
||||
|
||||
timezone: str
|
||||
city: str | None = None
|
||||
locale: str = "zh-CN"
|
||||
|
||||
|
||||
class AnniversaryRef(BaseModel):
|
||||
"""纪念日引用(从持久层取出后传入快照计算的精简形态)。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
calendar: str # solar / lunar
|
||||
date_iso: str # 公历 ISO;农历在集成层先转为公历再传入(或这里仅支持公历命中判定)
|
||||
recurs_yearly: bool
|
||||
kind: str
|
||||
|
||||
|
||||
class MilestonePreview(BaseModel):
|
||||
"""下一个特殊日子预览。"""
|
||||
|
||||
anniversary_id: str
|
||||
name: str
|
||||
date_iso: str
|
||||
days_until: int
|
||||
|
||||
|
||||
class TemporalSnapshot(BaseModel):
|
||||
"""时态快照——辰正的核心只读对象。"""
|
||||
|
||||
now_utc: str
|
||||
local_time: str
|
||||
timezone: str
|
||||
local_hour: int
|
||||
shichen: str
|
||||
day_period: str
|
||||
weekday: int
|
||||
is_weekend: bool
|
||||
solar_date: str
|
||||
lunar: LunarDate | None
|
||||
season: str
|
||||
solar_term: SolarTermInfo | None
|
||||
festivals: list[Festival]
|
||||
acquaintance: AcquaintanceDuration
|
||||
offline: OfflineDuration
|
||||
next_milestone: MilestonePreview | None
|
||||
active_milestones: list[AnniversaryRef]
|
||||
home_tz_diff_minutes: int
|
||||
location: LocationContext | None
|
||||
|
||||
|
||||
class SnapshotInput(BaseModel):
|
||||
"""compute_snapshot 的入参,全部以值传入,确保纯函数。"""
|
||||
|
||||
now: datetime
|
||||
timezone: str
|
||||
location: LocationContext | None = None
|
||||
anniversaries: list[AnniversaryRef] = []
|
||||
acquaintance_anchor: datetime
|
||||
last_active_at: datetime
|
||||
home_timezone: str = "Asia/Shanghai"
|
||||
offline_thresholds: OfflineThresholds = OfflineThresholds()
|
||||
|
||||
|
||||
def _season_for(northern: bool, month: int) -> str:
|
||||
"""按月份返回季节,南北半球相反。"""
|
||||
seasons_north = ["冬", "冬", "春", "春", "春", "夏", "夏", "夏", "秋", "秋", "秋", "冬"]
|
||||
season = seasons_north[month - 1]
|
||||
if northern:
|
||||
return season
|
||||
flip = {"春": "秋", "夏": "冬", "秋": "春", "冬": "夏"}
|
||||
return flip[season]
|
||||
|
||||
|
||||
def _tz_offset_minutes(tz_name: str, at: datetime) -> int:
|
||||
"""返回 tz_name 在 at 时刻相对 UTC 的偏移(分钟)。"""
|
||||
try:
|
||||
localized = at.astimezone(ZoneInfo(tz_name))
|
||||
except Exception:
|
||||
return 0
|
||||
offset = localized.utcoffset()
|
||||
return int(offset.total_seconds() // 60) if offset is not None else 0
|
||||
|
||||
|
||||
def _safe_replace_year(d: date, year: int) -> date:
|
||||
"""replace(year=...) 的安全版:2/29 在非闰年回退到 2/28。"""
|
||||
try:
|
||||
return d.replace(year=year)
|
||||
except ValueError:
|
||||
return d.replace(year=year, day=28)
|
||||
|
||||
|
||||
def _is_northern(city: str | None) -> bool:
|
||||
"""粗判城市是否北半球(v1 简化:默认北半球;南半球已知城市查表)。"""
|
||||
southern = {"悉尼", "墨尔本", "奥克兰", "惠灵顿", "约翰内斯堡", "布宜诺斯艾利斯", "圣地亚哥"}
|
||||
return not (city and city in southern)
|
||||
|
||||
|
||||
def _next_milestone(
|
||||
anniversaries: list[AnniversaryRef], today: date
|
||||
) -> tuple[MilestonePreview | None, list[AnniversaryRef]]:
|
||||
"""返回(下一个纪念日预览,今日命中列表)。"""
|
||||
active: list[AnniversaryRef] = []
|
||||
upcoming: list[tuple[int, AnniversaryRef, date]] = []
|
||||
for anni in anniversaries:
|
||||
try:
|
||||
anni_date = date.fromisoformat(anni.date_iso[:10])
|
||||
except ValueError:
|
||||
continue
|
||||
# 今日命中:月日相同(公历)
|
||||
if anni.recurs_yearly:
|
||||
hit_this_year = _safe_replace_year(anni_date, today.year)
|
||||
if hit_this_year == today:
|
||||
active.append(anni)
|
||||
next_occurrence = hit_this_year + timedelta(days=365)
|
||||
upcoming.append(((next_occurrence - today).days, anni, next_occurrence))
|
||||
elif hit_this_year > today:
|
||||
upcoming.append(((hit_this_year - today).days, anni, hit_this_year))
|
||||
else:
|
||||
next_occurrence = _safe_replace_year(anni_date, today.year + 1)
|
||||
upcoming.append(((next_occurrence - today).days, anni, next_occurrence))
|
||||
else:
|
||||
if anni_date == today:
|
||||
active.append(anni)
|
||||
elif anni_date > today:
|
||||
upcoming.append(((anni_date - today).days, anni, anni_date))
|
||||
|
||||
upcoming.sort()
|
||||
next_preview = None
|
||||
if upcoming:
|
||||
days_until, anni, d = upcoming[0]
|
||||
next_preview = MilestonePreview(
|
||||
anniversary_id=anni.id, name=anni.name, date_iso=d.isoformat(), days_until=days_until
|
||||
)
|
||||
return next_preview, active
|
||||
|
||||
|
||||
def compute_snapshot(input: SnapshotInput) -> TemporalSnapshot:
|
||||
"""组装时态快照。纯函数:给定输入确定地算出全部字段。"""
|
||||
# 本地时间
|
||||
try:
|
||||
local_dt = input.now.astimezone(ZoneInfo(input.timezone))
|
||||
except Exception:
|
||||
local_dt = input.now # 时区非法时降级为 UTC
|
||||
|
||||
local_hour = local_dt.hour
|
||||
today = local_dt.date()
|
||||
|
||||
# 农历/节气/节日
|
||||
try:
|
||||
lunar = solar_to_lunar(today)
|
||||
except Exception:
|
||||
lunar = None
|
||||
try:
|
||||
solar_term = get_solar_term(today)
|
||||
except Exception:
|
||||
solar_term = None
|
||||
try:
|
||||
festivals = get_festivals(
|
||||
today, locale=input.location.locale if input.location else "zh-CN"
|
||||
)
|
||||
except Exception:
|
||||
festivals = []
|
||||
|
||||
# 时辰与时段
|
||||
shichen = get_shichen(local_hour).name
|
||||
day_period = get_day_period(local_hour)
|
||||
|
||||
# 认识时长与离线
|
||||
acquaintance = compute_acquaintance_duration(input.acquaintance_anchor, input.now)
|
||||
offline = classify_offline_band(input.last_active_at, input.now, input.offline_thresholds)
|
||||
|
||||
# 纪念日
|
||||
next_milestone, active_milestones = _next_milestone(input.anniversaries, today)
|
||||
|
||||
# 季节
|
||||
northern = _is_northern(input.location.city if input.location else None)
|
||||
season = _season_for(northern, today.month)
|
||||
|
||||
# 时差
|
||||
home_offset = _tz_offset_minutes(input.home_timezone, input.now)
|
||||
user_offset = _tz_offset_minutes(input.timezone, input.now)
|
||||
home_tz_diff = (
|
||||
user_offset - home_offset
|
||||
) # 用户时区减家时区,东向为正(如家=上海、用户=东京,diff=+60)
|
||||
|
||||
return TemporalSnapshot(
|
||||
now_utc=input.now.astimezone(UTC).isoformat(),
|
||||
local_time=local_dt.isoformat(),
|
||||
timezone=input.timezone,
|
||||
local_hour=local_hour,
|
||||
shichen=shichen,
|
||||
day_period=day_period,
|
||||
weekday=today.weekday(), # 0=周一
|
||||
is_weekend=today.weekday() >= 5,
|
||||
solar_date=today.isoformat(),
|
||||
lunar=lunar,
|
||||
season=season,
|
||||
solar_term=solar_term,
|
||||
festivals=festivals,
|
||||
acquaintance=acquaintance,
|
||||
offline=offline,
|
||||
next_milestone=next_milestone,
|
||||
active_milestones=active_milestones,
|
||||
home_tz_diff_minutes=home_tz_diff,
|
||||
location=input.location,
|
||||
)
|
||||
Reference in new issue
Block a user