M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)
This commit is contained in:
1 parent
2a640a901b
commit
9c98c2f85e
69 files changed
+1193
-6033
No files matched your search
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -413,6 +414,18 @@ async def build_generation_prompt(
|
||||
lines.append(f"{speaker}:{expand(n.content)}")
|
||||
transcript = "\n".join(lines)
|
||||
parts = []
|
||||
# 获准长期记忆(息壤 M3):召回透明——注入来源可展开,旁枝不入(默认 active 级)
|
||||
if question.tree_id:
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
recalled = await xirang_service.recall_for_generation(
|
||||
await _tree_character_id(question.tree_id),
|
||||
f"{transcript}\n{question.content}",
|
||||
realm=question.realm,
|
||||
)
|
||||
if recalled:
|
||||
memory_lines = [f"- {m.content}" for m in recalled]
|
||||
parts.append(f"【获准记忆(来源可查)】\n" + "\n".join(memory_lines))
|
||||
if transcript:
|
||||
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
|
||||
parts.append(f"【当前问题】\n{expand(question.content)}")
|
||||
@@ -730,6 +743,11 @@ async def canonize(
|
||||
record_id = record.id
|
||||
await session.commit()
|
||||
|
||||
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
await xirang_service.on_canonized(record_id)
|
||||
|
||||
await one_qi.emit(
|
||||
"tree.canonized",
|
||||
character_id=tree.character_id,
|
||||
@@ -873,3 +891,11 @@ async def transplant(
|
||||
source="mirror_tree",
|
||||
)
|
||||
return {"new_tree_id": new_tree_id, "working_entry_node_id": new_entry}
|
||||
|
||||
|
||||
async def _tree_character_id(tree_id: str) -> str:
|
||||
from app.models.mirror_tree import MirrorTree
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
tree = await session.get(MirrorTree, tree_id)
|
||||
return tree.character_id if tree else ""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user