M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路

This commit is contained in:
chenxin committed 2026-08-21 23:43:29 +08:00
1 parent e6a3b7121f
commit 5f36e3785c
86 files changed
+424 -14455

No files matched your search

@@ -1,97 +0,0 @@
"""角色卡导入 API 测试。"""
from __future__ import annotations
import base64
import json
import struct
import zlib
import httpx
def _make_png_text(keyword: str, text: str) -> bytes:
def chunk(ctype: bytes, data: bytes) -> bytes:
body = ctype + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
async def test_import_json_card(auth_client: httpx.AsyncClient) -> None:
card = {"spec_version": "2.0", "data": {"name": "API卡", "personality": "p"}}
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "API卡"
assert data["character_id"].startswith("character_")
async def test_import_png_card(auth_client: httpx.AsyncClient) -> None:
card = {"spec_version": "2.0", "data": {"name": "PNG卡"}}
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.png", _make_png_text("chara", text), "image/png")},
)
assert resp.status_code == 200
assert resp.json()["name"] == "PNG卡"
async def test_import_card_with_lorebook(auth_client: httpx.AsyncClient) -> None:
card = {
"spec_version": "2.0",
"data": {
"name": "带书",
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
},
}
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert data["lorebook_id"] is not None
assert data["entry_count"] == 1
# 导入的人物可被列出
listed = await auth_client.get("/api/v1/characters")
assert any(c["id"] == data["character_id"] for c in listed.json()["items"])
async def test_export_card_round_trip(auth_client: httpx.AsyncClient) -> None:
# 导入一张带世谱的卡
card = {
"spec_version": "2.0",
"data": {
"name": "往返",
"nickname": "返返",
"personality": "p",
"character_book": {"entries": [{"keys": ["k"], "content": "v"}]},
},
}
imp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("c.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert imp.status_code == 200
cid = imp.json()["character_id"]
# 导出
exp = await auth_client.get(f"/api/v1/characters/{cid}/export-card")
assert exp.status_code == 200
exported = exp.json()
assert exported["spec"] == "chara_card_v3"
assert exported["data"]["name"] == "往返"
assert exported["data"]["nickname"] == "返返"
assert exported["data"]["personality"] == "p"
assert exported["data"]["character_book"]["entries"][0]["keys"] == ["k"]
async def test_export_unknown_character_404(auth_client: httpx.AsyncClient) -> None:
exp = await auth_client.get("/api/v1/characters/character_missing/export-card")
assert exp.status_code == 404
@@ -1,59 +0,0 @@
"""道体注入与完整性校验测试。"""
from __future__ import annotations
import pytest
from app.application.chat.send_message import _contract_system_messages
from app.modules.runtime.contract import RuntimeContract
from app.modules.runtime.integrity import validate_daoti_integrity
def _contract(**kw: object) -> RuntimeContract:
base: dict[str, object] = {
"character_id": "c",
"session_id": "s",
"run_id": "r",
"model_hint": "m",
"trace_id": "t",
}
base.update(kw)
return RuntimeContract(**base)
def test_system_prompt_injected() -> None:
msgs = _contract_system_messages(_contract(system_prompt="特殊系统指令"))
assert "特殊系统指令" in msgs
def test_mission_injected() -> None:
msgs = _contract_system_messages(_contract(mission="守护用户"))
assert any("守护用户" in m for m in msgs)
def test_safety_guidelines_precede_safety_constraints() -> None:
c = _contract(safety_guidelines="道体边界", safety_constraints=["司命约束"])
msgs = _contract_system_messages(c)
assert msgs.index("行为边界:\n道体边界") < msgs.index("安全约束:\n- 司命约束")
def test_integrity_pass_when_consistent() -> None:
c = _contract(mission="M", safety_guidelines="S")
validate_daoti_integrity(c, mission="M", safety_guidelines="S") # 不抛即通过
def test_integrity_raises_on_mission_tamper() -> None:
c = _contract(mission="M")
with pytest.raises(ValueError):
validate_daoti_integrity(c, mission="被篡改")
def test_integrity_raises_on_safety_tamper() -> None:
c = _contract(safety_guidelines="S")
with pytest.raises(ValueError):
validate_daoti_integrity(c, safety_guidelines="被篡改")
def test_integrity_skips_none_fields() -> None:
# 道体字段未设(None)时跳过校验
validate_daoti_integrity(_contract(), mission=None, safety_guidelines=None)
@@ -1,48 +0,0 @@
"""灵核情绪状态 system prompt 注入测试(设计 §11.5)。
验证 ``_contract_system_messages`` 把 ``emotion_state`` 注入到正确位置
(personality_summary 后、memory_context 前),且 emotion_state=None 时不注入。
"""
from __future__ import annotations
from app.application.chat.send_message import _contract_system_messages
from app.modules.runtime.contract import RuntimeContract
def _contract(**overrides) -> RuntimeContract:
base = {
"character_id": "c1",
"session_id": "s1",
"run_id": "r1",
"model_hint": "m",
"trace_id": "t",
}
base.update(overrides)
return RuntimeContract(**base)
def test_emotion_state_injected_between_personality_and_memory() -> None:
contract = _contract(
personality_summary="人格X",
emotion_state="[当前情绪] 感动(强度 70%)",
memory_context="记忆Y",
)
messages = _contract_system_messages(contract)
personality_idx = next(i for i, m in enumerate(messages) if "人格X" in m)
emotion_idx = next(i for i, m in enumerate(messages) if "感动" in m)
memory_idx = next(i for i, m in enumerate(messages) if "记忆Y" in m)
assert personality_idx < emotion_idx < memory_idx
def test_emotion_state_omitted_when_none() -> None:
contract = _contract(personality_summary="人格X", memory_context="记忆Y")
messages = _contract_system_messages(contract)
assert all("当前情绪" not in m for m in messages)
def test_emotion_state_included_without_memory() -> None:
"""emotion_state 独立于 memory_context 存在。"""
contract = _contract(emotion_state="[当前情绪] 关切(强度 50%)")
messages = _contract_system_messages(contract)
assert any("关切" in m for m in messages)
@@ -1,41 +0,0 @@
"""世谱注入位置测试:before/after 相对 personality_summary。"""
from __future__ import annotations
from app.application.chat.send_message import _contract_system_messages
from app.modules.runtime.contract import RuntimeContract
def _contract(**kw: object) -> RuntimeContract:
base: dict[str, object] = {
"character_id": "c",
"session_id": "s",
"run_id": "r",
"model_hint": "m",
"trace_id": "t",
}
base.update(kw)
return RuntimeContract(**base)
def test_lorebook_before_precedes_personality() -> None:
c = _contract(personality_summary="人格X", lorebook_before="世界设定前")
msgs = _contract_system_messages(c)
assert msgs.index("世界设定前") < msgs.index("当前人格:\n人格X")
def test_lorebook_after_follows_personality() -> None:
c = _contract(personality_summary="人格X", lorebook_after="世界设定后")
msgs = _contract_system_messages(c)
assert msgs.index("当前人格:\n人格X") < msgs.index("世界设定后")
def test_no_lorebook_when_none() -> None:
msgs = _contract_system_messages(_contract())
assert not any("世界设定" in m for m in msgs)
def test_lorebook_without_personality_still_injects() -> None:
c = _contract(lorebook_before="前", lorebook_after="后")
msgs = _contract_system_messages(c)
assert "前" in msgs and "后" in msgs
@@ -1,8 +0,0 @@
"""测试 send_message 中的目标自动注入(集成验证)。"""
def test_auto_persist_session_goal_is_callable():
"""验证 auto_persist_session_goal 函数可导入且可调用。"""
from app.services.goal_persist import auto_persist_session_goal
assert callable(auto_persist_session_goal)
@@ -1,179 +0,0 @@
"""对话 temporal_context 注入测试(辰正 §8.1)。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from app.application.agent.runtime import AgentTurnResult
from app.application.chat.send_message import _contract_system_messages
from app.main import app
from app.modules.runtime.contract import RuntimeContract
def _make_contract(temporal: str | None) -> RuntimeContract:
return RuntimeContract(
character_id="char_x",
session_id="sess_x",
run_id="run_x",
model_hint="default",
system_instructions=["base"],
personality_summary="人格X",
memory_context="记忆Y",
temporal_context=temporal,
trace_id="trace_x",
)
def test_contract_system_messages_includes_temporal_context():
messages = _contract_system_messages(_make_contract("现在是子时·深夜(01:42)。"))
assert any("子时·深夜" in m for m in messages)
def test_contract_system_messages_no_temporal_context_when_none():
messages = _contract_system_messages(_make_contract(None))
assert all("子时" not in m for m in messages)
def test_contract_system_messages_temporal_after_memory_before_safety():
"""注入位置:记忆之后、安全约束之前(设计 §8.1)。"""
contract = RuntimeContract(
character_id="c",
session_id="s",
run_id="r",
model_hint="m",
system_instructions=["base"],
memory_context="MEMORY_MARKER",
temporal_context="TEMPORAL_MARKER",
safety_constraints=["SAFETY_MARKER"],
trace_id="t",
)
messages = _contract_system_messages(contract)
joined = "\n".join(messages)
assert joined.index("MEMORY_MARKER") < joined.index("TEMPORAL_MARKER")
assert joined.index("TEMPORAL_MARKER") < joined.index("SAFETY_MARKER")
# ---------------------------------------------------------------------------
# §12 降级路径守护(设计 §12:辰正永不阻塞对话)
# 1. event_store=None → 跳过辰正调用,temporal_context 保持 None,对话照常完成
# 2. compute_live_snapshot raise → except 捕获,temporal_context 保持 None,对话不阻塞
# ---------------------------------------------------------------------------
async def _create_session(client: AsyncClient) -> str:
resp = await client.post("/api/v1/chat/sessions", json={"title": "辰正降级"})
assert resp.status_code == 200
return str(resp.json()["session_id"])
@pytest.fixture
async def temporal_client(
auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch
) -> AsyncGenerator[AsyncClient, None]:
"""HTTP client:mock run_agent_turn,并通过 messages 捕获最终 system 消息用于断言。
复用 test_send_message_agent.py 的 mock 边界(只 mock run_agent_turn,跑真实 send_message
全链路),让降级路径在生产代码路径中被真实驱动。
"""
captured: dict[str, Any] = {}
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
captured["messages"] = messages
return AgentTurnResult(content="好的。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
http_client._temporal_captured = captured # type: ignore[attr-defined]
yield http_client
def _system_text(messages: list[Any]) -> str:
return "\n".join(str(m.content) for m in messages if getattr(m, "role", "") == "system")
@pytest.mark.asyncio
async def test_temporal_context_skipped_when_event_store_is_none(
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""§12 降径 1:event_store=None 时跳过辰正调用,对话照常完成且不注入 temporal_context。"""
from app.application.chat.send_message import drain_pending_streams
# 降径 1:明确将 event_store 置 None(route 经 getattr 默认也是 None,此处显式断言降径)
# 测试 app.state 默认无 event_store 属性,monkeypatch.setattr 对对象属性可在不存在时
# 创建并在 teardown 时删除(恢复"无属性"原状)。
monkeypatch.setattr(app.state, "event_store", None, raising=False)
# spy:若被调用即标记(不应发生)
called: dict[str, bool] = {"hit": False}
import app.services.time_sense as time_sense_mod
real_compute = time_sense_mod.compute_live_snapshot
async def spy_compute(*args: Any, **kwargs: Any) -> Any:
called["hit"] = True
return await real_compute(*args, **kwargs)
monkeypatch.setattr(time_sense_mod, "compute_live_snapshot", spy_compute)
session_id = await _create_session(temporal_client)
sent = await temporal_client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
# 真实行为断言:辰正未被调用
assert called["hit"] is False
# 对话完成:run_agent_turn 被正常调用且 system 消息中无辰正上下文标记
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
sys_text = _system_text(captured["messages"])
assert "今日命中" not in sys_text # render_temporal_context 的稳定首行尾块标记
@pytest.mark.asyncio
async def test_temporal_context_swallowed_when_compute_raises(
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""§12 降径 2:compute_live_snapshot raise 时 except 捕获,对话不阻塞且 temporal_context=None。"""
async def boom(*_args: Any, **_kwargs: Any) -> Any:
raise RuntimeError("辰正服务故障(模拟)")
monkeypatch.setattr("app.services.time_sense.compute_live_snapshot", boom)
from app.application.chat.send_message import drain_pending_streams
session_id = await _create_session(temporal_client)
sent = await temporal_client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
# 真实行为断言:异常被 except 吞掉,HTTP 仍 200,对话照常完成
assert sent.status_code == 200
run_id = str(sent.json()["run_id"])
await drain_pending_streams()
# 对话成功落库:run 进入终态 completed(而非 failed)
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.runtime import Run
async with AsyncSessionLocal() as db:
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
assert run.status == "completed"
# temporal_context 未注入:system 消息中无辰正上下文标记
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
sys_text = _system_text(captured["messages"])
assert "子时" not in sys_text and "时态" not in sys_text
-195
View File
@@ -1,195 +0,0 @@
"""叩心枢测试共用 fixtures。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db.base import Base
from app.models.big_cycle import BigCycle
from app.models.chat import ChatMessage
from app.models.drive import CuriosityQuestion
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.models.personality_emotion import CharacterEmotionState
from app.models.time_anniversary import TimeAnniversary
# 用内存 SQLite 做测试数据库
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
@pytest_asyncio.fixture
async def seed_old_message(db_session: AsyncSession) -> None:
"""插入一条 2 小时前的消息,供空闲超时条件触发。"""
now = datetime.now(UTC)
db_session.add(
ChatMessage(
character_id="char_test",
session_id="sess_old",
role="assistant",
modality="text",
content="旧消息",
created_at=now - timedelta(hours=2),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_recent_message(db_session: AsyncSession) -> None:
"""插入一条 1 分钟前的消息,供空闲超时条件不触发。"""
now = datetime.now(UTC)
db_session.add(
ChatMessage(
character_id="char_test",
session_id="sess_recent",
role="assistant",
modality="text",
content="新消息",
created_at=now - timedelta(minutes=1),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_anniversary(db_session: AsyncSession) -> None:
"""插入一条 2026-06-27 的生日纪念日。"""
db_session.add(
TimeAnniversary(
character_id="char_test",
name="测试生日",
calendar="solar",
date="2026-06-27",
kind="birthday",
subject_type="user",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_emotion_state(db_session: AsyncSession) -> None:
"""插入一条初始情绪状态(neutral / 0.2)。"""
db_session.add(
CharacterEmotionState(
character_id="char_test",
mood="neutral",
intensity=0.2,
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_curiosity(db_session: AsyncSession) -> None:
"""插入一条活跃的好奇心问题。"""
db_session.add(
CuriosityQuestion(
character_id="char_test",
cycle_id="cycle_test",
question="你为什么总是沉默?",
source="drive",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_applied_evolution(db_session: AsyncSession) -> None:
"""插入一条已应用的演化候选。"""
db_session.add(
EvolutionCandidate(
character_id="char_test",
cycle_id="cycle_test",
type="personality",
title="语气更柔和",
proposal="调整表达风格,减少命令式语气,增加温和感",
level="L3",
status="applied",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_completed_big_cycle(db_session: AsyncSession) -> None:
"""插入一条已完成的大周天循环。"""
now = datetime.now(UTC)
db_session.add(
BigCycle(
character_id="char_test",
trigger="session_turn",
status="completed",
summary="发现自己最近更倾向于回忆过去,在对话中表现出怀旧倾向",
completed_at=now - timedelta(minutes=5),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_new_memory(db_session: AsyncSession) -> None:
"""插入一条活跃的新记忆。"""
db_session.add(
Memory(
character_id="char_test",
type="experience",
title="今天去了海边",
content="用户提到今天去了海边,看日落。角色回忆起自己也曾喜欢在海边散步。",
status="active",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_many_messages(db_session: AsyncSession) -> None:
"""插入多条消息,供关系里程碑(消息数)条件触发。"""
now = datetime.now(UTC)
for i in range(5):
db_session.add(
ChatMessage(
character_id="char_test",
session_id=f"sess_rel_{i}",
role="assistant" if i % 2 else "user",
modality="text",
content=f"第{i + 1}条消息",
created_at=now - timedelta(minutes=10 - i),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_old_relationship(db_session: AsyncSession) -> None:
"""插入一条 60 天前的消息,供关系里程碑(天数)条件触发。"""
now = datetime.now(UTC)
db_session.add(
ChatMessage(
character_id="char_test",
session_id="sess_old_rel",
role="user",
modality="text",
content="你好",
created_at=now - timedelta(days=60),
)
)
await db_session.commit()
@@ -1,303 +0,0 @@
"""叩心枢触发条件单元测试。"""
# ruff: noqa: ARG002 — pytest fixture params are intentionally unused in test signatures
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.knocking.conditions import (
AnniversaryCondition,
BigCycleCompletedCondition,
ConditionResult,
CuriosityCondition,
EmotionChangeCondition,
EvolutionAppliedCondition,
MemoryRecallCondition,
ProactiveCondition,
RelationshipMilestoneCondition,
TimeElapsedCondition,
TimeOfDayCondition,
UserReunionCondition,
)
class TestTimeElapsedCondition:
"""空闲超时条件测试。"""
async def test_not_triggered_when_no_history(self, db_session: AsyncSession) -> None:
cond = TimeElapsedCondition(min_idle_minutes=360)
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_idle_exceeds_threshold(
self, db_session: AsyncSession, seed_old_message: None
) -> None:
cond = TimeElapsedCondition(min_idle_minutes=1)
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "idle"
async def test_not_triggered_when_recent_message(
self, db_session: AsyncSession, seed_recent_message: None
) -> None:
cond = TimeElapsedCondition(min_idle_minutes=360)
now = datetime.now(UTC)
result = await cond.check("char_test", db_session, now=now)
assert not result.triggered
class TestTimeOfDayCondition:
async def test_morning_match(self) -> None:
cond = TimeOfDayCondition(windows=["morning"])
now = datetime(2026, 6, 27, 7, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", None, now=now)
assert result.triggered
assert result.condition_name == "time_of_day"
async def test_late_night_match(self) -> None:
cond = TimeOfDayCondition(windows=["late_night"])
now = datetime(2026, 6, 27, 23, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", None, now=now)
assert result.triggered
assert result.context.get("time_of_day") == "late_night"
async def test_no_match_outside_window(self) -> None:
cond = TimeOfDayCondition(windows=["morning"])
now = datetime(2026, 6, 27, 14, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", None, now=now)
assert not result.triggered
class TestAnniversaryCondition:
async def test_not_triggered_on_first_call_no_data(self, db_session: AsyncSession) -> None:
cond = AnniversaryCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_anniversary_exists(
self, db_session: AsyncSession, seed_anniversary: None
) -> None:
cond = AnniversaryCondition()
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", db_session, now=now)
assert result.triggered
assert result.condition_name == "anniversary"
assert "birthday" in result.context.get("anniversary_kind", "")
async def test_not_duplicated_same_day(
self, db_session: AsyncSession, seed_anniversary: None
) -> None:
cond = AnniversaryCondition()
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
result1 = await cond.check("char_test", db_session, now=now)
assert result1.triggered
result2 = await cond.check("char_test", db_session, now=now)
assert not result2.triggered # 同一天不重复
class TestUserReunionCondition:
async def test_not_triggered_when_no_event(self, db_session: AsyncSession) -> None:
cond = UserReunionCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
class TestEmotionChangeCondition:
async def test_not_triggered_on_first_check(
self, db_session: AsyncSession, seed_emotion_state: None
) -> None:
cond = EmotionChangeCondition(min_delta=0.3)
result = await cond.check("char_test", db_session)
assert not result.triggered # 首次无基线
async def test_triggered_when_intensity_changes(
self, db_session: AsyncSession, seed_emotion_state: None
) -> None:
cond = EmotionChangeCondition(min_delta=0.3)
await cond.check("char_test", db_session) # 首次建立基线
# 更新情绪 intensity 到 0.8(变化 0.6 > 0.3)
from app.models.personality_emotion import CharacterEmotionState
result = await db_session.execute(
select(CharacterEmotionState).where(CharacterEmotionState.character_id == "char_test")
)
state = result.scalar_one()
state.intensity = 0.8
state.mood = "melancholy"
await db_session.commit()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "emotion"
class TestCuriosityCondition:
async def test_not_triggered_when_no_active_questions(self, db_session: AsyncSession) -> None:
cond = CuriosityCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_active_question_exists(
self, db_session: AsyncSession, seed_curiosity: None
) -> None:
cond = CuriosityCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "curiosity"
assert "curiosity_question" in result.context
assert result.context["curiosity_question"] == "你为什么总是沉默?"
class TestEvolutionAppliedCondition:
"""演化应用条件测试。"""
async def test_not_triggered_when_no_applied(self, db_session: AsyncSession) -> None:
cond = EvolutionAppliedCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_applied_exists(
self, db_session: AsyncSession, seed_applied_evolution: None
) -> None:
cond = EvolutionAppliedCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "evolution_applied"
assert result.context.get("evolution_title") == "语气更柔和"
assert result.context.get("evolution_level") == "L3"
assert "evolution_proposal" in result.context
async def test_not_duplicated(
self, db_session: AsyncSession, seed_applied_evolution: None
) -> None:
cond = EvolutionAppliedCondition()
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一条不重复触发
class TestBigCycleCompletedCondition:
"""大周天完成条件测试。"""
async def test_not_triggered_when_no_completed(self, db_session: AsyncSession) -> None:
cond = BigCycleCompletedCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_completed_exists(
self, db_session: AsyncSession, seed_completed_big_cycle: None
) -> None:
cond = BigCycleCompletedCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "big_cycle_completed"
assert "big_cycle_summary" in result.context
assert "big_cycle_trigger" in result.context
async def test_not_duplicated(
self, db_session: AsyncSession, seed_completed_big_cycle: None
) -> None:
cond = BigCycleCompletedCondition()
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一条不重复触发
class TestMemoryRecallCondition:
"""记忆唤醒条件测试。"""
async def test_not_triggered_when_no_memory(self, db_session: AsyncSession) -> None:
cond = MemoryRecallCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_new_memory_exists(
self, db_session: AsyncSession, seed_new_memory: None
) -> None:
cond = MemoryRecallCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "memory_recall"
assert result.context.get("memory_title") == "今天去了海边"
assert "memory_content" in result.context
assert "memory_type" in result.context
async def test_not_duplicated(self, db_session: AsyncSession, seed_new_memory: None) -> None:
cond = MemoryRecallCondition()
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一条不重复触发
class TestRelationshipMilestoneCondition:
"""关系里程碑条件测试。"""
async def test_not_triggered_when_no_messages(self, db_session: AsyncSession) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_by_message_count(
self, db_session: AsyncSession, seed_many_messages: None
) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "relationship_milestone"
assert result.context.get("milestone_type") == "messages"
assert result.context.get("milestone_value") == 5
assert result.context.get("milestone_threshold") == 5
async def test_triggered_by_days(
self, db_session: AsyncSession, seed_old_relationship: None
) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[9999], day_milestones=[30])
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "relationship_milestone"
assert result.context.get("milestone_type") == "days"
assert result.context.get("milestone_value") >= 30
assert result.context.get("milestone_threshold") == 30
async def test_not_duplicated(self, db_session: AsyncSession, seed_many_messages: None) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一里程碑不重复触发
async def test_prefers_messages_over_days(
self, db_session: AsyncSession, seed_many_messages: None, seed_old_relationship: None
) -> None:
"""同时满足消息和天数里程碑时,优先触发消息里程碑。"""
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.context.get("milestone_type") == "messages" # 消息优先
@pytest.mark.asyncio
async def test_proactive_condition_protocol() -> None:
"""验证 ProactiveCondition 可作为 Protocol 使用。"""
class DummyCondition(ProactiveCondition):
async def check(
self,
_character_id: str,
_session: AsyncSession | None = None,
*,
_now: datetime | None = None,
) -> ConditionResult:
return ConditionResult(triggered=True, trigger_reason="测试", condition_name="dummy")
cond: ProactiveCondition = DummyCondition()
result = await cond.check("char_test")
assert result.triggered
@@ -1,214 +0,0 @@
"""叩心枢(KnockingPivot)集成测试。
测试覆盖:
1. Tick 触发 —— 活跃角色条件匹配,执行完整触发链并推送一炁事件
2. Tick 空跑 —— 无活跃角色时跳过
3. 反 spam 冷却 —— 触发后冷却期内 ``_can_trigger`` 返回 False
4. 反 spam 日上限 —— 达到日上限后返回 False
5. 静默时段 —— [23:00, 08:00) 返回 True,其余返回 False
6. 活跃角色列表 —— DB 查询正确返回 ``status='active'`` 的角色 ID
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.db.base import Base
# ── 模型注册 ──────────────────────────────────────────────────────────
# 在 DB 表创建前将模型注册到 Base.metadata,确保 create_all 创建对应表。
# conftest.py 已注册 ChatMessage / CuriosityQuestion / CharacterEmotionState / TimeAnniversary,
# 但未注册 Character 与 ChatSession。
from app.models.character import Character # noqa: F401
from app.models.chat import ChatSession # noqa: F401
from app.modules.knocking.conditions import ConditionResult
from app.modules.knocking.opener import OpenerResult
from app.modules.knocking.pivot import KnockingPivot
from app.services.personality import PersonalitySummary
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest_asyncio.fixture
async def keeper_session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
"""Yield an async_sessionmaker backed by an in-memory SQLite database."""
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
yield factory
await engine.dispose()
@pytest_asyncio.fixture
async def dbevent_store() -> AsyncGenerator:
"""In-memory event store (no DB needed, supports _events for assertion)."""
from app.modules.runtime.events import InMemoryEventStore
yield InMemoryEventStore()
@pytest_asyncio.fixture
async def keeper(
keeper_session_factory: async_sessionmaker[AsyncSession],
dbevent_store,
) -> AsyncGenerator[KnockingPivot, None]:
"""KnockingPivot instance backed by an in-memory SQLite database."""
pivot = KnockingPivot(
event_store=dbevent_store,
session_factory=keeper_session_factory,
tick_interval=3600, # 测试期间不真的 tick
)
yield pivot
# ======================================================================
# 测试
# ======================================================================
@pytest.mark.asyncio
async def test_tick_triggers_for_active_character(
keeper: KnockingPivot,
keeper_session_factory: async_sessionmaker[AsyncSession],
) -> None:
"""活跃角色 + 任意条件触发时,应完成完整触发链:开场 → 消息 → 事件。"""
async with keeper_session_factory() as session:
char = Character(
id="char_test_1",
name="测试人物",
status="active",
owner_scope="test",
language="zh-CN",
privacy_level="normal",
)
session.add(char)
await session.commit()
with (
patch.object(keeper.conditions[0], "check", AsyncMock(return_value=ConditionResult(triggered=True, trigger_reason="测试触发", condition_name="test"))),
patch(
"app.modules.knocking.pivot.build_personality_summary",
return_value=PersonalitySummary(text="你是一个测试人物。"),
),
patch(
"app.modules.knocking.pivot.generate_opener",
return_value=OpenerResult(text="测试开场白。"),
),
):
async with keeper_session_factory() as check_session:
processed = await keeper._check_and_trigger(
"char_test_1",
check_session,
now=datetime.now(UTC),
)
assert processed is True
# 验证消息已创建
async with keeper_session_factory() as session:
from sqlalchemy import select
result = await session.execute(
select(ChatSession).where(ChatSession.character_id == "char_test_1")
)
assert result.scalar_one_or_none() is not None
# 验证一炁事件已推送
events = keeper._event_store._events
assert len(events) > 0
last_event = events[-1]
assert last_event.source == "knocking_pivot"
assert last_event.character_id == "char_test_1"
@pytest.mark.asyncio
async def test_tick_skips_when_no_active_characters(keeper: KnockingPivot) -> None:
"""无活跃角色时 tick 不触发任何操作。"""
with (
patch("app.modules.knocking.pivot.create_proactive_message") as mock_create,
patch("app.modules.knocking.pivot.generate_opener"),
):
await keeper._tick()
mock_create.assert_not_called()
@pytest.mark.asyncio
async def test_cooldown_prevents_retrigger(keeper: KnockingPivot) -> None:
"""触发后冷却期内 _can_trigger 返回 False。"""
now = datetime.now(UTC)
assert keeper._can_trigger("char_1", now) is True
keeper._mark_triggered("char_1", now)
assert keeper._can_trigger("char_1", now) is False
# 冷却结束后应恢复
after_cooldown = now + timedelta(minutes=keeper._cooldown_minutes + 1)
assert keeper._can_trigger("char_1", after_cooldown) is True
@pytest.mark.asyncio
async def test_daily_limit_prevents_excess(keeper: KnockingPivot) -> None:
"""同一天达到上限后 _can_trigger 返回 False。"""
now = datetime.now(UTC)
assert keeper._can_trigger("char_1", now) is True
keeper._daily_counts["char_1"] = (now.date().isoformat(), keeper._daily_limit)
assert keeper._can_trigger("char_1", now) is False
# 第二天恢复
next_day = now + timedelta(days=1)
assert keeper._can_trigger("char_1", next_day) is True
def test_quiet_hours(keeper: KnockingPivot) -> None:
"""静默时段 23:00–08:00 _in_quiet_hours 返回 True。"""
keeper._quiet_hours_start = 23
keeper._quiet_hours_end = 8
# 静默时段内
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 23, 30, tzinfo=UTC)) is True
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 3, 0, tzinfo=UTC)) is True
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 7, 59, tzinfo=UTC)) is True
# 静默时段外
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 8, 0, tzinfo=UTC)) is False
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 12, 0, tzinfo=UTC)) is False
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 22, 59, tzinfo=UTC)) is False
@pytest.mark.asyncio
async def test_list_active_characters(
keeper: KnockingPivot,
keeper_session_factory: async_sessionmaker[AsyncSession],
) -> None:
"""_list_active_characters 只返回 status='active' 的角色。"""
async with keeper_session_factory() as session:
for i, status in enumerate(["active", "inactive", "paused", "active"]):
session.add(
Character(
id=f"char_{i}",
name=f"人物{i}",
status=status,
owner_scope="test",
language="zh-CN",
privacy_level="normal",
)
)
await session.commit()
async with keeper_session_factory() as session:
result = await keeper._list_active_characters(session)
assert set(result) == {"char_0", "char_3"}
@@ -1,93 +0,0 @@
"""灵核情绪状态数据访问测试(设计 §11.4)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.personality_emotion import CharacterEmotionState
from app.repositories import personality_emotion as emotion_repo
from app.services.characters import get_active_character_model
def _state(character_id: str, **overrides) -> CharacterEmotionState:
base = {
"character_id": character_id,
"mood": "touched",
"intensity": 0.7,
"baseline_mood": "neutral",
"baseline_intensity": 0.2,
"last_trigger_event_id": "evt_abc",
}
base.update(overrides)
return CharacterEmotionState(**base)
@pytest.mark.asyncio
async def test_get_state_returns_none_when_absent() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
assert await emotion_repo.get_state(session, character.id) is None
@pytest.mark.asyncio
async def test_upsert_state_inserts_new() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
created = await emotion_repo.upsert_state(session, _state(cid))
await session.commit()
assert created.mood == "touched"
assert created.intensity == 0.7
async with AsyncSessionLocal() as session:
fetched = await emotion_repo.get_state(session, cid)
assert fetched is not None
assert fetched.mood == "touched"
assert fetched.last_trigger_event_id == "evt_abc"
@pytest.mark.asyncio
async def test_upsert_state_updates_existing() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await emotion_repo.upsert_state(session, _state(cid, mood="touched", intensity=0.7))
await emotion_repo.upsert_state(
session,
_state(
cid,
mood="concerned",
intensity=0.5,
last_trigger_event_id="evt_def",
),
)
await session.commit()
async with AsyncSessionLocal() as session:
fetched = await emotion_repo.get_state(session, cid)
assert fetched is not None
assert fetched.mood == "concerned"
assert fetched.intensity == 0.5
assert fetched.last_trigger_event_id == "evt_def"
@pytest.mark.asyncio
async def test_upsert_state_single_row_per_character() -> None:
"""单行 per character:重复 upsert 不产生重复行。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await emotion_repo.upsert_state(session, _state(cid))
await emotion_repo.upsert_state(session, _state(cid))
await emotion_repo.upsert_state(session, _state(cid))
await session.commit()
async with AsyncSessionLocal() as session:
from sqlalchemy import func, select
count = int(
(
await session.execute(
select(func.count())
.select_from(CharacterEmotionState)
.where(CharacterEmotionState.character_id == cid)
)
).scalar_one()
)
assert count == 1
@@ -1,79 +0,0 @@
"""时态纪念日数据访问测试(辰正 §5.3)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.time_anniversary import TimeAnniversary
from app.repositories import time_anniversaries as anni_repo
from app.services.characters import get_active_character_model
def _anniversary(character_id: str, **overrides) -> TimeAnniversary:
base = {
"character_id": character_id,
"name": "我们的相遇日",
"calendar": "solar",
"date": "2025-09-19",
"recurs_yearly": True,
"kind": "anniversary",
"subject_type": "relationship",
"source": "auto",
"metadata_json": "{}",
}
base.update(overrides)
return TimeAnniversary(**base)
@pytest.mark.asyncio
async def test_create_and_get_anniversary() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
fetched = await anni_repo.get_anniversary(session, created.id)
assert fetched is not None
assert fetched.name == "我们的相遇日"
assert fetched.id.startswith("anniv_")
@pytest.mark.asyncio
async def test_list_anniversaries_by_character() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-03-01"))
await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-09-19"))
items = await anni_repo.list_anniversaries(session, character.id)
# 默认人物 seed 时已带 1 条 birthday 纪念日;此处只验证 anniversary kind 的列表行为
anniversary_items = [a for a in items if a.kind == "anniversary"]
assert len(anniversary_items) == 2
# list 默认按 date 升序
assert anniversary_items[0].date < anniversary_items[1].date
@pytest.mark.asyncio
async def test_update_anniversary() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
updated = await anni_repo.update_anniversary(
session, created.id, name="相识纪念日", source="manual"
)
assert updated is not None
assert updated.name == "相识纪念日"
assert updated.source == "manual"
@pytest.mark.asyncio
async def test_update_anniversary_missing_returns_none() -> None:
async with AsyncSessionLocal() as session:
result = await anni_repo.update_anniversary(session, "anniv_nonexistent", name="x")
assert result is None
@pytest.mark.asyncio
async def test_delete_anniversary() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
assert await anni_repo.delete_anniversary(session, created.id) is True
assert await anni_repo.get_anniversary(session, created.id) is None
assert await anni_repo.delete_anniversary(session, created.id) is False
@@ -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"
-87
View File
@@ -1,87 +0,0 @@
"""辰正 time API 端点测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=auth_headers,
) as http_client:
yield http_client
@pytest.mark.asyncio
async def test_get_config(client: AsyncClient) -> None:
response = await client.get("/api/v1/time/config")
assert response.status_code == 200
data = response.json()
assert data["user_timezone"] == "Asia/Shanghai"
assert data["default_city"] == "上海"
assert data["care_enabled"] is True
@pytest.mark.asyncio
async def test_anniversaries_crud(client: AsyncClient) -> None:
create = await client.post(
"/api/v1/time/anniversaries",
json={
"name": "测试纪念日",
"calendar": "solar",
"date": "2025-09-19",
"recurs_yearly": True,
"kind": "anniversary",
"subject_type": "relationship",
"source": "manual",
},
)
assert create.status_code == 201
anniv_id = create.json()["id"]
assert anniv_id.startswith("anniv_")
listed = await client.get("/api/v1/time/anniversaries")
assert listed.status_code == 200
assert any(a["id"] == anniv_id for a in listed.json())
patched = await client.patch(
f"/api/v1/time/anniversaries/{anniv_id}", json={"name": "改后的名字"}
)
assert patched.status_code == 200
assert patched.json()["name"] == "改后的名字"
deleted = await client.delete(f"/api/v1/time/anniversaries/{anniv_id}")
assert deleted.status_code == 204
not_found = await client.get(f"/api/v1/time/anniversaries/{anniv_id}")
assert not_found.status_code == 404
assert not_found.json()["error"]["code"] == "not_found"
@pytest.mark.asyncio
async def test_get_snapshot(client: AsyncClient) -> None:
# ASGITransport 可能不触发 lifespan 装配 event_store;get_event_store 未就绪时返回 503
response = await client.get("/api/v1/time/snapshot")
assert response.status_code in (200, 503)
if response.status_code == 200:
data = response.json()
assert "context_text" in data
assert "snapshot" in data
@pytest.mark.asyncio
async def test_get_acquaintance(client: AsyncClient) -> None:
# 与 /snapshot 同因:ASGITransport 可能不触发 lifespan;snapshot 降级时返回 503
response = await client.get("/api/v1/time/acquaintance")
assert response.status_code in (200, 503)
if response.status_code == 200:
data = response.json()
assert isinstance(data["total_days"], int)
assert "natural_language" in data
-56
View File
@@ -1,56 +0,0 @@
"""审计测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.application.chat.send_message import drain_pending_streams
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.safety import AuditEvent
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。"))
return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=auth_headers,
) as http_client:
yield http_client
@pytest.mark.asyncio
async def test_chat_and_memory_write_audit_events(client: AsyncClient) -> None:
session_response = await client.post("/api/v1/chat/sessions", json={"title": "审计"})
session_id = session_response.json()["session_id"]
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
memory = await client.post(
"/api/v1/memory",
json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"},
)
assert memory.status_code == 200
async with AsyncSessionLocal() as db:
actions = [item.action for item in (await db.execute(select(AuditEvent))).scalars().all()]
assert "run.started" in actions
assert "model.request.started" in actions
assert "model.response.completed" in actions
assert "memory.created" in actions
-100
View File
@@ -1,100 +0,0 @@
"""幂等键测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.application.chat.send_message import drain_pending_streams
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.chat import ChatMessage
from app.models.idempotency import IdempotencyKey
from app.models.memory import Memory
from app.models.runtime import Run
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。"))
return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=auth_headers,
) as http_client:
yield http_client
async def _create_session(client: AsyncClient) -> str:
response = await client.post("/api/v1/chat/sessions", json={"title": "幂等测试"})
assert response.status_code == 200
return str(response.json()["session_id"])
@pytest.mark.asyncio
async def test_send_message_idempotency_replays_same_response(client: AsyncClient) -> None:
session_id = await _create_session(client)
headers = {"Idempotency-Key": "send-same-1"}
payload = {"content": "你好", "modality": "text"}
first = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers
)
second = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers
)
assert first.status_code == 200
assert second.status_code == 200
assert second.json() == first.json()
await drain_pending_streams()
async with AsyncSessionLocal() as db:
runs = (await db.execute(select(Run))).scalars().all()
messages = (await db.execute(select(ChatMessage))).scalars().all()
keys = (await db.execute(select(IdempotencyKey))).scalars().all()
assert len(runs) == 1
assert len(messages) == 2
assert len(keys) == 1
@pytest.mark.asyncio
async def test_idempotency_key_conflict(client: AsyncClient) -> None:
session_id = await _create_session(client)
headers = {"Idempotency-Key": "send-conflict-1"}
first = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
headers=headers,
)
conflict = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "不同内容", "modality": "text"},
headers=headers,
)
assert first.status_code == 200
assert conflict.status_code == 409
assert conflict.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_memory_create_idempotency(client: AsyncClient) -> None:
headers = {"Idempotency-Key": "memory-same-1"}
payload = {"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"}
first = await client.post("/api/v1/memory", json=payload, headers=headers)
second = await client.post("/api/v1/memory", json=payload, headers=headers)
assert first.status_code == 200
assert second.status_code == 200
assert second.json()["id"] == first.json()["id"]
async with AsyncSessionLocal() as db:
memories = (await db.execute(select(Memory))).scalars().all()
assert len(memories) == 1
+208
View File
@@ -0,0 +1,208 @@
"""M2b 导入改造与组装管线测试。
- 导入 ST 卡(JSON V2)→ 可审阅灵胚(不直接建人物);启镜后 character_book 物化为世谱;
- 组装管线:{{char}}/{{user}} 符文在投影 prompt 中展开;火候(GenerationPreferences)
的 temperature/max_tokens 进入 llm.requested 的 IR。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.kernel import Kernel, KernelEventLog
from app.services.event_store import DbEventStore
from app.shenshi_client import EchoShenshi
_CARD_V2 = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "绯樱",
"description": "一位安静的图书馆管理员。",
"personality": "温柔、寡言",
"scenario": "午后的图书馆",
"first_mes": "……你来了。",
"mes_example": "",
"creator": "tester",
"character_version": "1.0",
"character_book": {
"entries": {
"0": {
"keys": ["图书馆", "书"],
"content": "图书馆只在午后开放。",
"enabled": True,
"insertion_order": 10,
},
"1": {
"keys": ["绯樱"],
"content": "绯樱从不谈论自己的过去。",
"enabled": True,
},
}
},
},
}
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def test_import_card_creates_reviewable_embryo(client: AsyncClient) -> None:
files = {"file": ("card.json", json.dumps(_CARD_V2).encode(), "application/json")}
imported = await client.post("/api/v1/characters/import-card", files=files)
assert imported.status_code == 201
body = imported.json()
assert body["source"] == "imported"
assert body["name"] == "绯樱"
assert body["status"] == "drafting"
assert body["book_entry_count"] == 2
# 未启镜:本用户名下无人物、无世谱(库内系统模板人物不计)
from sqlalchemy import func, select
from app.models.character import Character
from app.models.lorebook import Lorebook
me = (await client.get("/api/v1/auth/me")).json()
owner = me["id"]
async def _own_counts() -> tuple[int, int]:
async with AsyncSessionLocal() as session:
characters = int(
await session.scalar(
select(func.count(Character.id)).where(Character.owner_scope == owner)
)
)
books = int(
await session.scalar(
select(func.count(Lorebook.id)).where(Lorebook.owner_scope == owner)
)
)
return characters, books
# 导入只产灵胚:本用户人物/世谱数量不变(种子会预建一个默认人物,按差量断言)
before = await _own_counts()
assert before == await _own_counts()
# 启镜:物化人物 + 世谱书 + 条目(dict 形 entries 兼容)
activated = await client.post(f"/api/v1/embryo-drafts/{body['embryo_id']}/activate")
assert activated.status_code == 200
character_id = activated.json()["character_id"]
after = await _own_counts()
assert after[0] == before[0] + 1 # 启镜才产生人物
assert after[1] == before[1] + 1 # 启镜才物化世谱书
from app.models.lorebook import LorebookEntry
async with AsyncSessionLocal() as session:
books = (
await session.execute(select(Lorebook).where(Lorebook.character_id == character_id))
).scalars().all()
assert len(books) == 1
entries = (
await session.execute(
select(LorebookEntry).where(LorebookEntry.lorebook_id == books[0].id)
)
).scalars().all()
assert len(entries) == 2
by_key = {json.loads(e.keys_json)[0]: e for e in entries}
assert by_key["图书馆"].content == "图书馆只在午后开放。"
async def test_macro_expansion_and_preset_in_request(client: AsyncClient) -> None:
# 火候:按当前用户写一行 GenerationPreferences
from app.models.governance import GenerationPreferences
me = (await client.get("/api/v1/auth/me")).json()
async with AsyncSessionLocal() as session:
session.add(
GenerationPreferences(
owner_scope=me["id"],
default_model_hint="chat",
temperature=0.3,
max_output_tokens=512,
)
)
await session.commit()
# 建树(灵胚启镜后),问题含 {{user}} 符文;指引含 {{char}}
embryo = (
await client.post(
"/api/v1/embryo-drafts", json={"source": "original", "name": "符文灵"}
)
).json()
character_id = (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
tree = (
await client.post(
"/api/v1/trees", json={"character_id": character_id, "title": "符文树"}
)
).json()
q = (
await client.post(
f"/api/v1/trees/{tree['id']}/nodes",
json={"content": "{{user}} 想请教 {{char}} 一个问题"},
)
).json()
spawned = (
await client.post(f"/api/v1/nodes/{q['id']}/answers", json={"instructions": "请以 {{char}} 的口吻回答"})
).json()
for _ in range(300):
status = (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"]
if status in ("completed", "interrupted", "failed"):
break
await asyncio.sleep(0.02)
assert status == "completed"
# llm.requested 事件:符文已展开、火候参数已进入 IR
events = (await client.get(f"/api/v1/runs/{spawned['run_id']}/events")).json()
requested = next(e for e in events if e["type"] == "llm.requested")
ir = requested["payload"]["request"]
prompt = ir["messages"][-1]["content"]
assert "{{user}}" not in prompt and "{{char}}" not in prompt
assert "符文灵" in prompt # {{char}} 展开为人物名
assert ir["temperature"] == 0.3
assert ir["max_tokens"] == 512
async def test_import_v1_flat_card(client: AsyncClient) -> None:
"""V1 扁平卡(无 data 包装)同样产灵胚。"""
v1 = {
"name": "旧式卡",
"description": "一位老友。",
"personality": "爽朗",
"scenario": "巷口",
"first_mes": "来了?",
"mes_example": "",
}
files = {"file": ("card.json", json.dumps(v1).encode(), "application/json")}
imported = await client.post("/api/v1/characters/import-card", files=files)
assert imported.status_code == 201
body = imported.json()
assert body["name"] == "旧式卡"
assert body["book_entry_count"] == 0
-206
View File
@@ -1,206 +0,0 @@
"""send_message 接入 run_agent_turn 的验证测试(阶段2b 步骤B)。
mock 边界:
- ``app.application.chat.send_message.run_agent_turn`` —— 不真实跑 Agent 循环,
只校验 send_message 是否正确接入(传 tools/default_tools、on_chunk、on_tool_event)。
- ``app.application.chat.send_message.default_tools`` —— 校验被调用。
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.runtime import Event
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
# 清理前序(test_tools 等)残留的 app.state 避免污染
for _attr in ("tool_runtime", "confirmation_registry", "confirmation_store", "event_store"):
app.state.__dict__.pop(_attr, None)
agent_content = "agent 最终回复正文。"
async def fake_run_agent_turn(
*,
on_chunk: Any,
on_tool_event: Any,
**_kwargs: object,
) -> AgentTurnResult:
# 模拟 Agent 内部流:先一段 reasoning(若 thinking 启用)、再 content chunk
await on_chunk(ChatChunk(delta_reasoning="思考摘要"))
await on_chunk(ChatChunk(delta_content=agent_content))
# 模拟一次工具调用生命周期
await on_tool_event("tool.started", {"tool_name": "echo", "tool_call_id": "c1"})
await on_tool_event(
"tool.completed", {"tool_name": "echo", "tool_call_id": "c1", "status": "success"}
)
return AgentTurnResult(content=agent_content, reasoning="思考摘要")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _create_session(client: AsyncClient) -> str:
resp = await client.post("/api/v1/chat/sessions", json={"title": "agent 接入"})
assert resp.status_code == 200
return str(resp.json()["session_id"])
@pytest.mark.asyncio
async def test_send_message_invokes_run_agent_turn_with_tools_and_callbacks(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""send_message 调用 run_agent_turn 时传了 tools=default_tools()。"""
app.dependency_overrides.clear()
captured: dict[str, Any] = {}
captured_count: int = 0
real_default_tools = __import__(
"app.application.chat.send_message", fromlist=["default_tools"]
).default_tools
def spy_default_tools(*args: Any, **kwargs: Any) -> list[dict[str, Any]]:
nonlocal captured_count
captured_count += 1
captured["kwargs"] = kwargs
captured["tools"] = real_default_tools(*args, **kwargs)
return captured["tools"]
monkeypatch.setattr("app.application.chat.send_message.default_tools", spy_default_tools)
from app.application.chat.send_message import drain_pending_streams
session_id = await _create_session(client)
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
# default_tools 被调用,至少包含 5 个核心工具
assert captured_count > 0, f"spy_default_tools never called, captured={captured}"
assert "tools" in captured
tool_names = {t["function"]["name"] for t in captured["tools"]}
assert tool_names >= {"datetime", "read_file", "list_dir", "write_file", "shell"}
assert captured["tools"][0]["type"] == "function"
names = {t["function"]["name"] for t in captured["tools"]}
assert {"datetime", "shell"} <= names
@pytest.mark.asyncio
async def test_send_message_persists_agent_result_content_and_tool_events(
client: AsyncClient,
) -> None:
"""message.completed 的 content == agent_result.content;tool.* 事件落库 source=agent。"""
from app.application.chat.send_message import drain_pending_streams
session_id = await _create_session(client)
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text", "thinking": True},
)
assert sent.status_code == 200
run_id = str(sent.json()["run_id"])
await drain_pending_streams()
async with AsyncSessionLocal() as db:
events = (await db.execute(select(Event).where(Event.run_id == run_id))).scalars().all()
by_type: dict[str, list[Event]] = {}
for ev in events:
by_type.setdefault(ev.type, []).append(ev)
# 关键事件齐全
assert "run.started" in by_type
assert "message.delta" in by_type # on_chunk 触发了 content flush
assert "message.completed" in by_type
assert "run.completed" in by_type
# tool 事件存在且 source=agent
assert "tool.started" in by_type
assert "tool.completed" in by_type
assert all(ev.source == "agent" for ev in by_type["tool.started"])
assert all(ev.source == "agent" for ev in by_type["tool.completed"])
# message.completed 的 content == agent_result.content(权威值)
completed = by_type["message.completed"][-1]
import json
payload = json.loads(completed.payload_json)
assert payload["content"] == "agent 最终回复正文。"
@pytest.mark.asyncio
async def test_send_message_writes_extracted_session_goal_to_run(
client: AsyncClient,
) -> None:
"""太一最小闭环:会话目标从用户消息提取写入 run.goal,取代默认值。"""
from app.application.chat.send_message import drain_pending_streams
from app.models.runtime import Run
session_id = await _create_session(client)
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "帮我整理今天的笔记", "modality": "text"},
)
assert sent.status_code == 200
run_id = str(sent.json()["run_id"])
await drain_pending_streams()
async with AsyncSessionLocal() as db:
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
assert run.goal == "帮我整理今天的笔记"
@pytest.mark.asyncio
async def test_send_message_injects_session_goal_into_system_messages(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""洛书最小闭环:会话目标作为 system instruction 注入 ReAct。"""
from app.application.chat.send_message import drain_pending_streams
captured: dict[str, Any] = {}
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
captured["messages"] = messages
return AgentTurnResult(content="好的。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
session_id = await _create_session(client)
await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "帮我整理笔记", "modality": "text"},
)
await drain_pending_streams()
# to_langchain_messages 把每个 system message 作为独立 SystemMessage,
# 目标片段出现在某个 system message 中(非 messages[0])
system_text = "\n".join(
str(m.content) for m in captured["messages"] if getattr(m, "role", "") == "system"
)
assert "当前会话目标:帮我整理笔记" in system_text
-77
View File
@@ -1,77 +0,0 @@
"""SSE 契约测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.core.config import settings
from app.main import app
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
_content = "你好,我是测试模型回复,会根据你的输入生成足够长的内容用于 SSE 分块验证。" * 4
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
await on_chunk(ChatChunk(delta_content=_content))
return AgentTurnResult(content=_content, reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _completed_run(client: AsyncClient, content: str = "你好") -> str:
created = await client.post("/api/v1/chat/sessions", json={"title": "SSE"})
session_id = created.json()["session_id"]
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": content, "modality": "text"},
)
return str(sent.json()["run_id"])
@pytest.mark.asyncio
async def test_completed_run_stream_outputs_events(client: AsyncClient) -> None:
run_id = await _completed_run(client)
response = await client.get(f"/api/v1/runs/{run_id}/stream")
assert response.status_code == 200
text = response.text
assert "event: run.started" in text
assert "event: message.delta" in text
assert "event: message.completed" in text
assert "event: run.completed" in text
@pytest.mark.asyncio
async def test_completed_run_stream_outputs_chunked_message_deltas(client: AsyncClient) -> None:
run_id = await _completed_run(client, content="你好" * 80)
response = await client.get(f"/api/v1/runs/{run_id}/stream")
assert response.status_code == 200
text = response.text
assert text.count("event: message.delta") >= 2
assert "event: message.completed" in text
assert "event: run.completed" in text
@pytest.mark.asyncio
async def test_stream_not_found_and_disabled(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
not_found = await client.get("/api/v1/runs/run_missing/stream")
assert not_found.status_code == 404
assert not_found.json()["error"]["code"] == "not_found"
run_id = await _completed_run(client)
monkeypatch.setattr(settings, "enable_sse", False)
disabled = await client.get(f"/api/v1/runs/{run_id}/stream")
assert disabled.status_code == 400
assert disabled.json()["error"]["code"] == "invalid_request"