feat:客户agent以及记忆模块功能开发

This commit is contained in:
2026-09-11 22:38:15 +08:00
parent 0197ac2ef2
commit 4da4775e8c
39 changed files with 2508 additions and 14 deletions
+68
View File
@@ -0,0 +1,68 @@
"""conversation_archive 会话归档仓储。"""
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from model.conversation_archive import ConversationArchive
from repositories.base import BaseRepository
class ConversationArchiveRepo(BaseRepository):
"""提供按会话读取和幂等批量归档能力。"""
model = ConversationArchive
async def list_by_session(self, session_id: str) -> list[ConversationArchive]:
"""按消息创建顺序读取完整归档会话。"""
statement = (
select(ConversationArchive)
.where(ConversationArchive.session_id == session_id)
.order_by(ConversationArchive.create_time, ConversationArchive.id)
)
return list((await self.db.scalars(statement)).all())
async def archive_batch(self, rows: Iterable[dict]) -> int:
"""批量写入归档记录,重复的 session_id + message_id 自动跳过。"""
rows = list(rows)
if not rows:
return 0
session_id = rows[0]["session_id"]
message_ids = [row["message_id"] for row in rows]
existing = await self.db.scalars(
select(ConversationArchive.message_id).where(
ConversationArchive.session_id == session_id,
ConversationArchive.message_id.in_(message_ids),
)
)
existing_ids = set(existing.all())
pending = [row for row in rows if row["message_id"] not in existing_ids]
if not pending:
return 0
self.db.add_all(
[ConversationArchive(**row) for row in pending]
)
try:
await self.db.commit()
except IntegrityError:
await self.db.rollback()
# 并发归档时,唯一键冲突代表其他调用已完成该消息归档。
remaining = await self.db.scalars(
select(ConversationArchive.message_id).where(
ConversationArchive.session_id == session_id,
ConversationArchive.message_id.in_(message_ids),
)
)
if set(remaining.all()) >= set(message_ids):
return 0
raise
return len(pending)
__all__ = ["ConversationArchiveRepo"]
@@ -0,0 +1,57 @@
"""客户画像变更日志仓储。"""
from __future__ import annotations
import json
from sqlalchemy import text
class CustomerProfileChangeLogRepo:
"""记录画像更新原因,供缓存失效和后续审计复用。"""
def __init__(self, db):
self.db = db
async def record(
self,
*,
customer_id: int,
tag: str | None,
old_value,
new_value,
source: str | None,
confidence: float | None,
reason: str | None,
operator_id: int | None = None,
) -> None:
"""写入一次画像变更日志。"""
await self.db.execute(
text(
"""
INSERT INTO customer_profile_change_log
(customer_id, tag, old_value, new_value, source,
confidence, reason, operator_id)
VALUES
(:customer_id, :tag, :old_value, :new_value, :source,
:confidence, :reason, :operator_id)
"""
),
{
"customer_id": customer_id,
"tag": tag,
"old_value": json.dumps(old_value, ensure_ascii=False)
if isinstance(old_value, (dict, list)) else old_value,
"new_value": json.dumps(new_value, ensure_ascii=False)
if isinstance(new_value, (dict, list)) else new_value,
"source": source,
"confidence": confidence,
"reason": reason,
"operator_id": operator_id,
},
)
await self.db.commit()
__all__ = ["CustomerProfileChangeLogRepo"]
+28
View File
@@ -0,0 +1,28 @@
"""客户-投顾关系查询仓储。"""
from sqlalchemy import select
from model.customer_relation import CustomerRelation
from repositories.base import BaseRepository
class CustomerRelationRepo(BaseRepository):
"""按客户 ID 隔离读取有效关系。"""
model = CustomerRelation
async def list_by_customer(self, customer_id: int) -> list[CustomerRelation]:
"""返回指定客户尚未结束的关系。"""
statement = (
select(CustomerRelation)
.where(
CustomerRelation.customer_id == customer_id,
CustomerRelation.status != "已结束",
)
.order_by(CustomerRelation.assign_time.desc(), CustomerRelation.id.desc())
)
return list((await self.db.scalars(statement)).all())
__all__ = ["CustomerRelationRepo"]
+15
View File
@@ -7,6 +7,7 @@ from sqlalchemy import case, select, update
from sqlalchemy.dialects.mysql import insert as mysql_insert
from model.fin_holdings import FinHoldings
from model.fin_product import FinProduct
from repositories.base import BaseRepository
@@ -23,6 +24,20 @@ class FinHoldingsRepo(BaseRepository):
stmt = stmt.order_by(FinHoldings.id)
return list((await self.db.scalars(stmt)).all())
async def list_with_products(
self, customer_id: int, *, include_closed: bool = True
) -> list[tuple[FinHoldings, FinProduct | None]]:
"""按客户关联查询持仓和基金产品,产品缺失时保留持仓记录。"""
stmt = (
select(FinHoldings, FinProduct)
.outerjoin(FinProduct, FinProduct.id == FinHoldings.product_id)
.where(FinHoldings.customer_id == customer_id)
.order_by(FinHoldings.update_time.desc(), FinHoldings.id.desc())
)
if not include_closed:
stmt = stmt.where(FinHoldings.status == "持有中")
return list((await self.db.execute(stmt)).all())
async def get_by_customer_product(
self, customer_id: int, product_id: int
) -> FinHoldings | None:
+107
View File
@@ -0,0 +1,107 @@
"""memory_unit 长期记忆仓储。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from model.memory_unit import MemoryUnit
from repositories.base import BaseRepository
ACTIVE_STATUSES = ("candidate", "confirmed")
INACTIVE_STATUSES = ("expired", "rejected", "archived")
class MemoryUnitRepo(BaseRepository):
"""负责 MySQL 主体记忆的隔离、去重和同步状态管理。"""
model = MemoryUnit
async def add_memory(self, values: dict) -> MemoryUnit:
"""先写入 MySQL 主体并返回数据库 ID。"""
obj = MemoryUnit(**values)
self.db.add(obj)
await self.db.commit()
await self.db.refresh(obj)
return obj
async def find_exact(
self, customer_id: int, memory_type: str, tag: str, content: str
) -> MemoryUnit | None:
"""按客户、类型、标签和内容精确查找去重对象。"""
return await self.db.scalar(
select(MemoryUnit).where(
MemoryUnit.customer_id == customer_id,
MemoryUnit.memory_type == memory_type,
MemoryUnit.tag == tag,
MemoryUnit.content == content,
MemoryUnit.status.not_in(INACTIVE_STATUSES),
)
)
async def list_for_customer(
self,
customer_id: int,
*,
memory_type: str | None = None,
tag: str | None = None,
now: datetime | None = None,
limit: int = 100,
) -> list[MemoryUnit]:
"""查询默认可召回的客户记忆,过滤拒绝、归档和过期记录。"""
now = now or datetime.now()
conditions = [
MemoryUnit.customer_id == customer_id,
MemoryUnit.status.in_(ACTIVE_STATUSES),
(MemoryUnit.valid_until.is_(None) | (MemoryUnit.valid_until > now)),
]
if memory_type:
conditions.append(MemoryUnit.memory_type == memory_type)
if tag:
conditions.append(MemoryUnit.tag == tag)
statement = (
select(MemoryUnit)
.where(*conditions)
.order_by(MemoryUnit.update_time.desc(), MemoryUnit.id.desc())
.limit(limit)
)
return list((await self.db.scalars(statement)).all())
async def merge_evidence(
self, memory: MemoryUnit, *, evidence_count: int = 1, conflict_count: int = 0
) -> MemoryUnit:
"""合并证据和冲突计数,不改变客户隔离范围。"""
memory.evidence_count = (memory.evidence_count or 0) + evidence_count
memory.conflict_count = (memory.conflict_count or 0) + conflict_count
memory.update_time = datetime.now()
await self.db.commit()
await self.db.refresh(memory)
return memory
async def update_sync_status(self, memory_id: int, **values) -> None:
"""更新向量/图谱索引 ID 和同步状态。"""
memory = await self.db.get(MemoryUnit, memory_id)
if memory is None:
return
for key, value in values.items():
setattr(memory, key, value)
memory.update_time = datetime.now()
await self.db.commit()
async def list_pending_sync(self, limit: int = 100) -> list[MemoryUnit]:
"""返回存在失败或空索引的记忆,供重试入口使用。"""
statement = (
select(MemoryUnit)
.where(
(MemoryUnit.milvus_id.is_(None) | (MemoryUnit.milvus_sync_status == "failed"))
| (MemoryUnit.graph_node_id.is_(None) | (MemoryUnit.neo4j_sync_status == "failed"))
)
.order_by(MemoryUnit.id)
.limit(limit)
)
return list((await self.db.scalars(statement)).all())
__all__ = ["MemoryUnitRepo"]
+34
View File
@@ -0,0 +1,34 @@
"""客户工单查询仓储。"""
from __future__ import annotations
from sqlalchemy import select
from model.biz_work_order import BizWorkOrder
from repositories.base import BaseRepository
class WorkOrderRepo(BaseRepository):
"""按客户隔离查询工单状态。"""
model = BizWorkOrder
COMPLETED_STATUSES = ("已完成", "已驳回")
async def list_by_customer(
self, customer_id: int, *, active_only: bool = True, limit: int = 100
) -> list[BizWorkOrder]:
"""按更新时间倒序返回指定客户的工单。"""
conditions = [BizWorkOrder.customer_id == customer_id]
if active_only:
conditions.append(BizWorkOrder.status.not_in(self.COMPLETED_STATUSES))
statement = (
select(BizWorkOrder)
.where(*conditions)
.order_by(BizWorkOrder.update_time.desc(), BizWorkOrder.id.desc())
.limit(limit)
)
return list((await self.db.scalars(statement)).all())
__all__ = ["WorkOrderRepo"]