391 lines
14 KiB
Python
391 lines
14 KiB
Python
"""辰正时态服务编排(设计 §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
|