M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路
This commit is contained in:
1 parent
e6a3b7121f
commit
5f36e3785c
86 files changed
+424
-14455
No files matched your search
@@ -1,66 +0,0 @@
|
||||
"""角色卡导入服务测试(DB 集成)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.repositories import lorebook as lorebook_repo
|
||||
from app.services.card_import import import_character_card
|
||||
|
||||
|
||||
async def test_import_creates_character_and_lorebook() -> None:
|
||||
card = {
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "狂三",
|
||||
"nickname": "三三",
|
||||
"personality": "腹黑优雅",
|
||||
"character_book": {
|
||||
"name": "狂三世谱",
|
||||
"entries": [
|
||||
{"keys": ["猫"], "content": "喜欢猫。"},
|
||||
{"keys": ["时间"], "content": "时间能力。", "constant": True},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
raw = json.dumps(card).encode("utf-8")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await import_character_card(session, raw, "local-user")
|
||||
books = await lorebook_repo.list_books(session, character.id, "local-user")
|
||||
entries = await lorebook_repo.list_book_entries(session, books[0].id)
|
||||
|
||||
assert character.name == "狂三"
|
||||
assert character.display_name == "三三"
|
||||
assert character.personality_summary == "腹黑优雅"
|
||||
assert len(books) == 1
|
||||
assert books[0].name == "狂三世谱"
|
||||
assert len(entries) == 2
|
||||
assert any(e.constant for e in entries)
|
||||
|
||||
|
||||
async def test_import_without_book_creates_only_character() -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "无书角色", "personality": "温和"}}
|
||||
raw = json.dumps(card).encode("utf-8")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await import_character_card(session, raw, "local-user")
|
||||
books = await lorebook_repo.list_books(session, character.id, "local-user")
|
||||
|
||||
assert character.name == "无书角色"
|
||||
assert character.personality_summary == "温和"
|
||||
assert books == []
|
||||
|
||||
|
||||
async def test_import_v1_flat_card() -> None:
|
||||
# V1 扁平结构,无 data 包裹
|
||||
card = {"name": "V1角色", "personality": "p"}
|
||||
raw = json.dumps(card).encode("utf-8")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await import_character_card(session, raw, "local-user")
|
||||
assert character.name == "V1角色"
|
||||
assert character.personality_summary == "p"
|
||||
@@ -1,87 +0,0 @@
|
||||
"""灵核情绪状态机端到端集成测试:Dispatcher + handle_time_event + DB。
|
||||
|
||||
验证一炁订阅基础设施(计划 5)+ 灵核情绪状态机(设计 §11)全链路:
|
||||
EventStore.append(time.reunion) → Dispatcher 分发 → handle_time_event → DB 情绪状态更新。
|
||||
这是 main.py lifespan ``dispatcher.register("time.*", handle_time_event)`` 的端到端验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.personality import PersonalityVersion
|
||||
from app.modules.runtime.events import Event, InMemoryEventStore
|
||||
from app.repositories.personality_emotion import get_state
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.dispatcher import Dispatcher
|
||||
from app.services.personality_emotion import handle_time_event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_drives_emotion_state_update() -> None:
|
||||
"""append time.reunion → dispatcher 分发 → handle_time_event 写入 DB。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
pv = PersonalityVersion(
|
||||
character_id=cid,
|
||||
identity_profile="测试身份",
|
||||
emotion_baseline='{"mood": "neutral", "intensity": 0.2}',
|
||||
)
|
||||
session.add(pv)
|
||||
await session.flush()
|
||||
character.current_personality_version_id = pv.id
|
||||
await session.commit()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
dispatcher = Dispatcher(store)
|
||||
dispatcher.register("time.*", handle_time_event)
|
||||
await dispatcher.start()
|
||||
try:
|
||||
await store.append(
|
||||
Event(
|
||||
type="time.reunion",
|
||||
source="time_sense",
|
||||
payload={"reunion_intensity": 0.6, "offline_band": "reunion_impact"},
|
||||
character_id=cid,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.1) # 等 pump + consumer 异步完成
|
||||
finally:
|
||||
await dispatcher.stop()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await get_state(session, cid)
|
||||
assert row is not None
|
||||
assert row.mood == "touched"
|
||||
assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta
|
||||
character = await session.get(Character, cid)
|
||||
assert character is not None
|
||||
assert character.current_mood_id == "touched"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_ignores_non_time_events() -> None:
|
||||
"""非 time.* 事件不触发 handle_time_event(无副作用:无 state row 写入)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await session.commit()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
dispatcher = Dispatcher(store)
|
||||
dispatcher.register("time.*", handle_time_event)
|
||||
await dispatcher.start()
|
||||
try:
|
||||
await store.append(Event(type="run.started", source="run", character_id=cid))
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await dispatcher.stop()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 非 time.* 事件 → handler 不被调用 → 无 state row
|
||||
assert await get_state(session, cid) is None
|
||||
@@ -1,226 +0,0 @@
|
||||
"""灵核情绪状态服务测试(设计 §11)。"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.personality import PersonalityVersion
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.modules.personality.emotion import EmotionState
|
||||
from app.modules.runtime.events import Event
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.personality_emotion import (
|
||||
apply_time_event,
|
||||
compute_current_state,
|
||||
handle_time_event,
|
||||
)
|
||||
|
||||
|
||||
async def _seed_personality(
|
||||
session, character_id: str, baseline_json: str = '{"mood": "neutral", "intensity": 0.2}'
|
||||
) -> PersonalityVersion:
|
||||
"""创建人格版本并绑定到 character.current_personality_version_id。"""
|
||||
pv = PersonalityVersion(
|
||||
character_id=character_id,
|
||||
identity_profile="测试身份",
|
||||
emotion_baseline=baseline_json,
|
||||
)
|
||||
session.add(pv)
|
||||
await session.flush()
|
||||
character = await session.get(Character, character_id)
|
||||
assert character is not None
|
||||
character.current_personality_version_id = pv.id
|
||||
return pv
|
||||
|
||||
|
||||
def _reunion_event(
|
||||
character_id: str, intensity: float = 0.6, event_id: str = "event_test1"
|
||||
) -> Event:
|
||||
return Event(
|
||||
id=event_id,
|
||||
type="time.reunion",
|
||||
source="time_sense",
|
||||
payload={"reunion_intensity": intensity, "offline_band": "reunion_impact"},
|
||||
character_id=character_id,
|
||||
)
|
||||
|
||||
|
||||
# ---- compute_current_state ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_no_state_returns_baseline() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await _seed_personality(session, cid, '{"mood": "pleased", "intensity": 0.4}')
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
state = await compute_current_state(session, cid)
|
||||
assert state == EmotionState(mood="pleased", intensity=0.4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_no_state_no_personality_returns_default() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
state = await compute_current_state(session, cid)
|
||||
assert state == EmotionState() # neutral 0.2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_with_state_zero_turns_returns_state_unchanged() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await _seed_personality(session, cid)
|
||||
session.add(
|
||||
CharacterEmotionState(
|
||||
character_id=cid,
|
||||
mood="touched",
|
||||
intensity=0.8,
|
||||
baseline_mood="neutral",
|
||||
baseline_intensity=0.2,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
state = await compute_current_state(session, cid)
|
||||
# 无后续 user 回合 → 不 decay
|
||||
assert state is not None
|
||||
assert state.mood == "touched"
|
||||
assert abs(state.intensity - 0.8) < 0.001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_decays_after_user_messages() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await _seed_personality(session, cid)
|
||||
# 写入 touched 0.8 状态,updated_at 手动设为 1 小时前
|
||||
old = datetime.now(UTC) - timedelta(hours=1)
|
||||
row = CharacterEmotionState(
|
||||
character_id=cid,
|
||||
mood="touched",
|
||||
intensity=0.8,
|
||||
baseline_mood="neutral",
|
||||
baseline_intensity=0.2,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
# SQLite 读回 naive;写入时也用 naive 保持一致
|
||||
row.updated_at = old.replace(tzinfo=None)
|
||||
# 6 条 user 消息 = 半衰期回合数 → intensity 衰减一半
|
||||
chat_session = ChatSession(character_id=cid)
|
||||
session.add(chat_session)
|
||||
await session.flush()
|
||||
for i in range(6):
|
||||
session.add(
|
||||
ChatMessage(
|
||||
character_id=cid,
|
||||
session_id=chat_session.id,
|
||||
role="user",
|
||||
content=f"msg {i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
state = await compute_current_state(session, cid)
|
||||
# elapsed=6, factor=0.5 → 0.2 + (0.8-0.2)*0.5 = 0.5
|
||||
assert state is not None
|
||||
assert state.mood == "touched"
|
||||
assert abs(state.intensity - 0.5) < 0.01
|
||||
|
||||
|
||||
# ---- apply_time_event ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_reunion_initializes_from_baseline() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await _seed_personality(session, cid, '{"mood": "neutral", "intensity": 0.2}')
|
||||
await apply_time_event(_reunion_event(cid, intensity=0.6), session)
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
from app.repositories.personality_emotion import get_state
|
||||
|
||||
row = await get_state(session, cid)
|
||||
assert row is not None
|
||||
assert row.mood == "touched"
|
||||
assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta
|
||||
assert row.last_trigger_event_id == "event_test1"
|
||||
character = await session.get(Character, cid)
|
||||
assert character is not None
|
||||
assert character.current_mood_id == "touched"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_reunion_accumulates_when_same_mood() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await _seed_personality(session, cid)
|
||||
await apply_time_event(_reunion_event(cid, intensity=0.5), session)
|
||||
await apply_time_event(_reunion_event(cid, intensity=0.3, event_id="event_test2"), session)
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
from app.repositories.personality_emotion import get_state
|
||||
|
||||
row = await get_state(session, cid)
|
||||
assert row is not None
|
||||
assert row.mood == "touched"
|
||||
# 首次 0.2+0.5=0.7;同 mood 累加 0.7+0.3=1.0
|
||||
assert abs(row.intensity - 1.0) < 0.001
|
||||
assert row.last_trigger_event_id == "event_test2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_event_without_character_id_is_noop() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await apply_time_event(
|
||||
Event(type="time.reunion", source="time_sense", payload={}, character_id=None),
|
||||
session,
|
||||
)
|
||||
await session.commit()
|
||||
# 无异常即通过;无副作用可验证(无 character_id 无法写)
|
||||
|
||||
|
||||
# ---- handle_time_event ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_time_event_commits_via_own_session() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await _seed_personality(session, cid)
|
||||
await session.commit()
|
||||
# handle_time_event 自管 session + 提交
|
||||
await handle_time_event(_reunion_event(cid, intensity=0.6))
|
||||
async with AsyncSessionLocal() as session:
|
||||
from app.repositories.personality_emotion import get_state
|
||||
|
||||
row = await get_state(session, cid)
|
||||
assert row is not None
|
||||
assert row.mood == "touched"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_time_event_swallows_exception() -> None:
|
||||
"""降级原则:handler 异常不抛(不阻塞事件流)。"""
|
||||
# character_id 指向不存在的角色 → _resolve_baseline 返回默认,apply 仍尝试写
|
||||
# 但更直接:用一个会触发异常的事件(如 DB 约束)。这里验证无 character_id 不抛 + 任意异常被吞。
|
||||
# 构造一个正常事件,handler 应正常完成不抛
|
||||
await handle_time_event(
|
||||
Event(type="time.reunion", source="time_sense", payload={}, character_id="char_nonexistent")
|
||||
)
|
||||
# 无抛出即通过
|
||||
@@ -1,112 +0,0 @@
|
||||
"""辰正集成测试:重逢流事件链 + milestone 联动(设计 §6.3)。"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.time_anniversary import TimeAnniversary
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.time_sense.modulation import ModulationInput
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.time_sense import compute_live_snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reunion_flow_emits_reunion_and_sentiment():
|
||||
"""离线 > 3 天 → time.reunion + time.sentiment(reunion_memory)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
chat_session = ChatSession(character_id=character.id, status="active")
|
||||
session.add(chat_session)
|
||||
await session.flush()
|
||||
old_msg = ChatMessage(
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
role="user",
|
||||
content="5 天前的消息",
|
||||
created_at=datetime(2026, 6, 29, 4, 0, tzinfo=UTC),
|
||||
)
|
||||
session.add(old_msg)
|
||||
await session.flush()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 5 天后
|
||||
)
|
||||
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.offline.band == "reunion_impact"
|
||||
event_types = {e.type for e in result.emitted_events}
|
||||
assert "time.reunion" in event_types
|
||||
assert "time.sentiment" in event_types # reunion_memory 触发
|
||||
sentiment = next(e for e in result.emitted_events if e.type == "time.sentiment")
|
||||
assert sentiment.payload["trigger_type"] == "reunion_memory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_event_when_active():
|
||||
"""active 档(无历史消息,last_active=now)→ 无 reunion/sentiment 事件。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 上午
|
||||
)
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.offline.band == "active"
|
||||
event_types = {e.type for e in result.emitted_events}
|
||||
assert "time.reunion" not in event_types
|
||||
assert "time.sentiment" not in event_types
|
||||
assert "time.user_care" not in event_types # 白天 + 无 birthday 等信号
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_milestone_at_late_night_emits_sentiment_overlay():
|
||||
"""凌晨 + 今日命中纪念日 → active_milestones 非空 + time.sentiment(late_night_overlay)。
|
||||
|
||||
验证 anniversary 命中 + 凌晨时段联动 sentiment 的集成路径(Task 4 未覆盖)。
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
anni = TimeAnniversary(
|
||||
character_id=character.id,
|
||||
name="我们的相遇日",
|
||||
calendar="solar",
|
||||
date="2026-07-05", # 今日(now=18:00 UTC → 上海 02:00 本地日期 07-05)命中
|
||||
recurs_yearly=True,
|
||||
kind="anniversary",
|
||||
subject_type="relationship",
|
||||
source="manual",
|
||||
)
|
||||
session.add(anni)
|
||||
await session.flush()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨
|
||||
)
|
||||
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.day_period == "凌晨"
|
||||
assert len(result.snapshot.active_milestones) >= 1
|
||||
# duration_node(0 不在 nodes)/anniversary_prewarm(next=365>7)/reunion_memory(active 档) 都不触发
|
||||
# → late_night_overlay(凌晨 + has_active_milestone)
|
||||
sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"]
|
||||
assert len(sentiment_events) == 1
|
||||
assert sentiment_events[0].payload["trigger_type"] == "late_night_overlay"
|
||||
# active 档(无 chat 历史)→ 不触发 reunion
|
||||
assert "time.reunion" not in {e.type for e in result.emitted_events}
|
||||
@@ -1,137 +0,0 @@
|
||||
"""辰正 service 测试(流 A 实时快照 + 流 C 事件触发)。"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.time_sense.modulation import ModulationInput
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.time_sense import compute_live_snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_live_snapshot_returns_snapshot_and_factors():
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
|
||||
)
|
||||
assert result.snapshot is not None
|
||||
assert result.factors is not None
|
||||
assert result.context_text # 非空
|
||||
assert "周" in result.context_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_live_snapshot_degrades_on_failure():
|
||||
"""计算失败时降级返回最小快照(不抛异常)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
# 传入非法时区触发降级
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC),
|
||||
timezone="Invalid/Zone",
|
||||
)
|
||||
# 降级:snapshot 仍返回(compute_snapshot 内部降级为 UTC),不抛
|
||||
assert result.snapshot is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_user_care_when_birthday():
|
||||
"""user_birthday_today → care_kind=birthday → 发 time.user_care(不依赖 snapshot 时段/时区)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(user_birthday_today=True),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
|
||||
)
|
||||
assert result.snapshot is not None
|
||||
care_events = [e for e in result.emitted_events if e.type == "time.user_care"]
|
||||
assert len(care_events) == 1
|
||||
assert care_events[0].payload["care_kind"] == "birthday"
|
||||
# birthday 不应触发 reunion(last_active≈now),也不命中 sentiment 节点
|
||||
assert all(e.type != "time.reunion" for e in result.emitted_events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_reunion_when_offline_exceeds_threshold():
|
||||
"""seed 5 天前消息 → offline.band=reunion_impact → 发 time.reunion + time.sentiment。"""
|
||||
now = datetime(2026, 7, 4, 4, 0, tzinfo=UTC)
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
# 先建会话(FK 约束),再 seed 一条 5 天前的消息
|
||||
chat_session = ChatSession(character_id=character.id, status="active")
|
||||
session.add(chat_session)
|
||||
await session.flush()
|
||||
session.add(
|
||||
ChatMessage(
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
role="user",
|
||||
content="旧消息",
|
||||
created_at=now - timedelta(days=5),
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.offline.band == "reunion_impact"
|
||||
# 5 天 = 7200 min > 4320 min 阈值 → 重逢
|
||||
reunion_events = [e for e in result.emitted_events if e.type == "time.reunion"]
|
||||
assert len(reunion_events) == 1
|
||||
payload = reunion_events[0].payload
|
||||
assert payload["offline_band"] == "reunion_impact"
|
||||
assert "reunion_intensity" in payload
|
||||
assert payload["offline_minutes"] >= 4320
|
||||
assert reunion_events[0].sensitivity == "medium"
|
||||
# is_reunion=True + has_reunion_memory=is_reunion → sentiment=reunion_memory
|
||||
sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"]
|
||||
assert len(sentiment_events) == 1
|
||||
assert sentiment_events[0].payload["trigger_type"] == "reunion_memory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_live_snapshot_attaches_run_id_to_events():
|
||||
"""对话入口传 run_id → 触发的 time.* 事件全部带该 run_id(落当前 run,SSE 可见)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
# 凌晨场景触发 late_night care / sentiment
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(conversation_turns_today=3),
|
||||
now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨
|
||||
timezone="Asia/Shanghai",
|
||||
run_id="run_test_plan4",
|
||||
)
|
||||
assert result.emitted_events, "凌晨 + 3 轮应触发至少一个 time.* 事件"
|
||||
for ev in result.emitted_events:
|
||||
assert ev.run_id == "run_test_plan4"
|
||||
Reference in new issue
Block a user