Files
hunjing/backend/tests/test_import_and_assembly.py
T

209 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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