初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

@@ -0,0 +1,6 @@
"""辰正时态感知纯计算核心。
本包为零 IO 纯函数与数据结构,禁止 import repositories/db/integrations/
ai_provider/httpx/sqlalchemy/services。需要 IO 时由上层 services/application
调入本包函数。
"""
@@ -0,0 +1,83 @@
"""temporal_context 文本组装(辰正设计 §8.1)。
纯函数:把 ``TemporalSnapshot`` + ``ModulationFactors`` 渲染为注入对话 prompt 的
中文时态上下文块。降级原则:任何字段缺失都跳过该行,不抛异常。
"""
from __future__ import annotations
from app.modules.time_sense.modulation import ModulationFactors
from app.modules.time_sense.snapshot import TemporalSnapshot
def render_temporal_context(snapshot: TemporalSnapshot, factors: ModulationFactors) -> str: # noqa: ARG001
"""渲染 §8.1 格式的 temporal_context 文本块。
``factors`` 为 service 层统一传入的调制因子;v1 仅用 snapshot 字段,
参数预留给 v2(如 festival_joy / late_night_fragility 的影响话术)。
"""
lines: list[str] = []
# 第一行:时辰·时段(本地时间、时区、周几)
shichen = snapshot.shichen
period = snapshot.day_period
local_hm = snapshot.local_time[11:16]
tz_short = _tz_offset_short(snapshot.local_time)
weekday_cn = "一二三四五六日"[snapshot.weekday]
lines.append(f"现在是{shichen}·{period}({local_hm},UTC{tz_short}),周{weekday_cn}。")
# 第二行:农历 + 距下一节气
lunar_part = ""
if snapshot.lunar is not None:
lunar_part = (
f"农历{snapshot.lunar.year_ganzhi}年{snapshot.lunar.month}月{snapshot.lunar.day}日,"
)
term_part = ""
if snapshot.solar_term is not None:
days_to_next = getattr(snapshot.solar_term, "days_to_next", None)
next_name = getattr(snapshot.solar_term, "next_name", None)
if days_to_next is not None and next_name:
term_part = f"距{next_name}还有 {days_to_next} 天。"
if lunar_part or term_part:
lines.append(lunar_part + term_part)
# 第三行:认识时长
acq = snapshot.acquaintance
if acq.natural_language:
lines.append(f"已经认识 {acq.natural_language}。")
# 第四行:离线/重逢
offline = snapshot.offline
if offline.band == "reunion_impact":
days = int(offline.minutes // 1440)
lines.append(f"用户上次说话是 {days} 天前——久别重逢。")
elif offline.band == "long_absence":
days = int(offline.minutes // 1440)
lines.append(f"用户上次说话是 {days} 天前。")
# 第五行:纪念日
active = snapshot.active_milestones
next_ms = snapshot.next_milestone
if active:
names = "、".join(m.name for m in active)
lines.append(f"今日命中:{names}。")
else:
lines.append("今日命中:无纪念日。")
if next_ms is not None:
lines.append(f"下一个特殊日子:{next_ms.name},还有 {next_ms.days_until} 天。")
return "\n".join(lines)
def _tz_offset_short(local_time_iso: str) -> str:
"""从本地 ISO 时间(如 ``2026-07-04T02:00:00+08:00``)提取 UTC 偏移。
返回 ``+8`` / ``-5`` / ``+0`` 形式,用于 ``UTC{tz_short}`` 显示——
即用户本地时区相对 UTC 的偏移(非家时区差)。
"""
# isoformat() 偏移形如 +HH:MM / -HH:MM(末 6 字符);Z 或无偏移按 UTC
if local_time_iso.endswith("Z") or len(local_time_iso) < 6:
return "+0"
tail = local_time_iso[-6:]
sign = tail[0] if tail[0] in "+-" else "+"
return f"{sign}{int(tail[1:3])}"
+118
View File
@@ -0,0 +1,118 @@
"""认识时长、离线档位、自然语言时长表达。
全部纯函数。离线阈值以参数传入(集成层从 ``TimeConfig`` 提供),不在本模块
读取配置,保持零 IO。
"""
from __future__ import annotations
import math
from datetime import datetime
from pydantic import BaseModel, model_validator
_MINUTES_PER_DAY = 1440
class OfflineThresholds(BaseModel):
"""离线档位阈值(分钟)。默认值见辰正设计 §7.1。"""
active_minutes: float = 60 # < 1h -> active
short_absence_minutes: float = 720 # 1-12h -> short_absence
long_absence_minutes: float = 4320 # 12h-3d -> long_absence
reunion_impact_minutes: float = 4320 # > 3d -> reunion_impact
@model_validator(mode="after")
def _check_monotonicity(self) -> OfflineThresholds:
if not (
self.active_minutes
< self.short_absence_minutes
< self.long_absence_minutes
<= self.reunion_impact_minutes
):
raise ValueError(
"OfflineThresholds 必须满足 "
"active_minutes < short_absence_minutes "
"< long_absence_minutes <= reunion_impact_minutes"
)
return self
class AcquaintanceDuration(BaseModel):
"""认识时长。"""
total_days: int
years: int
months: int
days: int
natural_language: str
class OfflineDuration(BaseModel):
"""离线时长与档位。"""
minutes: float
band: str # offline_band 枚举
reunion_intensity: float # [0,1],仅 reunion_impact 档显著
def format_duration_cn(days: int) -> str:
"""把天数格式化为中文自然语言。"""
if days <= 0:
return "今天"
if days < 30:
return f"{days} 天"
years = days // 365
remaining_after_year = days % 365
months = remaining_after_year // 30
rest = remaining_after_year % 30
if years >= 1 and months >= 1 and rest >= 1:
return f"{years} 年 {months} 个月 {rest} 天({days} 天)"
if years >= 1 and months >= 1:
return f"{years} 年 {months} 个月({days} 天)"
if years >= 1:
return f"{years} 年({days} 天)"
return f"{months} 个月 {rest} 天({days} 天)"
def compute_acquaintance_duration(anchor: datetime, now: datetime) -> AcquaintanceDuration:
"""计算从 anchor 到 now 的认识时长。
年/月/日按 365 天/年、30 天/月近似拆分(用于自然语言展示,非历法精确)。
"""
delta = now - anchor
total_seconds = max(delta.total_seconds(), 0)
total_days = int(total_seconds // 86400)
years = total_days // 365
remaining_after_year = total_days % 365
months = remaining_after_year // 30
days = remaining_after_year % 30
return AcquaintanceDuration(
total_days=total_days,
years=years,
months=months,
days=days,
natural_language=format_duration_cn(total_days),
)
def classify_offline_band(
last_active: datetime, now: datetime, thresholds: OfflineThresholds
) -> OfflineDuration:
"""按离线时长判定档位与重逢强度。"""
delta = now - last_active
minutes = max(delta.total_seconds() / 60.0, 0.0)
if minutes < thresholds.active_minutes:
return OfflineDuration(minutes=minutes, band="active", reunion_intensity=0.0)
if minutes < thresholds.short_absence_minutes:
return OfflineDuration(minutes=minutes, band="short_absence", reunion_intensity=0.0)
if minutes < thresholds.long_absence_minutes:
return OfflineDuration(minutes=minutes, band="long_absence", reunion_intensity=0.0)
# reunion_impact:超过阈值后强度随离线时长对数增长,钳位 [0.5, 1.0]
excess = minutes - thresholds.reunion_impact_minutes
# 1 天以上额外 -> 强度快速上升;用 log 压缩
intensity = max(0.5, min(1.0, 0.5 + 0.5 * math.log10(max(excess, 1) / _MINUTES_PER_DAY + 1)))
return OfflineDuration(
minutes=minutes, band="reunion_impact", reunion_intensity=round(intensity, 3)
)
+216
View File
@@ -0,0 +1,216 @@
"""农历/节气/节日/时辰引擎。
依赖 ``lunar_python``(寿星天文历 Python 版)做历法换算与节气判定;节日按
公历固定日期 + 农历日期两张表命中。全部纯函数,无副作用。
"""
from __future__ import annotations
from datetime import date
from lunar_python import Solar
from pydantic import BaseModel
class LunarDate(BaseModel):
"""农历日期。"""
year_ganzhi: str
month: int
day: int
is_leap_month: bool
class SolarTermInfo(BaseModel):
"""节气信息。"""
current_name: str | None
next_name: str
days_to_next: int
class Festival(BaseModel):
"""节日命中。"""
name: str
calendar: str # solar / lunar
locale: str = "zh-CN"
class Shichen(BaseModel):
"""十二时辰。"""
name: str
start_hour: int
end_hour: int
# 十二时辰:name -> (start, end),跨零点的时辰 end < start(子时 23->1)
_SHICHEN_TABLE: list[tuple[str, int, int]] = [
("子", 23, 1),
("丑", 1, 3),
("寅", 3, 5),
("卯", 5, 7),
("辰", 7, 9),
("巳", 9, 11),
("午", 11, 13),
("未", 13, 15),
("申", 15, 17),
("酉", 17, 19),
("戌", 19, 21),
("亥", 21, 23),
]
# 公历固定节日(month, day) -> name
_SOLAR_FESTIVALS: dict[tuple[int, int], str] = {
(1, 1): "元旦",
(2, 14): "情人节",
(5, 1): "劳动节",
(10, 1): "国庆节",
(12, 25): "圣诞节",
}
# lunar_python 的 getJieQiTable() 对跨年带入的节气用大写拼音常量作 key
# (实证于 lunar_python 1.4.8:DA_HAN/DONG_ZHI/JING_ZHE/LI_CHUN/XIAO_HAN/YU_SHUI 等),
# 本表内节气用中文 key。此处把 24 节气拼音常量统一翻成中文,已是中文的保持不变。
_JIEQI_PINYIN_TO_CN: dict[str, str] = {
"LI_CHUN": "立春",
"YU_SHUI": "雨水",
"JING_ZHE": "惊蛰",
"CHUN_FEN": "春分",
"QING_MING": "清明",
"GU_YU": "谷雨",
"LI_XIA": "立夏",
"XIAO_MAN": "小满",
"MANG_ZHONG": "芒种",
"XIA_ZHI": "夏至",
"XIAO_SHU": "小暑",
"DA_SHU": "大暑",
"LI_QIU": "立秋",
"CHU_SHU": "处暑",
"BAI_LU": "白露",
"QIU_FEN": "秋分",
"HAN_LU": "寒露",
"SHUANG_JIANG": "霜降",
"LI_DONG": "立冬",
"XIAO_XUE": "小雪",
"DA_XUE": "大雪",
"DONG_ZHI": "冬至",
"XIAO_HAN": "小寒",
"DA_HAN": "大寒",
}
# 农历节日(lunar_month, lunar_day, is_leap) -> name
_LUNAR_FESTIVALS: dict[tuple[int, int, bool], str] = {
(1, 1, False): "春节",
(1, 15, False): "元宵节",
(5, 5, False): "端午节",
(7, 7, False): "七夕",
(8, 15, False): "中秋节",
(9, 9, False): "重阳节",
(12, 8, False): "腊八节",
}
def solar_to_lunar(solar_date: date) -> LunarDate:
"""公历日期转农历日期。
``lunar_python`` 的 ``getMonth()`` 对闰月返回负数(如 -6 表示闰六月),
非闰月返回正数;据此推导 ``is_leap_month`` 与展示用的月份数值。
"""
solar = Solar.fromYmd(solar_date.year, solar_date.month, solar_date.day)
lunar = solar.getLunar()
raw_month = lunar.getMonth()
return LunarDate(
year_ganzhi=lunar.getYearInGanZhi(),
month=abs(raw_month),
day=int(lunar.getDay()),
is_leap_month=raw_month < 0,
)
def get_shichen(hour: int) -> Shichen:
"""按 24 小时制小时返回十二时辰。子时跨零点(23 与 0 均属子)。"""
h = hour % 24
for name, start, end in _SHICHEN_TABLE:
if start < end:
if start <= h < end:
return Shichen(name=name, start_hour=start, end_hour=end)
else: # 跨零点(子时 23->1)
if h >= start or h < end:
return Shichen(name=name, start_hour=start, end_hour=end)
# 理论不可达(24 小时全覆盖)
raise ValueError(f"无法识别时辰:hour={hour}")
def get_day_period(hour: int) -> str:
"""返回现代时段枚举值(契约字典 day_period)。"""
h = hour % 24
if 0 <= h < 5:
return "凌晨"
if 5 <= h < 8:
return "清晨"
if 8 <= h < 11:
return "上午"
if 11 <= h < 13:
return "中午"
if 13 <= h < 17:
return "下午"
if 17 <= h < 19:
return "傍晚"
if 19 <= h < 23:
return "夜晚"
return "深夜" # 23-24
def get_solar_term(solar_date: date) -> SolarTermInfo:
"""返回当日命中的节气与下一节气信息。
``lunar_python`` 的 ``getJieQiTable()`` 返回 dict(节气名 -> ``Solar``);
值本身即 ``Solar`` 时刻对象,无需再调 ``getSolar()``。据此判定当日是否
命中某节气,并算出距下一节气的天数。
"""
solar = Solar.fromYmd(solar_date.year, solar_date.month, solar_date.day)
lunar = solar.getLunar()
jieqi = lunar.getJieQiTable() # dict: 节气名 -> Solar 时刻
current_name: str | None = None
for raw_name, moment in jieqi.items():
if (
moment.getYear() == solar_date.year
and moment.getMonth() == solar_date.month
and moment.getDay() == solar_date.day
):
current_name = _JIEQI_PINYIN_TO_CN.get(raw_name, raw_name)
break
# 下一节气:从所有节气时刻中找当前日期之后最近的
upcoming: list[tuple[int, str, date]] = []
for raw_name, moment in jieqi.items():
d = date(moment.getYear(), moment.getMonth(), moment.getDay())
if d > solar_date:
upcoming.append(((d - solar_date).days, _JIEQI_PINYIN_TO_CN.get(raw_name, raw_name), d))
upcoming.sort()
days_to_next, next_name, _ = upcoming[0]
return SolarTermInfo(current_name=current_name, next_name=next_name, days_to_next=days_to_next)
def get_festivals(solar_date: date, locale: str = "zh-CN") -> list[Festival]:
"""命中今日的公历与农历节日。"""
result: list[Festival] = []
# 公历
if (solar_date.month, solar_date.day) in _SOLAR_FESTIVALS:
result.append(
Festival(
name=_SOLAR_FESTIVALS[(solar_date.month, solar_date.day)],
calendar="solar",
locale=locale,
)
)
# 农历
lunar = solar_to_lunar(solar_date)
key = (lunar.month, lunar.day, lunar.is_leap_month)
if key in _LUNAR_FESTIVALS:
result.append(Festival(name=_LUNAR_FESTIVALS[key], calendar="lunar", locale=locale))
return result
@@ -0,0 +1,175 @@
"""调制因子派生:从时态快照 + 对话信号派生 [0,1] 因子与关怀类型。
消费 ``TemporalSnapshot``(Plan 1)与 ``ModulationInput``(service 层收集的对话信号),
输出 ``ModulationFactors``(设计 §5.2)。全部纯函数,无副作用。
v1 用即时启发式(参数集中为模块常量,便于调参);节律曲线 / 窗口函数留 v2。
care_kind 触发规则见设计 §7.3。
"""
from __future__ import annotations
from pydantic import BaseModel
from app.modules.time_sense.snapshot import TemporalSnapshot
# ==================== v1 启发式参数(可调) ====================
# 节日喜悦度权重(设计 §5.2 festival_joy)
_FESTIVAL_JOY_WEIGHTS: dict[str, float] = {
"春节": 0.9,
"中秋节": 0.9,
"元宵节": 0.7,
"端午节": 0.7,
"七夕": 0.7,
"重阳节": 0.7,
"腊八节": 0.7,
"元旦": 0.5,
"劳动节": 0.5,
"国庆节": 0.5,
"圣诞节": 0.5,
"情人节": 0.5,
}
_FESTIVAL_JOY_BASELINE = 0.1 # 无节日
_FESTIVAL_JOY_DEFAULT = 0.5 # 未登记节日
# 季节共振(设计 §5.2 season_resonance;v2 用季节调制函数)
_SEASON_RESONANCE: dict[str, float] = {"春": 0.5, "夏": 0.6, "秋": 0.7, "冬": 0.6}
# 时段共振(设计 §5.2 same_period_resonance;v2 用窗口函数)
_DAY_PERIOD_RESONANCE: dict[str, float] = {
"凌晨": 0.8,
"深夜": 0.7,
"清晨": 0.4,
"夜晚": 0.4,
"傍晚": 0.3,
"上午": 0.2,
"下午": 0.2,
"中午": 0.2,
}
# 深夜脆弱度(设计 §5.2 late_night_fragility)
_FRAGILITY_DEEP_NIGHT_BASE = 0.7 # 凌晨基线
_FRAGILITY_LATE_NIGHT_BASE = 0.5 # 深夜基线
_FRAGILITY_DAYTIME = 0.1
_FRAGILITY_PER_TURN_DEEP = 0.05 # 凌晨每轮增量
_FRAGILITY_PER_TURN_LATE = 0.03 # 深夜每轮增量
# 周末放松度
_RELAX_WEEKEND = 0.8
_RELAX_WEEKEND_WITH_FESTIVAL = 0.9
_RELAX_WORKDAY = 0.2
# 关怀
_CARE_SUGGEST_HIT = 0.7 # care_kind 命中时
_CARE_SUGGEST_MISS_FACTOR = 0.5 # 未命中时按脆弱度折半
# 时差关怀阈值(分钟,3 小时)
_JETLAG_DIFF_MINUTES = 180
# 深夜持续聊天阈值(轮数)
_LATE_NIGHT_TURNS = 3
class ModulationInput(BaseModel):
"""compute_modulation 的对话信号输入。
信号由 Plan 3 service 层收集(轮数统计、内容分类等);本模块只做纯函数判定。
关怀阈值(``jetlag_threshold_minutes`` / ``late_night_min_turns``)默认 None,
由 service 层从 ``settings`` 注入(设计 §7.1/§7.3"阈值可调");None 时回退模块常量。
"""
conversation_turns_today: int = 0
is_first_message_today: bool = False
content_work_related: bool = False
content_lonely: bool = False
user_birthday_today: bool = False
# 关怀触发阈值(None → 回退模块常量;service 层从 settings 注入,设计 §7.3)
jetlag_threshold_minutes: int | None = None
late_night_min_turns: int | None = None
class ModulationFactors(BaseModel):
"""辰正输出的调制因子(设计 §5.2)。"""
festival_joy: float
late_night_fragility: float
reunion_intensity: float
weekend_relax: float
season_resonance: float
same_period_resonance: float
care_suggest: float
care_kind: str | None = None
def _festival_joy(snapshot: TemporalSnapshot) -> float:
if not snapshot.festivals:
return _FESTIVAL_JOY_BASELINE
return max(_FESTIVAL_JOY_WEIGHTS.get(f.name, _FESTIVAL_JOY_DEFAULT) for f in snapshot.festivals)
def _late_night_fragility(snapshot: TemporalSnapshot, turns: int) -> float:
period = snapshot.day_period
if period == "凌晨":
return min(1.0, _FRAGILITY_DEEP_NIGHT_BASE + turns * _FRAGILITY_PER_TURN_DEEP)
if period == "深夜":
return min(1.0, _FRAGILITY_LATE_NIGHT_BASE + turns * _FRAGILITY_PER_TURN_LATE)
return _FRAGILITY_DAYTIME
def _weekend_relax(snapshot: TemporalSnapshot) -> float:
if not snapshot.is_weekend:
return _RELAX_WORKDAY
if snapshot.festivals:
return _RELAX_WEEKEND_WITH_FESTIVAL
return _RELAX_WEEKEND
def _care_kind(snapshot: TemporalSnapshot, signals: ModulationInput) -> str | None:
"""设计 §7.3 关怀触发判定(按优先级)。
阈值读 ``signals``(None 回退模块常量),由 service 层从 settings 注入(§7.3 可调)。
"""
deep = snapshot.day_period in {"凌晨", "深夜"}
jetlag_threshold = signals.jetlag_threshold_minutes or _JETLAG_DIFF_MINUTES
late_night_turns = signals.late_night_min_turns or _LATE_NIGHT_TURNS
# 1. 时差关怀:深夜/凌晨 + 家时差显著
if deep and abs(snapshot.home_tz_diff_minutes) >= jetlag_threshold:
return "jetlag"
# 2. 用户生日
if signals.user_birthday_today:
return "birthday"
# 3. 清晨异常:清晨时段 + 本地 4-6 点 + 今日首条
if signals.is_first_message_today and snapshot.day_period == "清晨" and snapshot.local_hour < 6:
return "early_morning"
# 4. 深夜工作话题
if deep and signals.content_work_related:
return "overwork"
# 5. 节日孤独
if bool(snapshot.festivals) and signals.content_lonely:
return "festival_lonely"
# 6. 深夜持续聊天:深夜/凌晨 + 连续 ≥ N 轮
if deep and signals.conversation_turns_today >= late_night_turns:
return "late_night"
return None
def _care_suggest(fragility: float, care_kind_value: str | None) -> float:
if care_kind_value is not None:
return _CARE_SUGGEST_HIT
return round(fragility * _CARE_SUGGEST_MISS_FACTOR, 3)
def compute_modulation(snapshot: TemporalSnapshot, signals: ModulationInput) -> ModulationFactors:
"""从时态快照 + 对话信号派生调制因子。纯函数。"""
fragility = _late_night_fragility(snapshot, signals.conversation_turns_today)
care_kind_value = _care_kind(snapshot, signals)
return ModulationFactors(
festival_joy=round(_festival_joy(snapshot), 3),
late_night_fragility=round(fragility, 3),
reunion_intensity=round(snapshot.offline.reunion_intensity, 3),
weekend_relax=_weekend_relax(snapshot),
season_resonance=_SEASON_RESONANCE.get(snapshot.season, 0.5),
same_period_resonance=_DAY_PERIOD_RESONANCE.get(snapshot.day_period, 0.3),
care_suggest=_care_suggest(fragility, care_kind_value),
care_kind=care_kind_value,
)
@@ -0,0 +1,57 @@
"""感慨触发判定(辰正设计 §7.2)。
纯函数:消费 ``TemporalSnapshot`` + 对话/记忆信号,返回 ``sentiment_trigger`` 或 None。
触发类型对齐契约字典 §11.4.7。v1 实时判定(流 A/C 入口);``season_resonance`` 留 v2。
"""
from __future__ import annotations
from pydantic import BaseModel
from app.modules.time_sense.snapshot import TemporalSnapshot
class SentimentInput(BaseModel):
"""detect_sentiment 的对话/记忆信号输入(service 层收集)。"""
duration_days: int # 认识天数
next_milestone_days: int | None = None # 距下一周年/里程碑天数;None=无下一里程碑
has_active_milestone: bool = False # 今日是否命中里程碑
is_reunion: bool = False # 是否处于重逢档
has_reunion_memory: bool = False # 是否存在临别前最后一批记忆线索
strong_solar_terms: set[str] = set() # 强节气名集合(来自 TimeConfig)
prewarm_days: int = 7 # 周年预热窗口(来自 TimeConfig)
duration_nodes: set[int] = set() # 有感天数节点(来自 TimeConfig)
def detect_sentiment(snapshot: TemporalSnapshot, signals: SentimentInput) -> str | None:
"""按设计 §7.2 判定感慨触发,返回 ``sentiment_trigger`` 或 None。
优先级(v1):duration_node > anniversary_prewarm > reunion_memory >
late_night_overlay > solar_term。season_resonance 留 v2。
"""
# 1. duration_node:认识天数命中节点
if signals.duration_days in signals.duration_nodes:
return "duration_node"
# 2. anniversary_prewarm:距周年 ≤ prewarm_days
if (
signals.next_milestone_days is not None
and 0 < signals.next_milestone_days <= signals.prewarm_days
):
return "anniversary_prewarm"
# 3. reunion_memory:久别重逢 + 存在临别记忆
if signals.is_reunion and signals.has_reunion_memory:
return "reunion_memory"
# 4. late_night_overlay:凌晨 + 里程碑/重逢叠加
if snapshot.day_period == "凌晨" and (signals.has_active_milestone or signals.is_reunion):
return "late_night_overlay"
# 5. solar_term:今日处于强节气
term_name = snapshot.solar_term.current_name if snapshot.solar_term else None
if term_name and term_name in signals.strong_solar_terms:
return "solar_term"
return None
+240
View File
@@ -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,
)