M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路
This commit is contained in:
1 parent
e6a3b7121f
commit
5f36e3785c
86 files changed
+424
-14455
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user