"""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