M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)

This commit is contained in:
chenxin committed 2026-08-27 09:16:32 +08:00
1 parent 2a640a901b
commit 9c98c2f85e
69 files changed
+1193 -6033

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
"""息壤(记忆域):三级记忆、实录与召回(data-model.md §3)。"""
from app.domains.xirang.service import (
confirm_memory,
correct_memory,
delete_memory,
deny_memory,
memorize_node,
on_canonized,
place_candidates,
recall_for_generation,
record_factual,
)
__all__ = [
"confirm_memory",
"correct_memory",
"delete_memory",
"deny_memory",
"memorize_node",
"on_canonized",
"place_candidates",
"recall_for_generation",
"record_factual",
]
+504
View File
@@ -0,0 +1,504 @@
"""息壤(记忆域)服务:提取管线、分级落位、确认流、正影、删除、检索与激活。
铁律(memory-model.md §5):只有归真结果(或用户手动入忆)能产生长期记忆候选——
微痕不自动升级;未归真旁枝自动入忆率必须为 0(测试钉死)。
"""
from __future__ import annotations
import asyncio
import json
import math
import re
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.kernel import Kernel
from app.models.mirror_tree import Canonization, TreeNode
from app.models.xirang import FactualRecord, Memory, MemoryTrigger
# 微痕默认半衰期(天);命中强化 +0.08、上限 3.0(借鉴 Memory-Palace 活力模型)
TRACE_HALF_LIFE_DAYS = 7.0
VITALITY_HIT_BONUS = 0.08
VITALITY_MAX = 3.0
# 召回预算:条数与字符上限
RECALL_LIMIT = 5
RECALL_CHAR_BUDGET = 1200
# 明忆白名单(PRD 三级记忆):低敏客观类自动生效带撤销窗;心痕等主观类入候选箱
EXPLICIT_AUTO_TYPES = ("persona", "instruction", "episodic")
# deny 两次退出检索
DENY_EXIT_THRESHOLD = 2
deny_counts: dict[str, int] = {}
# ── 归真触发:实录 + 提取 ──────────────────────────────────────────────
async def on_canonized(canonization_id: str) -> None:
"""归真后置(由 canonize 同步调用):实录落库 + 调度异步提取。"""
await record_factual(canonization_id)
asyncio.get_running_loop().create_task(_extract_guarded(canonization_id))
async def _extract_guarded(canonization_id: str) -> None:
try:
await _extract_after_canonized(canonization_id)
except Exception as exc: # noqa: BLE001 — 后台任务异常必须可见
from app.core.logging import log_with_context
log_with_context(f"息壤提取任务失败:{exc}", level="ERROR")
async def _extract_after_canonized(canonization_id: str) -> None:
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_ids = json.loads(record.path_nodes_json)
nodes = []
for nid in path_ids:
node = await session.get(TreeNode, nid)
if node is not None:
nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in nodes
)
character_id = record.character_id
await _extract_candidates(
character_id=character_id, canonization_id=canonization_id, transcript=transcript
)
async def record_factual(canonization_id: str) -> None:
"""实录(同步,随归真事务后立即落库)。"""
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_nodes = []
for nid in json.loads(record.path_nodes_json):
node = await session.get(TreeNode, nid)
if node is not None:
path_nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in path_nodes
)
summary = f"[{record.scope}] " + " / ".join(n.content[:40] for n in path_nodes[-2:])
session.add(
FactualRecord(
character_id=record.character_id,
canonization_id=canonization_id,
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
summary=summary,
)
)
character_id = record.character_id
scope = record.scope
await session.commit()
_ = scope
async def _extract_candidates(*, character_id: str, canonization_id: str, transcript: str) -> None:
"""提取管线:LLM 抽取 JSON 候选;解析失败回落为确定性单条摘要候选(不阻塞闭环)。"""
from app.shenshi_client.types import ShenshiChatRequest, ShenshiMessage
kernel: Kernel | None = _kernel_ref()
raw = ""
if kernel is not None:
request = ShenshiChatRequest(
messages=[
ShenshiMessage(
role="system",
content=(
"你是记忆抽取器。从对话实录中抽取长期记忆候选,"
'输出 JSON 数组 [{"content","type","priority","confidence","sensitive"}],'
'type ∈ persona/episodic/instruction/relationship_view;宁缺毋滥,无值得记忆的内容输出 []。'
),
),
ShenshiMessage(role="user", content=transcript[:8000]),
],
run_id=f"extract_{canonization_id}",
)
parts: list[str] = []
try:
async for delta in kernel.shenshi.stream_chat(request):
parts.append(delta.delta)
raw = "".join(parts)
except Exception: # noqa: BLE001 — 提取失败回落确定性路径
raw = ""
candidates = _parse_candidates(raw)
if not candidates:
candidates = [
{
"content": f"共同经历:{transcript[-200:]}",
"type": "episodic",
"priority": 50,
"confidence": 0.6,
"sensitive": False,
}
]
await place_candidates(
character_id=character_id,
candidates=candidates,
source_refs=[canonization_id],
source="self_inferred",
realm="reality",
)
def _parse_candidates(raw: str) -> list[dict]:
match = re.search(r"\[.*\]", raw, re.S)
if not match:
return []
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
out: list[dict] = []
for item in data:
if isinstance(item, dict) and item.get("content"):
out.append(item)
return out
async def place_candidates(
*,
character_id: str,
candidates: list[dict],
source_refs: list[str],
source: str,
realm: str = "reality",
) -> list[Memory]:
"""分级落位 + 确定性去重(同类型同内容跳过)。"""
placed: list[Memory] = []
async with AsyncSessionLocal() as session:
existing = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id, Memory.status.in_(("active", "pending"))
)
)
)
.scalars()
.all()
)
existing_norms = {_norm(m.content) for m in existing}
for cand in candidates:
content = str(cand.get("content", "")).strip()
if not content or _norm(content) in existing_norms:
continue
sensitive = bool(cand.get("sensitive"))
mtype = str(cand.get("type", "episodic"))
if sensitive:
tier, status = "sensitive", "pending" # 重忆必须确认(HJ-1201 管线侧)
elif mtype in EXPLICIT_AUTO_TYPES:
# 白名单明忆:低敏 persona/instruction/episodic 自动生效,撤销窗=可删除/可正影
tier, status = "explicit", "active"
else:
# 心痕(relationship_view,主观非事实)等入候选箱待审阅
tier, status = "explicit", "candidate"
memory = Memory(
character_id=character_id,
tier=tier,
type=mtype,
status=status,
source=source,
content=content,
source_refs_json=json.dumps(source_refs, ensure_ascii=False),
sensitivity="high" if sensitive else "low",
priority=int(cand.get("priority", 50)),
confidence=float(cand.get("confidence", 0.5)),
realm=realm,
)
session.add(memory)
await session.flush()
placed.append(memory)
for key in cand.get("keys", []) or []:
session.add(MemoryTrigger(memory_id=memory.id, kind="keyword", pattern=str(key)))
await session.commit()
for m in placed:
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": m.id, "tier": m.tier, "status": m.status},
source="xirang",
)
return placed
def _norm(text: str) -> str:
return re.sub(r"\s+", "", text).lower()
def _kernel_ref() -> Kernel | None:
from app.main import app
return getattr(app.state, "kernel", None)
# ── 手动入忆 ──────────────────────────────────────────────
async def memorize_node(*, node_id: str, owner_scope: str, label: str) -> Memory:
"""手动入忆:须显式标注 fact/insight/opinion/hypothesis;未归真内容只作 fact_candidate。"""
from app.models.character import Character
if label not in ("fact", "insight", "opinion", "hypothesis"):
raise AppError("invalid_request", "手动入忆必须标注 fact/insight/opinion/hypothesis。")
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
raise AppError("not_found", "节点不存在。")
canonized_node_ids: set[str] = set()
canons = (
await session.execute(select(Canonization).where(Canonization.tree_id == node.tree_id))
).scalars().all()
for canon in canons:
canonized_node_ids.update(json.loads(canon.path_nodes_json))
character = await session.get(Character, await _tree_character(session, node.tree_id))
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "节点不存在。")
character_id = character.id
on_canon = node.id in canonized_node_ids
memory = Memory(
character_id=character.id,
tier="explicit",
type="episodic" if (on_canon and label == "fact") else "fact_candidate",
status="active",
source="user_explicit",
content=f"[{label}] {node.content}",
source_refs_json=json.dumps([node_id], ensure_ascii=False),
sensitivity="low",
realm=node.realm,
)
session.add(memory)
await session.flush()
memory_id = memory.id
await session.commit()
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": memory_id, "manual": True, "label": label},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def _tree_character(session: AsyncSession, tree_id: str) -> str:
from app.models.mirror_tree import MirrorTree
tree = await session.get(MirrorTree, tree_id)
return tree.character_id if tree else ""
# ── 确认流 / 正影 / 删除 ──────────────────────────────────────────────
async def _owned_memory(memory_id: str, owner_scope: str, *, session: AsyncSession) -> Memory:
from app.models.character import Character
memory = await session.get(Memory, memory_id)
if memory is None:
raise AppError("not_found", "记忆不存在。")
character = await session.get(Character, memory.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "记忆不存在。")
return memory
async def confirm_memory(memory_id: str, owner_scope: str) -> Memory:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status not in ("pending", "candidate"):
raise AppError("HJ-1103", "只有待确认(pending)或候选箱(candidate)的记忆可确认。", status_code=409)
memory.status = "active"
view = _view(memory)
await session.commit()
await one_qi.emit("memory.confirmed", character_id=view["character_id"], payload={"memory_id": memory_id}, source="xirang")
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def deny_memory(memory_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
memory.status = "tombstoned"
await session.commit()
character_id = memory.character_id
deny_counts[memory_id] = deny_counts.get(memory_id, 0) + 1
exited = deny_counts[memory_id] >= DENY_EXIT_THRESHOLD
await one_qi.emit(
"memory.denied", character_id=character_id, payload={"memory_id": memory_id, "exited": exited}, source="xirang"
)
return {"memory_id": memory_id, "exited_retrieval": True}
async def correct_memory(memory_id: str, owner_scope: str, new_content: str) -> Memory:
"""正影:新版本指向旧版本;旧版 superseded 停止普通召回,内容不改写。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status == "tombstoned":
raise AppError("HJ-1202", "已删除内容不可正影。", status_code=409)
new_version = Memory(
character_id=memory.character_id,
tier=memory.tier,
type=memory.type,
status="active" if memory.status in ("active", "pending") else memory.status,
source=memory.source,
content=new_content,
source_refs_json=memory.source_refs_json,
sensitivity=memory.sensitivity,
priority=memory.priority,
realm=memory.realm,
prev_version_id=memory.id,
)
session.add(new_version)
memory.status = "superseded"
await session.flush()
new_id = new_version.id
await session.commit()
await one_qi.emit(
"memory.superseded",
character_id=None,
payload={"memory_id": memory_id, "new_version_id": new_id},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, new_id) # type: ignore[return-value]
async def delete_memory(memory_id: str, owner_scope: str) -> None:
"""删除:墓碑化——内容不再出现在主存储查询/检索/上下文;墓碑不留原文。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
character_id = memory.character_id
memory.status = "tombstoned"
memory.content = "" # 墓碑不承载被删原文(data-model §10)
await session.commit()
await one_qi.emit(
"memory.deleted", character_id=character_id, payload={"memory_id": memory_id}, source="xirang"
)
# ── 检索(双通道 + RRF 融合 + 预算)与激活 ──────────────────────────────────────────────
async def recall_for_generation(
character_id: str, query_text: str, *, realm: str = "reality", limit: int = RECALL_LIMIT
) -> list[Memory]:
"""获准长期记忆召回:关键词通道 + 活力时近通道 → RRF 融合 → 预算裁剪。
通道 A:关键词/触发词命中(content LIKE 或 trigger pattern 命中);
通道 B:活力值 × 时近衰减排序(标准档接入 pgvector 后升级为语义通道)。
过滤:active、界域匹配、非墓碑/被取代。
"""
async with AsyncSessionLocal() as session:
actives = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.realm.in_(("reality", realm)),
)
)
)
.scalars()
.all()
)
if not actives:
return []
by_id = {m.id: m for m in actives}
now = utc_now()
def vitality_of(m: Memory) -> float:
age_days = max((now - _as_utc(m.created_at)).total_seconds() / 86400, 0)
return m.vitality * math.pow(2, -age_days / TRACE_HALF_LIFE_DAYS)
# 通道 A:关键词命中(含触发词)
keywords = {t.memory_id: t.pattern for t in await _triggers(set(by_id))}
channel_a: list[str] = []
text_norm = _norm(query_text)
for m in actives:
hit = _norm(m.content)[:24] in text_norm or any(
_norm(p) in text_norm for p in ([keywords.get(m.id)] if keywords.get(m.id) else [])
)
if hit or any(_norm(w) and _norm(w) in text_norm for w in _split_terms(m.content)):
channel_a.append(m.id)
# 通道 B:活力 × 时近
channel_b = sorted(by_id, key=lambda mid: vitality_of(by_id[mid]), reverse=True)
# RRF 融合
k = 60
scores: dict[str, float] = {}
for rank, mid in enumerate(channel_a):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
for rank, mid in enumerate(channel_b):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
ranked = sorted(scores, key=lambda mid: scores[mid], reverse=True)[:limit]
# 字符预算
picked: list[Memory] = []
used = 0
for mid in ranked:
m = by_id[mid]
if used + len(m.content) > RECALL_CHAR_BUDGET:
continue
picked.append(m)
used += len(m.content)
# 命中强化 + 召回统计
if picked:
async with AsyncSessionLocal() as session:
for m in picked:
row = await session.get(Memory, m.id)
row.vitality = min(row.vitality + VITALITY_HIT_BONUS, VITALITY_MAX)
row.recall_count += 1
row.last_recalled_at = utc_now()
await session.commit()
return picked
async def _triggers(memory_ids: set[str]) -> list[MemoryTrigger]:
if not memory_ids:
return []
async with AsyncSessionLocal() as session:
result = await session.execute(
select(MemoryTrigger).where(MemoryTrigger.memory_id.in_(tuple(memory_ids)))
)
return list(result.scalars().all())
def _split_terms(content: str) -> list[str]:
return [w for w in re.split(r"[,。!?、,.!?;;\s]+", content) if len(w) >= 2][:6]
def _as_utc(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
def _view(memory: Memory) -> dict:
return {
"id": memory.id,
"character_id": memory.character_id,
"tier": memory.tier,
"type": memory.type,
"status": memory.status,
"content": memory.content,
}