Files
group_fqcd_jr/app/service/projection_cleanup_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

142 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""投影清理:记忆失效或删除时,清掉它在图库与向量库里的派生数据。
**为什么以画像为准,而不是按标识直接删边**:一条记忆可能对应多条派生边(同一 key 的
证据累积会让 tag 取值变化),直接按 tag_key 删容易删不干净或误删。这里走
「删掉该记忆对应的长期事实 → 重建画像 → 对账修复」,让投影跟着权威源收敛——
最终图里剩下的内容必然与画像一致,而不依赖"我记得这条记忆当初投影成了什么"。
**Milvus 向量**按 `memory_uuid` 删除;集合不存在或客户端不可用时**如实报告未清理**,
绝不伪造成功。这与 `_cleanup_projection` 的审计语义一致:审计只记录适配器返回的真实结论。
"""
import logging
from dataclasses import dataclass
from sqlalchemy import delete, select
from app.core.config import get_settings
from app.infrastructure.db import SessionFactory
from app.infrastructure.milvus_profile_projection import PROFILE_COLLECTION
from app.model.memory import MemoryUnit
from app.model.profile import UserFact
from app.service.profile_assembly_service import ProfileAssemblyService
from app.service.profile_graph_projection_service import ProfileGraphProjectionService
from app.service.relationship_service import RelationshipService
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProjectionCleanupOutcome:
"""与 `app/worker/runtime.py` 的同名结构保持字段一致(该处只按属性取用)。
`cleaned=True` 表示派生数据已确实清理;为假时 `detail` 必须说明真实缺口,
审计会原样记录它。
"""
cleaned: bool
detail: str = ""
class ProjectionCleanupService:
def __init__(self, relationships: RelationshipService | None = None) -> None:
self.relationships = relationships
async def cleanup(self, *, memory_uuid: str, operation: str) -> ProjectionCleanupOutcome:
del operation # 失效与删除对投影的处理一致:都以权威源为准重建
async with SessionFactory() as session:
memory = await session.scalar(
select(MemoryUnit).where(MemoryUnit.memory_uuid == memory_uuid)
)
if memory is None:
# 记忆已经不在库里:投影清理的目标已消失,视为完成(幂等)
return ProjectionCleanupOutcome(True, "memory_not_found")
customer_id = int(memory.customer_id)
fact_key = str(memory.memory_key)
# 该记忆已失效/删除,它对应的事实不应再参与画像
async with SessionFactory() as session, session.begin():
await session.execute(delete(UserFact).where(
UserFact.customer_id == customer_id, UserFact.fact_key == fact_key
))
# 以剩余证据重建画像,再让图跟着画像收敛
details: list[str] = []
try:
async with SessionFactory() as session, session.begin():
await ProfileAssemblyService(session).rebuild(customer_id)
details.append("profile_rebuilt")
except Exception:
logger.warning("profile rebuild failed during cleanup customer_id=%s",
customer_id, exc_info=True)
details.append("profile_rebuild_failed")
graph_ok = await self._cleanup_graph(customer_id, details)
vector_ok = await self._cleanup_vector(memory_uuid, details)
return ProjectionCleanupOutcome(
cleaned=graph_ok and vector_ok and "profile_rebuild_failed" not in details,
detail="; ".join(details),
)
async def _cleanup_graph(self, customer_id: int, details: list[str]) -> bool:
if self.relationships is None:
details.append("graph_client_unavailable")
return False
try:
async with SessionFactory() as session:
outcome = await ProfileGraphProjectionService(
session, self.relationships
).reconcile_customer(customer_id, repair=True)
except Exception:
logger.warning("graph cleanup failed customer_id=%s", customer_id, exc_info=True)
details.append("graph_cleanup_failed")
return False
if outcome.degraded:
details.append(f"graph_degraded:{outcome.reason}")
return False
if not outcome.consistent:
# repair 之后仍不一致:如实报告,不写成清理成功
details.append(
f"graph_still_inconsistent:missing={len(outcome.missing)},"
f"orphaned={len(outcome.orphaned)}"
)
return False
details.append("graph_cleaned")
return True
async def _cleanup_vector(self, memory_uuid: str, details: list[str]) -> bool:
"""删除该记忆的向量。集合不存在(未启用语义召回)时视为无需清理。"""
settings = get_settings()
if not settings.milvus_uri:
details.append("vector_store_not_configured")
return True
try:
from pymilvus import MilvusClient # type: ignore[import-untyped]
client = MilvusClient(
uri=settings.milvus_uri, token=settings.milvus_token or None
)
except Exception:
logger.warning("milvus client unavailable during cleanup", exc_info=True)
details.append("vector_client_unavailable")
return False
try:
# 集合名与投影写入路径**同一个常量**(`PROFILE_COLLECTION`)。此前读的是
# 已删除的 `settings.milvus_collection`(`jr_memory`),该集合不存在
# ⇒ 这里每次都走下面的 `vector_collection_absent` 分支、
# **报清理成功但一个向量都没删**,陈旧向量永久留存。
if PROFILE_COLLECTION not in set(client.list_collections()):
# 记忆向量集合尚未启用:没有需要清理的派生数据
details.append("vector_collection_absent")
return True
client.delete(
collection_name=PROFILE_COLLECTION,
filter=f'memory_uuid == "{memory_uuid}"',
)
details.append("vector_cleaned")
return True
except Exception:
logger.warning("milvus delete failed memory_uuid=%s", memory_uuid, exc_info=True)
details.append("vector_delete_failed")
return False