初始化提交
This commit is contained in:
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])}"
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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