初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,390 @@
|
||||
"""辰正时态服务编排(设计 §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
|
||||
Reference in new issue
Block a user