Files

217 lines
8.8 KiB
Python
Raw Permalink Normal View History

"""MySQL + Milvus + Neo4j 客户长期记忆同步服务。"""
from __future__ import annotations
from datetime import datetime
from inspect import isawaitable
from typing import Any, Callable
from tool.llm import llm
from tool.confidence import BaseConfidenceCalcTool
from repositories.memory_unit import MemoryUnitRepo
from service.memory.milvus_memory import MilvusMemoryStore
from service.memory.neo4j_memory import Neo4jMemoryStore
from service.memory.schemas import MemoryUnitDTO
class LongTermMemoryService:
"""以 MySQL 为主体事实源,向 Milvus 和 Neo4j 同步镜像。"""
def __init__(
self,
*,
milvus_store: MilvusMemoryStore | None = None,
neo4j_store: Neo4jMemoryStore | None = None,
repository_factory=MemoryUnitRepo,
embedder: Callable[[str], Any] | None = None,
):
self.milvus_store = milvus_store or MilvusMemoryStore()
self.neo4j_store = neo4j_store or Neo4jMemoryStore()
self.repository_factory = repository_factory
self.embedder = embedder or llm.embed_one
self.confidence_tool = BaseConfidenceCalcTool()
async def save(self, db, memory: MemoryUnitDTO) -> tuple[MemoryUnitDTO, list[str]]:
"""保存主体并尽力同步两个外部索引,返回记忆和 warnings。"""
repo = self.repository_factory(db)
existing = await repo.find_exact(
memory.customer_id,
memory.memory_type.value,
memory.tag,
)
warnings: list[str] = []
if existing is not None:
2026-09-12 19:56:48 +08:00
existing = await repo.merge_evidence(
existing,
content=memory.content,
source=memory.source.value,
evidence_count=max(memory.evidence_count or 1, 1),
evidence_ref=memory.evidence_ref,
)
entity = existing
else:
# final_score 是召回重排阶段的临时分数,不属于 MySQL 主体字段。
values = memory.model_dump(
mode="json",
exclude={"id", "milvus_id", "graph_node_id", "final_score"},
)
2026-09-12 19:56:48 +08:00
now = datetime.now()
values["last_verified_at"] = values.get("last_verified_at") or now
values["update_time"] = values.get("update_time") or now
values["memory_type"] = memory.memory_type.value
values["source"] = memory.source.value
confidence_result, confidence_warning = self._calculate_confidence(memory)
warnings.extend(confidence_warning)
values.update(confidence_result)
entity = await repo.add_memory(values)
if existing is not None:
confidence_result, confidence_warning = self._calculate_confidence(entity)
warnings.extend(confidence_warning)
await repo.update_sync_status(entity.id, **confidence_result)
for key, value in confidence_result.items():
setattr(entity, key, value)
try:
vector = self.embedder(entity.content)
if isawaitable(vector):
vector = await vector
milvus_id = await self.milvus_store.upsert(entity, vector)
await repo.update_sync_status(
entity.id, milvus_id=milvus_id, milvus_sync_status="success"
)
entity.milvus_id = milvus_id
entity.milvus_sync_status = "success"
except Exception as exc:
warnings.append(f"milvus_sync_failed:{type(exc).__name__}")
await repo.update_sync_status(
entity.id,
milvus_sync_status="failed",
sync_retry_count=(entity.sync_retry_count or 0) + 1,
last_sync_error=str(exc)[:500],
)
try:
graph_id = await self.neo4j_store.upsert(entity)
await repo.update_sync_status(
entity.id, graph_node_id=graph_id, neo4j_sync_status="success"
)
entity.graph_node_id = graph_id
entity.neo4j_sync_status = "success"
except Exception as exc:
warnings.append(f"neo4j_sync_failed:{type(exc).__name__}")
await repo.update_sync_status(
entity.id,
neo4j_sync_status="failed",
sync_retry_count=(entity.sync_retry_count or 0) + 1,
last_sync_error=str(exc)[:500],
)
return self._to_dto(entity), warnings
def _calculate_confidence(self, memory) -> tuple[dict[str, Any], list[str]]:
"""计算记忆置信度;异常时强制降级为候选记忆。"""
source = memory.source.value if hasattr(memory.source, "value") else memory.source
memory_type = (
memory.memory_type.value
if hasattr(memory.memory_type, "value")
else memory.memory_type
)
create_time = getattr(memory, "create_time", None)
age_days = max(0, (datetime.now() - create_time).days) if create_time else 0
try:
result = self.confidence_tool.evaluate(
tag=memory.tag,
source=source,
evidence_count=memory.evidence_count or 0,
age_days=age_days,
memory_type=memory_type,
)
result.pop("age_days", None)
result.pop("threshold", None)
result["confidence_update_time"] = datetime.now()
return result, []
except Exception as exc:
return {
"status": "candidate",
"confidence_reason": "置信度计算失败,降级保存为候选记忆",
"confidence_version": BaseConfidenceCalcTool.VERSION,
"confidence_update_time": datetime.now(),
}, [f"confidence_calculation_failed:{type(exc).__name__}"]
async def recall(
self,
db,
customer_id: int,
*,
memory_type: str | None = None,
tag: str | None = None,
limit: int = 100,
) -> tuple[list[MemoryUnitDTO], list[str]]:
"""按客户、类型、标签和有效期召回主体记忆。"""
entities = await self.repository_factory(db).list_for_customer(
customer_id, memory_type=memory_type, tag=tag, limit=limit
)
return [self._to_dto(entity) for entity in entities], []
2026-09-12 19:56:48 +08:00
async def refresh_confidence(
self, db, customer_id: int, *, limit: int = 500
) -> int:
"""按当前时间刷新有效记忆的置信度并持久化结果。"""
repo = self.repository_factory(db)
entities = await repo.list_for_confidence_refresh(customer_id, limit=limit)
refreshed = 0
for entity in entities:
confidence_result, _ = self._calculate_confidence(entity)
await repo.update_confidence(entity.id, **confidence_result)
refreshed += 1
return refreshed
async def retry_pending(self, db, *, limit: int = 100) -> dict[str, int]:
"""重试 MySQL 中缺少外部索引或同步失败的记忆。"""
entities = await self.repository_factory(db).list_pending_sync(limit)
success = 0
failed = 0
for entity in entities:
dto = self._to_dto(entity)
_, warnings = await self.save(db, dto)
if warnings:
failed += 1
else:
success += 1
return {"success": success, "failed": failed}
@staticmethod
def _to_dto(entity) -> MemoryUnitDTO:
"""将 ORM 实体转换为跨层 DTO。"""
data = {
"id": entity.id,
"customer_id": entity.customer_id,
"session_id": entity.session_id,
"agent_run_id": entity.agent_run_id,
"memory_type": entity.memory_type,
"tag": entity.tag,
"content": entity.content,
"info_type": entity.info_type,
"source": entity.source,
"evidence_ref": entity.evidence_ref or [],
"source_confidence": float(entity.source_confidence or 0),
"confidence": float(entity.confidence or 0),
"historical_accuracy": float(entity.historical_accuracy or 0),
"confidence_version": getattr(entity, "confidence_version", None),
"confidence_reason": getattr(entity, "confidence_reason", None),
"confidence_update_time": getattr(entity, "confidence_update_time", None),
"evidence_count": entity.evidence_count or 0,
"recall_count": entity.recall_count or 0,
"status": entity.status,
"valid_from": entity.valid_from,
"valid_until": entity.valid_until,
"last_verified_at": entity.last_verified_at,
"milvus_id": entity.milvus_id,
"graph_node_id": entity.graph_node_id,
}
return MemoryUnitDTO.model_validate(data)
__all__ = ["LongTermMemoryService"]