209 lines
7.4 KiB
Python
209 lines
7.4 KiB
Python
"""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
|