- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位 - 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起) - 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数 - 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离 - 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换 - 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库 - 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id - 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继 - 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
511 lines
19 KiB
Python
511 lines
19 KiB
Python
"""息壤(记忆域)服务:提取管线、分级落位、确认流、正影、删除、检索与激活。
|
||
|
||
铁律(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_scoped(canonization_id: str) -> None:
|
||
"""幻境/境界实录:只记录"发生过"与作用域,内容不进真界本相、不触发提取。"""
|
||
await record_factual(canonization_id)
|
||
|
||
|
||
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,
|
||
scope=record.scope,
|
||
)
|
||
)
|
||
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,
|
||
}
|