Files
hunjing/backend/app/services/time_sense.py
T
2026-08-21 16:00:13 +08:00

391 lines
14 KiB
Python
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.
"""辰正时态服务编排(设计 §6 流 A + 流 C)。
职责:
- 流 A:拉数据 → compute_snapshot + compute_modulation → 组 temporal_context 文本
- 流 C:检测 care / sentiment / reunion → 发 time.* 事件到 EventStore
降级原则(设计 §12):任何计算失败返回最小结果,不阻塞对话。
本 plan 不含流 B(后台 tick)——依赖未实现的 background_task 基础设施。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage
from app.models.time_anniversary import TimeAnniversary
from app.modules.runtime.events import Event, EventStore
from app.modules.time_sense.context_text import render_temporal_context
from app.modules.time_sense.duration import AcquaintanceDuration, OfflineThresholds
from app.modules.time_sense.modulation import ModulationFactors, ModulationInput, compute_modulation
from app.modules.time_sense.sentiment import SentimentInput, detect_sentiment
from app.modules.time_sense.snapshot import (
AnniversaryRef,
LocationContext,
SnapshotInput,
TemporalSnapshot,
compute_snapshot,
)
from app.repositories.time_anniversaries import (
create_anniversary,
delete_anniversary,
get_anniversary,
list_anniversaries,
update_anniversary,
)
from app.schemas.time import AnniversaryCreateRequest, AnniversaryUpdateRequest
from app.services.characters import get_active_character_model
class LiveSnapshotResult(BaseModel):
"""compute_live_snapshot 的返回。"""
snapshot: TemporalSnapshot | None = None
factors: ModulationFactors | None = None
context_text: str = ""
emitted_events: list[Event] = Field(default_factory=list)
async def emit_time_event(
store: EventStore,
event_type: str,
character_id: str,
payload: dict[str, Any],
*,
run_id: str | None = None,
source: str = "time_sense",
sensitivity: str = "low",
) -> Event:
"""构造并 append 一个 time.* 事件。"""
event = Event(
type=event_type,
source=source,
payload=payload,
character_id=character_id,
run_id=run_id,
sensitivity=sensitivity,
)
return await store.append(event)
def _thresholds_from_config() -> OfflineThresholds:
return OfflineThresholds(
active_minutes=settings.offline_active_minutes,
short_absence_minutes=settings.offline_short_absence_minutes,
long_absence_minutes=settings.offline_long_absence_minutes,
reunion_impact_minutes=settings.offline_reunion_impact_minutes,
)
async def _resolve_last_active(
session: AsyncSession, character_id: str, fallback: datetime
) -> datetime:
"""查 chat_messages 最新一条的 created_at;无记录返回 fallback。
DB 异常向上冒泡,由 ``compute_live_snapshot`` 降级处理(设计 §12)。
"""
result = await session.execute(
select(ChatMessage.created_at)
.where(ChatMessage.character_id == character_id)
.order_by(ChatMessage.created_at.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return row if row is not None else fallback
def _anniversary_refs(rows: list[TimeAnniversary]) -> list[AnniversaryRef]:
"""TimeAnniversary ORM → AnniversaryRef(snapshot 输入)。"""
refs: list[AnniversaryRef] = []
for a in rows:
refs.append(
AnniversaryRef(
id=a.id,
name=a.name,
calendar=a.calendar,
date_iso=a.date,
recurs_yearly=a.recurs_yearly,
kind=a.kind,
)
)
return refs
async def compute_live_snapshot(
session: AsyncSession,
store: EventStore,
character_id: str, # noqa: ARG001 v1 单人物:忽略传入 id,使用当前 active 角色(设计已注明)
signals: ModulationInput,
*,
now: datetime | None = None,
timezone: str | None = None,
run_id: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> LiveSnapshotResult:
"""流 A + 流 C:算快照/因子/上下文 + 触发 time.* 事件。
任何异常降级返回最小结果(context_text 含 now_utc,因子/事件空),不抛。
"""
now = now or utc_now()
emitted: list[Event] = []
try:
character = await get_active_character_model(session, owner_scope=owner_scope)
# v1:character_id 应与 active 一致;若不一致仍用 active(v1 单人物)
anniv_rows = await list_anniversaries(session, character.id)
anniv_refs = _anniversary_refs(anniv_rows)
anchor = character.activated_at or character.created_at
# SQLite 的 DateTime(timezone=True) 仍按 naive 读回(Postgres 不受影响);
# 此处与 serializers.py 同样的防御式补 tzinfo,避免与 aware now 做差时 TypeError。
if anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=UTC)
last_active = await _resolve_last_active(session, character.id, fallback=now)
if last_active.tzinfo is None:
last_active = last_active.replace(tzinfo=UTC)
location = LocationContext(
timezone=timezone or settings.user_timezone,
city=settings.default_city,
locale=settings.default_locale,
)
inp = SnapshotInput(
now=now,
timezone=timezone or settings.user_timezone,
location=location,
anniversaries=anniv_refs,
acquaintance_anchor=anchor,
last_active_at=last_active,
home_timezone=settings.home_timezone,
offline_thresholds=_thresholds_from_config(),
)
# v1 阈值可调(设计 §7.3):ModulationInput 阈值默认 None,此处从 settings 注入;
# 保持 modules/time_sense 纯函数(不读 settings),测试可显式传值覆盖。
if signals.jetlag_threshold_minutes is None:
signals = signals.model_copy(
update={"jetlag_threshold_minutes": settings.jetlag_threshold_minutes}
)
if signals.late_night_min_turns is None:
signals = signals.model_copy(
update={"late_night_min_turns": settings.late_night_min_turns}
)
snapshot = compute_snapshot(inp)
factors = compute_modulation(snapshot, signals)
context_text = render_temporal_context(snapshot, factors)
# 流 C 事件触发(Task 4 在此扩展)
await _trigger_events(
store,
snapshot,
factors,
signals,
character.id,
emitted,
anchor,
run_id=run_id,
)
return LiveSnapshotResult(
snapshot=snapshot,
factors=factors,
context_text=context_text,
emitted_events=emitted,
)
except Exception:
# 降级:最小快照(设计 §12)——记日志便于排查,辰正永不阻塞对话
logger.exception("辰正 compute_live_snapshot 降级 character_id={}", character_id)
return LiveSnapshotResult(
snapshot=None,
factors=None,
context_text=f"[时态上下文暂不可用] 当前 UTC:{now.astimezone(UTC).isoformat()}",
emitted_events=emitted,
)
async def _trigger_events(
store: EventStore,
snapshot: TemporalSnapshot,
factors: ModulationFactors,
signals: ModulationInput, # noqa: ARG001 v1 仅 care 用 factors;signals 留 v2 内容信号扩展
character_id: str,
emitted: list[Event],
anchor: datetime, # noqa: ARG001 留 v2 认识天数/里程碑锚点事件
run_id: str | None = None,
) -> None:
"""流 C:care / sentiment / reunion 检测 + emit。"""
# 1. 关怀(基于 compute_modulation 的 care_kind)
if factors.care_kind is not None and settings.care_enabled:
ev = await emit_time_event(
store,
"time.user_care",
character_id,
{
"care_kind": factors.care_kind,
"care_suggest": factors.care_suggest,
"late_night_fragility": factors.late_night_fragility,
"day_period": snapshot.day_period,
"local_hour": snapshot.local_hour,
},
run_id=run_id,
)
emitted.append(ev)
# 2. 感慨(5 触发)
is_reunion = snapshot.offline.band == "reunion_impact"
next_milestone_days = snapshot.next_milestone.days_until if snapshot.next_milestone else None
sentiment_signals = SentimentInput(
duration_days=snapshot.acquaintance.total_days,
next_milestone_days=next_milestone_days,
has_active_milestone=bool(snapshot.active_milestones),
is_reunion=is_reunion,
has_reunion_memory=is_reunion, # v1 简化:重逢即假定有临别记忆;v2 从息壤查
strong_solar_terms=settings.strong_solar_terms,
prewarm_days=settings.sentiment_prewarm_days,
duration_nodes=settings.duration_nodes,
)
trigger = detect_sentiment(snapshot, sentiment_signals)
if trigger is not None:
ev = await emit_time_event(
store,
"time.sentiment",
character_id,
{
"trigger_type": trigger,
"duration_days": snapshot.acquaintance.total_days,
"day_period": snapshot.day_period,
},
run_id=run_id,
)
emitted.append(ev)
# 3. 重逢
if is_reunion:
ev = await emit_time_event(
store,
"time.reunion",
character_id,
{
"offline_minutes": round(snapshot.offline.minutes, 1),
"reunion_intensity": factors.reunion_intensity,
"offline_band": snapshot.offline.band,
},
sensitivity="medium", # 重逢涉及久别记忆,标 medium
run_id=run_id,
)
emitted.append(ev)
# ---- service 包装函数(薄 endpoint 调用;session 在此自管)----
async def get_live_snapshot(
store: EventStore, owner_scope: str = settings.default_owner_scope
) -> LiveSnapshotResult:
"""GET /time/snapshot:当前 active 人物的实时快照。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
return await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
owner_scope=owner_scope,
)
async def list_anniversaries_for_active(
owner_scope: str = settings.default_owner_scope,
) -> list[TimeAnniversary]:
"""GET /time/anniversaries:当前 active 人物的纪念日列表。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
return await list_anniversaries(session, character.id)
async def create_anniversary_for_active(
payload: AnniversaryCreateRequest, owner_scope: str = settings.default_owner_scope
) -> TimeAnniversary:
"""POST /time/anniversaries:为当前 active 人物创建纪念日。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
anni = TimeAnniversary(
character_id=character.id,
name=payload.name,
calendar=payload.calendar,
date=payload.date,
recurs_yearly=payload.recurs_yearly,
kind=payload.kind,
subject_type=payload.subject_type,
source=payload.source,
)
created = await create_anniversary(session, anni)
await session.commit()
return created
async def get_anniversary_or_404(
anniv_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> TimeAnniversary:
"""GET /time/anniversaries/{id}:取单个,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
anni = await get_anniversary(session, anniv_id)
if anni is None:
raise AppError("not_found", "纪念日不存在。")
return anni
async def update_anniversary_or_404(
anniv_id: str,
payload: AnniversaryUpdateRequest,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> TimeAnniversary:
"""PATCH /time/anniversaries/{id}:部分更新,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
fields = payload.model_dump(exclude_none=True)
updated = await update_anniversary(session, anniv_id, **fields)
if updated is None:
raise AppError("not_found", "纪念日不存在。")
await session.commit()
return updated
async def delete_anniversary_or_404(
anniv_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> None:
"""DELETE /time/anniversaries/{id}:删除,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
ok = await delete_anniversary(session, anniv_id)
if not ok:
raise AppError("not_found", "纪念日不存在。")
await session.commit()
async def get_acquaintance_for_active(
store: EventStore, owner_scope: str = settings.default_owner_scope
) -> AcquaintanceDuration:
"""GET /time/acquaintance:当前 active 人物的认识时长。snapshot 降级时抛 503。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
owner_scope=owner_scope,
)
if result.snapshot is None:
# 契约 §15 / 设计 §12:time_context_missing → invalid_request(默认 400)
raise AppError("time_context_missing", "时态上下文不可用。")
return result.snapshot.acquaintance