M2b:删除旧 chat 体系——线性会话/叩心/情绪/纪念日模型与页面退役(迁移 003 双向验证),runs 去会话外键,测试全量修剪
This commit is contained in:
1 parent
5f36e3785c
commit
ad9c9e942a
22 files changed
+424
-427
No files matched your search
@@ -74,16 +74,6 @@ async def reset_database() -> AsyncGenerator[None, None]:
|
||||
from app.services.ai_provider_admin import set_endpoint_overrides_cache
|
||||
|
||||
set_endpoint_overrides_cache([])
|
||||
# 清理可能被前序 TestClient 启动的 KnockingPivot 后台任务,避免写锁冲突
|
||||
from app.main import app as _app
|
||||
|
||||
pivot = getattr(_app.state, "knocking_pivot", None)
|
||||
if pivot is not None:
|
||||
try: # noqa: SIM105 — pivot.stop() 是协程,contextlib.suppress 不适用
|
||||
await pivot.stop()
|
||||
except Exception:
|
||||
pass
|
||||
_app.state.__dict__.pop("knocking_pivot", None)
|
||||
yield
|
||||
_assert_test_database_url(str(_engine.url))
|
||||
async with _engine.begin() as conn:
|
||||
|
||||
@@ -1,55 +1,39 @@
|
||||
"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。"""
|
||||
"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。
|
||||
|
||||
纪念日表(time_anniversaries)随旧时间模型退役删除,本文件只验证 profile 兜底;
|
||||
辰正(M8 重实现)落地后生日提醒改按 profile.birthday 计算。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.repositories import time_anniversaries as anni_repo
|
||||
from app.repositories.json_utils import loads_dict
|
||||
from app.schemas.character import CreateCharacterRequest
|
||||
from app.services.characters import create_character, create_character_from_template
|
||||
|
||||
|
||||
def _birthday_anniversaries(items: list) -> list:
|
||||
return [a for a in items if a.kind == "birthday"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_character_without_birthday_applies_default():
|
||||
"""未传 birthday → profile.birthday 与纪念日均取 created_at 的 ISO 日期。"""
|
||||
"""未传 birthday → profile.birthday 取 created_at 的 ISO 日期。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
resp = await create_character(session, CreateCharacterRequest(name="lingxi"))
|
||||
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
|
||||
character = await session.get(Character, resp.id)
|
||||
|
||||
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
||||
assert resp.profile["birthday"] == birthday_iso
|
||||
|
||||
birthday_annivs = _birthday_anniversaries(anniversaries)
|
||||
assert len(birthday_annivs) == 1
|
||||
a = birthday_annivs[0]
|
||||
assert a.date == birthday_iso
|
||||
assert a.calendar == "solar"
|
||||
assert a.recurs_yearly is True
|
||||
assert a.kind == "birthday"
|
||||
assert a.subject_type == "character_self"
|
||||
assert a.source == "auto"
|
||||
# display_name 为 None → 回落到 name
|
||||
assert a.name == "lingxi·生辰"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_character_with_birthday_keeps_user_value():
|
||||
"""用户传了 birthday(即便为自由文本)→ 保持用户值,且不自动建生日纪念日。"""
|
||||
"""用户传了 birthday(即便为自由文本)→ 保持用户值。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
resp = await create_character(
|
||||
session,
|
||||
CreateCharacterRequest(name="jingwu", profile={"birthday": "霜降之夜"}),
|
||||
)
|
||||
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
|
||||
|
||||
assert resp.profile["birthday"] == "霜降之夜"
|
||||
assert _birthday_anniversaries(anniversaries) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -58,75 +42,53 @@ async def test_create_character_with_blank_birthday_applies_default():
|
||||
async with AsyncSessionLocal() as session:
|
||||
resp = await create_character(
|
||||
session,
|
||||
CreateCharacterRequest(name="chenyu", profile={"birthday": " "}),
|
||||
CreateCharacterRequest(name="yuelan", profile={"birthday": " "}),
|
||||
)
|
||||
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
|
||||
character = await session.get(Character, resp.id)
|
||||
|
||||
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
||||
assert resp.profile["birthday"] == birthday_iso
|
||||
assert len(_birthday_anniversaries(anniversaries)) == 1
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await session.get(Character, resp.id)
|
||||
assert resp.profile["birthday"] == character.created_at.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _template(key: str, profile_json: str) -> Character:
|
||||
return Character(
|
||||
name=key,
|
||||
display_name="模板",
|
||||
status="active",
|
||||
language="zh-CN",
|
||||
profile_json=profile_json,
|
||||
model_policy_json="{}",
|
||||
privacy_level="normal",
|
||||
owner_scope="system",
|
||||
is_system_template=True,
|
||||
source_template_id=None,
|
||||
template_key=key,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_character_from_template_without_birthday_uses_own_created_at():
|
||||
"""模板 profile 无 birthday → 子角色用自己的 created_at 兜底。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
template = Character(
|
||||
name="tpl",
|
||||
display_name="模板",
|
||||
status="active",
|
||||
language="zh-CN",
|
||||
profile_json='{"background": "测试模板"}', # 无 birthday
|
||||
model_policy_json="{}",
|
||||
privacy_level="normal",
|
||||
owner_scope="system",
|
||||
is_system_template=True,
|
||||
source_template_id=None,
|
||||
template_key="test_tpl",
|
||||
)
|
||||
template = _template("test_tpl", '{"background": "测试模板"}')
|
||||
session.add(template)
|
||||
await session.flush()
|
||||
|
||||
character = await create_character_from_template(session, template, "local-user")
|
||||
profile = loads_dict(character.profile_json)
|
||||
anniversaries = await anni_repo.list_anniversaries(session, character.id)
|
||||
|
||||
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
||||
assert profile["birthday"] == birthday_iso
|
||||
birthday_annivs = _birthday_anniversaries(anniversaries)
|
||||
assert len(birthday_annivs) == 1
|
||||
assert birthday_annivs[0].date == birthday_iso
|
||||
# display_name 优先于 name
|
||||
assert birthday_annivs[0].name == "模板·生辰"
|
||||
assert profile["birthday"] == character.created_at.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_character_from_template_inherits_explicit_birthday():
|
||||
"""模板 profile 有显式 birthday → 子角色继承该值,不兜底。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
template = Character(
|
||||
name="tpl2",
|
||||
display_name="模板二",
|
||||
status="active",
|
||||
language="zh-CN",
|
||||
profile_json='{"birthday": "2020-01-01"}',
|
||||
model_policy_json="{}",
|
||||
privacy_level="normal",
|
||||
owner_scope="system",
|
||||
is_system_template=True,
|
||||
source_template_id=None,
|
||||
template_key="test_tpl2",
|
||||
)
|
||||
template = _template("test_tpl2", '{"birthday": "2020-01-01"}')
|
||||
session.add(template)
|
||||
await session.flush()
|
||||
|
||||
character = await create_character_from_template(session, template, "local-user")
|
||||
profile = loads_dict(character.profile_json)
|
||||
anniversaries = await anni_repo.list_anniversaries(session, character.id)
|
||||
|
||||
assert profile["birthday"] == "2020-01-01" # 继承模板的显式 birthday
|
||||
birthday_annivs = _birthday_anniversaries(anniversaries)
|
||||
assert len(birthday_annivs) == 1
|
||||
assert birthday_annivs[0].date == "2020-01-01"
|
||||
assert birthday_annivs[0].name == "模板二·生辰"
|
||||
assert profile["birthday"] == "2020-01-01"
|
||||
@@ -26,7 +26,6 @@ async def _seed_trace_data() -> None:
|
||||
Run(
|
||||
id="run_001",
|
||||
character_id="char_test",
|
||||
session_id="sess_test",
|
||||
status="completed",
|
||||
origin="chat",
|
||||
created_at=BASE_TIME,
|
||||
@@ -105,7 +104,7 @@ async def test_get_trace_aggregates_and_sorts() -> None:
|
||||
assert detail.trace_id == TRACE_ID
|
||||
assert detail.run_ids == ["run_001"]
|
||||
assert detail.character_ids == ["char_test"]
|
||||
assert detail.session_ids == ["sess_test"]
|
||||
assert detail.session_ids == [] # 会话归属随线性会话退役
|
||||
# 五条记录按 created_at 升序归并
|
||||
assert [item.kind for item in detail.items] == [
|
||||
"event",
|
||||
@@ -180,7 +179,7 @@ async def test_list_traces_aggregates_and_sorts_recent_first() -> None:
|
||||
assert s.audit_count == 2
|
||||
assert s.idempotency_count == 1
|
||||
assert s.character_ids == ["char_test"]
|
||||
assert s.session_ids == ["sess_test"]
|
||||
assert s.session_ids == [] # 会话归属随线性会话退役
|
||||
# 按 last_seen 倒序
|
||||
assert all(
|
||||
resp.items[i].last_seen >= resp.items[i + 1].last_seen for i in range(len(resp.items) - 1)
|
||||
|
||||
@@ -5,10 +5,8 @@ from collections.abc import AsyncGenerator
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.api.v1.deps import require_admin
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
from app.main import app
|
||||
|
||||
|
||||
@@ -16,11 +14,6 @@ from app.main import app
|
||||
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,
|
||||
@@ -41,136 +34,6 @@ async def test_health_and_defaults(client: AsyncClient) -> None:
|
||||
assert personality.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_run_and_memory_crud(client: AsyncClient) -> None:
|
||||
created = await client.post("/api/v1/chat/sessions", json={"title": "测试"})
|
||||
assert created.status_code == 200
|
||||
session_id = created.json()["session_id"]
|
||||
|
||||
sent = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
run_id = sent.json()["run_id"]
|
||||
await drain_pending_streams()
|
||||
run = await client.get(f"/api/v1/runs/{run_id}")
|
||||
assert run.status_code == 200
|
||||
assert run.json()["status"] == "completed"
|
||||
|
||||
memory = await client.post(
|
||||
"/api/v1/memory",
|
||||
json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"},
|
||||
)
|
||||
assert memory.status_code == 200
|
||||
memory_id = memory.json()["id"]
|
||||
listed = await client.get("/api/v1/memory")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["total"] == 1
|
||||
updated = await client.patch(f"/api/v1/memory/{memory_id}", json={"importance": 0.9})
|
||||
assert updated.status_code == 200
|
||||
deleted = await client.delete(f"/api/v1/memory/{memory_id}")
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["status"] == "deleted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_session_and_messages_query_api(client: AsyncClient) -> None:
|
||||
created = await client.post("/api/v1/chat/sessions", json={"title": "查询测试"})
|
||||
assert created.status_code == 200
|
||||
created_body = created.json()
|
||||
session_id = created_body["session_id"]
|
||||
|
||||
listed = await client.get("/api/v1/chat/sessions")
|
||||
assert listed.status_code == 200
|
||||
listed_body = listed.json()
|
||||
assert listed_body["total"] >= 1
|
||||
assert any(item["session_id"] == session_id for item in listed_body["items"])
|
||||
|
||||
fetched = await client.get(f"/api/v1/chat/sessions/{session_id}")
|
||||
assert fetched.status_code == 200
|
||||
fetched_body = fetched.json()
|
||||
assert fetched_body["session_id"] == session_id
|
||||
assert fetched_body["title"] == "查询测试"
|
||||
assert fetched_body["status"] == "active"
|
||||
|
||||
sent = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "查询消息", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
# 流式化后 send_message 立即返回,assistant_message_id 此刻为空,需等待后台任务定稿。
|
||||
await drain_pending_streams()
|
||||
|
||||
messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
||||
assert messages.status_code == 200
|
||||
messages_body = messages.json()
|
||||
assert messages_body["total"] == 2
|
||||
assert [item["role"] for item in messages_body["items"]] == ["user", "assistant"]
|
||||
assert messages_body["items"][0]["content"] == "查询消息"
|
||||
assert messages_body["items"][1]["message_id"].startswith("message_")
|
||||
|
||||
missing = await client.get("/api/v1/chat/sessions/session_missing")
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["error"]["code"] == "not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_session_update_and_delete(client: AsyncClient) -> None:
|
||||
created = await client.post("/api/v1/chat/sessions", json={"title": "待删除"})
|
||||
assert created.status_code == 200
|
||||
session_id = created.json()["session_id"]
|
||||
|
||||
# 发两条消息制造关联数据(覆盖 messages 软删路径)
|
||||
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()
|
||||
messages_before = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
||||
assert messages_before.status_code == 200
|
||||
assert messages_before.json()["total"] == 2
|
||||
|
||||
# PATCH 重命名
|
||||
renamed = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"title": "重命名后"})
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json()["title"] == "重命名后"
|
||||
assert renamed.json()["status"] == "active"
|
||||
|
||||
# PATCH 归档
|
||||
archived = await client.patch(
|
||||
f"/api/v1/chat/sessions/{session_id}", json={"status": "archived"}
|
||||
)
|
||||
assert archived.status_code == 200
|
||||
assert archived.json()["status"] == "archived"
|
||||
|
||||
# PATCH 禁止直接置 deleted
|
||||
bad = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"status": "deleted"})
|
||||
assert bad.status_code == 400
|
||||
|
||||
# DELETE 软删除 → 204
|
||||
deleted = await client.delete(f"/api/v1/chat/sessions/{session_id}")
|
||||
assert deleted.status_code == 204
|
||||
|
||||
# 删除后 GET 该会话 404
|
||||
gone = await client.get(f"/api/v1/chat/sessions/{session_id}")
|
||||
assert gone.status_code == 404
|
||||
|
||||
# 列表不再包含
|
||||
listed = await client.get("/api/v1/chat/sessions")
|
||||
assert listed.status_code == 200
|
||||
assert not any(item["session_id"] == session_id for item in listed.json()["items"])
|
||||
|
||||
# 消息也被软删:总条数为 0
|
||||
messages_after = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
||||
assert messages_after.status_code == 404 # 会话已 deleted,查询应拒绝
|
||||
|
||||
# 重复删除仍返回 404
|
||||
repeat = await client.delete(f"/api/v1/chat/sessions/{session_id}")
|
||||
assert repeat.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None:
|
||||
# 初始当前人格为 v1
|
||||
@@ -252,42 +115,6 @@ async def test_personality_propose_approve_reject_and_rollback(client: AsyncClie
|
||||
app.dependency_overrides.pop(require_admin, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_version_selection(client: AsyncClient) -> None:
|
||||
"""选择/激活消息版本——覆盖 services/chat.select_message_version 路径。"""
|
||||
created = await client.post("/api/v1/chat/sessions", json={"title": "版本测试"})
|
||||
assert created.status_code == 200
|
||||
session_id = created.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()
|
||||
|
||||
messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
||||
assert messages.status_code == 200
|
||||
items = messages.json()["items"]
|
||||
assert len(items) == 2
|
||||
assistant_msg = next(m for m in items if m["role"] == "assistant")
|
||||
msg_id = assistant_msg["message_id"]
|
||||
|
||||
# 选择不存在的版本索引 → 400
|
||||
bad = await client.patch(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version",
|
||||
json={"version_idx": 99},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
# 选择索引 0(当前版本)
|
||||
ok = await client.patch(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version",
|
||||
json={"version_idx": 0},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skills_crud(client: AsyncClient) -> None:
|
||||
"""Skill 能力包 CRUD——覆盖 services/skills 路径。"""
|
||||
|
||||
@@ -34,23 +34,8 @@ async def test_error_contracts(client: AsyncClient) -> None:
|
||||
assert missing_run.status_code == 404
|
||||
assert_error(missing_run, "not_found")
|
||||
|
||||
validation = await client.post("/api/v1/chat/sessions", json={"title": "x" * 300})
|
||||
validation = await client.post("/api/v1/runs", json={"prompt": ""})
|
||||
assert validation.status_code == 422
|
||||
assert_error(validation, "validation_failed")
|
||||
|
||||
created = await client.post("/api/v1/chat/sessions", json={"title": "错误"})
|
||||
session_id = created.json()["session_id"]
|
||||
unsupported = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "image"},
|
||||
)
|
||||
assert unsupported.status_code == 415
|
||||
assert_error(unsupported, "unsupported_media_type")
|
||||
|
||||
too_large = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "x" * 40000, "modality": "text"},
|
||||
)
|
||||
# 当前消息内容无长度上限限制,接受 200;若后续加回来再改为 assert 413
|
||||
if too_large.status_code == 413:
|
||||
assert_error(too_large, "payload_too_large")
|
||||
# 旧 chat 会话错误契约随线性会话模型退役;415/413 契约由镜树/媒体域随需要重建
|
||||
@@ -30,7 +30,7 @@ async def test_event_payload_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
character_id = await _admin_character_id()
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = await create_run(
|
||||
db, character_id=character_id, session_id=None, model_hint="default-chat"
|
||||
db, character_id=character_id, model_hint="default-chat"
|
||||
)
|
||||
with pytest.raises(AppError) as exc:
|
||||
await append_event(
|
||||
@@ -49,7 +49,7 @@ async def test_run_max_events(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
character_id = await _admin_character_id()
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = await create_run(
|
||||
db, character_id=character_id, session_id=None, model_hint="default-chat"
|
||||
db, character_id=character_id, model_hint="default-chat"
|
||||
)
|
||||
await append_event(
|
||||
db, run_id=run.id, character_id=character_id, event_type="message.delta", payload={}
|
||||
@@ -70,7 +70,7 @@ async def test_cancel_run() -> None:
|
||||
character_id = await _admin_character_id()
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = await create_run(
|
||||
db, character_id=character_id, session_id=None, model_hint="default-chat"
|
||||
db, character_id=character_id, model_hint="default-chat"
|
||||
)
|
||||
await db.commit()
|
||||
response = await cancel_run(run.id)
|
||||
|
||||
Reference in new issue
Block a user