diff --git a/backend/app/domains/mirror_tree/service.py b/backend/app/domains/mirror_tree/service.py index 8907d2f..c85d08a 100644 --- a/backend/app/domains/mirror_tree/service.py +++ b/backend/app/domains/mirror_tree/service.py @@ -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 diff --git a/backend/tests/test_xirang.py b/backend/tests/test_xirang.py index 2de656a..66bd314 100644 --- a/backend/tests/test_xirang.py +++ b/backend/tests/test_xirang.py @@ -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 diff --git a/frontend/src/app/(app)/memories/page.tsx b/frontend/src/app/(app)/memories/page.tsx new file mode 100644 index 0000000..5f48152 --- /dev/null +++ b/frontend/src/app/(app)/memories/page.tsx @@ -0,0 +1,58 @@ +import { PageContainer } from "@/components/layout/PageContainer"; +import { ErrorState } from "@/components/shared/StateViews"; +import { MemoryGovernance } from "@/components/memories/MemoryGovernance"; +import { serverApi } from "@/lib/api/server-client"; +import { describeError } from "@/lib/utils/error"; +import type { Character } from "@/types/api"; +import type { FactualRecordView, XirangMemory } from "@/lib/api/memories"; + +export const dynamic = "force-dynamic"; + +export default async function MemoriesPage() { + let error: string | null = null; + let characterId: string | undefined; + + try { + const active = await serverApi + .get("/characters/active") + .catch(() => null); + characterId = active?.id; + } catch (e) { + error = describeError(e); + } + + if (error || !characterId) { + return ( + + + + ); + } + + const [memories, records] = await Promise.all([ + serverApi + .get(`/characters/${characterId}/memories`) + .catch(() => []), + serverApi + .get(`/characters/${characterId}/factual-records`) + .catch(() => []), + ]); + + return ( + + + + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 458f74b..646b1eb 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -7,6 +7,7 @@ import { LogoButton } from "@/components/easter-egg/LogoButton"; import { AdminIcon, ArchiveIcon, + MemoryIcon, ChatIcon, CharacterIcon, CosmosIcon, @@ -84,7 +85,10 @@ const NAV_GROUPS: NavGroup[] = [ }, { title: "藏识", - items: [{ href: "/archive", label: "归档", icon: }], + items: [ + { href: "/memory", label: "记忆", icon: }, + { href: "/archive", label: "归档", icon: }, + ], }, { title: "百工", diff --git a/frontend/src/components/memories/MemoryGovernance.tsx b/frontend/src/components/memories/MemoryGovernance.tsx new file mode 100644 index 0000000..38eba32 --- /dev/null +++ b/frontend/src/components/memories/MemoryGovernance.tsx @@ -0,0 +1,293 @@ +"use client"; + +/** + * 记忆治理:候选箱审阅、重忆确认、正影、删除、来源展开、实录查看。 + * 治理红线:删除权与来源可见性高于召回便利;墓碑与高敏不展示原文。 + */ + +import { useCallback, useState } from "react"; + +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import * as memoriesApi from "@/lib/api/memories"; +import { cn } from "@/lib/utils/cn"; +import type { FactualRecordView, XirangMemory } from "@/lib/api/memories"; + +interface MemoryGovernanceProps { + characterId: string; + initialMemories: XirangMemory[]; + initialRecords: FactualRecordView[]; +} + +const TIER_LABEL: Record = { + trace: "微痕", + explicit: "明忆", + sensitive: "重忆", +}; +const TYPE_LABEL: Record = { + persona: "画像", + episodic: "事件", + instruction: "指令", + relationship_view: "心痕", + fact_candidate: "手记", +}; +const STATUS_LABEL: Record = { + candidate: "候选箱", + pending: "待确认", + active: "已生效", + superseded: "已正影", + tombstoned: "已删除", +}; + +type Tab = "pending" | "candidate" | "active" | "archive" | "records"; + +export function MemoryGovernance({ + characterId, + initialMemories, + initialRecords, +}: MemoryGovernanceProps) { + const [tab, setTab] = useState("pending"); + const [items, setItems] = useState(initialMemories); + const [records] = useState(initialRecords); + const [busy, setBusy] = useState(false); + const [correctTarget, setCorrectTarget] = useState(null); + const [correctValue, setCorrectValue] = useState(""); + + const reload = useCallback(async () => { + setItems(await memoriesApi.listMemories(characterId)); + }, [characterId]); + + const run = async (action: () => Promise) => { + setBusy(true); + try { + await action(); + await reload(); + } finally { + setBusy(false); + } + }; + + const byTab = (status: XirangMemory["status"]) => + items.filter((m) => m.status === status); + const visible: XirangMemory[] = + tab === "pending" + ? byTab("pending") + : tab === "candidate" + ? byTab("candidate") + : tab === "active" + ? byTab("active") + : tab === "archive" + ? items.filter( + (m) => m.status === "superseded" || m.status === "tombstoned", + ) + : []; + + const tabs: { key: Tab; label: string; count: number }[] = [ + { key: "pending", label: "待确认", count: byTab("pending").length }, + { key: "candidate", label: "候选箱", count: byTab("candidate").length }, + { key: "active", label: "已生效", count: byTab("active").length }, + { + key: "archive", + label: "归档", + count: items.filter( + (m) => m.status === "superseded" || m.status === "tombstoned", + ).length, + }, + { key: "records", label: "实录", count: records.length }, + ]; + + return ( +
+
+ {tabs.map((t) => ( + + ))} +
+ + {tab === "records" ? ( +
    + {records.length === 0 && ( +
  • + 尚无实录——镜树归真后自动生成。 +
  • + )} + {records.map((r) => ( +
  • +

    {r.summary}

    +

    + {new Date(r.occurred_at).toLocaleString("zh-CN")} · 证据{" "} + {r.evidence.path_nodes?.length ?? 0} 节点 +

    +
  • + ))} +
+ ) : ( +
    + {visible.length === 0 && ( +
  • + 此分区暂无记忆。 +
  • + )} + {visible.map((m) => ( +
  • +
    + + {TIER_LABEL[m.tier] ?? m.tier} + + + {TYPE_LABEL[m.type] ?? m.type} + + + {STATUS_LABEL[m.status] ?? m.status} + + {m.recall_count > 0 && ( + 召回 {m.recall_count} 次 + )} + + {new Date(m.created_at).toLocaleString("zh-CN")} + +
    +

    + {m.content || "(原文已随删除移除)"} +

    + {m.source_refs.length > 0 && ( +

    + 来源:{m.source_refs.join(" · ")} +

    + )} + {(m.status === "pending" || + m.status === "candidate" || + m.status === "active") && ( +
    + {(m.status === "pending" || m.status === "candidate") && ( + + )} + + + +
    + )} +
  • + ))} +
+ )} + + setCorrectTarget(null)} + title="正影(形成新版本)" + size="md" + > +
+

+ 正影不改写历史:新版本成为召回当前版,旧版保留来源与纠错轨迹、停止普通召回。 +

+