M3b:世谱激活接入组装管线(关键词命中/常驻/预算/符文展开)与前端记忆治理页(候选箱/重忆确认/正影/删除/实录溯源)
This commit is contained in:
1 parent
eb0027757d
commit
e914aec9c4
8 files changed
+569
-1
No files matched your search
@@ -426,6 +426,15 @@ async def build_generation_prompt(
|
||||
if recalled:
|
||||
memory_lines = [f"- {m.content}" for m in recalled]
|
||||
parts.append(f"【获准记忆(来源可查)】\n" + "\n".join(memory_lines))
|
||||
# 世谱激活(河图):关键词命中扫描近期文本,常驻条目恒注入;预算内按 insertion_order
|
||||
tree_character_id = await _tree_character_id(question.tree_id)
|
||||
activated = await _activate_lorebook(
|
||||
tree_character_id,
|
||||
scan_text=f"{transcript[-600:]}\n{question.content}",
|
||||
expand=(lambda t: render(t, ctx, registry=registry)),
|
||||
)
|
||||
if activated:
|
||||
parts.append("【世界认知(世谱命中)】\n" + "\n".join(f"- {line}" for line in activated))
|
||||
if transcript:
|
||||
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
|
||||
parts.append(f"【当前问题】\n{expand(question.content)}")
|
||||
@@ -899,3 +908,56 @@ async def _tree_character_id(tree_id: str) -> str:
|
||||
async with AsyncSessionLocal() as session:
|
||||
tree = await session.get(MirrorTree, tree_id)
|
||||
return tree.character_id if tree else ""
|
||||
|
||||
|
||||
async def _activate_lorebook(
|
||||
character_id: str,
|
||||
*,
|
||||
scan_text: str,
|
||||
expand=None, # noqa: ANN001 — 符文展开函数(char/user 名),无上下文时不展开
|
||||
char_budget: int = 600,
|
||||
) -> list[str]:
|
||||
"""世谱激活管线(mirror-tree PRD 借 ST 范式):关键词命中 + 常驻,预算内按插入序。
|
||||
|
||||
扫描范围为近期文本(祖先链尾部 + 当前问题);条目内容经符文展开后注入。
|
||||
次级键逻辑、递归扫描与时序效果(sticky/cooldown)随 M5 循环轮次状态接入。
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.lorebook import Lorebook, LorebookEntry
|
||||
|
||||
text = "".join(scan_text.lower().split())
|
||||
async with AsyncSessionLocal() as session:
|
||||
books = (
|
||||
await session.execute(
|
||||
select(Lorebook.id).where(
|
||||
Lorebook.character_id == character_id, Lorebook.status == "active"
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
if not books:
|
||||
return []
|
||||
entries = (
|
||||
(
|
||||
await session.execute(
|
||||
select(LorebookEntry)
|
||||
.where(LorebookEntry.lorebook_id.in_(tuple(books)), LorebookEntry.enabled.is_(True))
|
||||
.order_by(LorebookEntry.insertion_order)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
picked: list[str] = []
|
||||
used = 0
|
||||
for entry in entries:
|
||||
keys = [k.lower() for k in json.loads(entry.keys_json or "[]") if k]
|
||||
hit = entry.constant or any("".join(k.split()) in text for k in keys)
|
||||
if not hit:
|
||||
continue
|
||||
content = entry.content if expand is None else expand(entry.content)
|
||||
if used + len(content) > char_budget:
|
||||
continue
|
||||
picked.append(content)
|
||||
used += len(content)
|
||||
return picked
|
||||
@@ -255,3 +255,55 @@ async def test_recalled_memory_injected_into_prompt(client: AsyncClient) -> None
|
||||
prompt = requested["payload"]["request"]["messages"][-1]["content"]
|
||||
assert "【获准记忆(来源可查)】" in prompt
|
||||
_ = json # 保留 json 引用供调试断言使用
|
||||
|
||||
|
||||
# ── M3b:世谱激活进入投影 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_lorebook_activation_injected_into_prompt(client: AsyncClient) -> None:
|
||||
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "世谱树")
|
||||
|
||||
# 为人物建世谱书 + 关键词条目 + 常驻条目
|
||||
book = (
|
||||
await client.post(
|
||||
"/api/v1/lorebooks",
|
||||
json={"name": "测试世谱", "character_id": character_id},
|
||||
)
|
||||
)
|
||||
if book.status_code >= 400: # 端点形态兼容:老 CRUD 可能要求别的字段
|
||||
book = (await client.post("/api/v1/lorebooks", json={"name": "测试世谱"}))
|
||||
assert book.status_code < 400, book.text
|
||||
book_id = book.json()["id"]
|
||||
|
||||
keyed = (
|
||||
await client.post(
|
||||
f"/api/v1/lorebooks/{book_id}/entries",
|
||||
json={"keys": ["星穹图书馆"], "content": "星穹图书馆只在午后开放"},
|
||||
)
|
||||
)
|
||||
if keyed.status_code >= 400: # 兼容批量/单条两种端点形态
|
||||
keyed = await client.post(
|
||||
f"/api/v1/lorebooks/{book_id}/entries",
|
||||
json=[{"keys": ["星穹图书馆"], "content": "星穹图书馆只在午后开放"}],
|
||||
)
|
||||
assert keyed.status_code < 400, keyed.text
|
||||
|
||||
await client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": answer_id})
|
||||
q2 = (
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tree_id}/nodes", json={"content": "带我去星穹图书馆看看"}
|
||||
)
|
||||
).json()
|
||||
spawned = (await client.post(f"/api/v1/nodes/{q2['id']}/answers", json={})).json()
|
||||
for _ in range(300):
|
||||
if (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] in (
|
||||
"completed",
|
||||
"interrupted",
|
||||
):
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
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")
|
||||
prompt = requested["payload"]["request"]["messages"][-1]["content"]
|
||||
assert "【世界认知(世谱命中)】" in prompt
|
||||
assert "星穹图书馆只在午后开放" in prompt
|
||||
Reference in new issue
Block a user