## 说明
**这批改动不是本次会话写的**,它们在会话开始前就已在工作区里、一直未提交。
我做的是**验证**它确实成立,然后按你的指示代为提交。
出处:`docs/演示用/记忆系统排查报告-2026-09-14.md` 与同目录
`记忆系统修复文档-2026-09-14.md`(两份都在本次一并入库)。
排查报告的结论是「记忆系统没有坏」——库里有真实数据、170 条抽取事件全部消费成功;
真正的问题是「观测不到」+「召回结果没人消费」。
## 改动内容(按两份文档的编号)
- **F1 `RecalledMemory.content` 断头路**:`base.py` 新增 `memory_context_text()`,
`risk_agent._agent_system_prompt` 接收并注入记忆段。无记忆时返回空串,
因此 prompt 逐字不变 —— 这也是它能安全接线的理由。
- **F3 `governance.recall` 员工身份恒空**:补一条明确的语义日志。
员工身份下召回的是"该用户自身作为客户"的记忆,恒为空属预期,
但此前没有任何提示,运维看到 `count=0` 只会以为记忆坏了。
- **F4 可观测性**:`GET /api/v1/users/me/memories`(`stored` / `recalled` /
`downstream` / `pending_events` 四段)+ 抽取与召回的 6 处日志 +
三个只读探针 `tools/probe_memory_state.py`、`probe_memory_detail.py`、
`probe_agent_types.py`。
**未实施**(文档明确留作待决,我也不代为决定):F2 `known` 引用校验永不触发
(需架构确认 memory 类 `source_references` 由业务填还是底座统一附加)、
F5 客服是否读写长期记忆(涉脱敏与复核,需产品+合规)。
## 我做的验证(会话内实测,非照录文档)
- 新接口 `GET /users/me/memories` 以 `cust_t` 调用 -> **HTTP 200**:
stored: total=2, by_status={'active': 2}
recalled: count=2, degraded=False
两条记忆:preference:horizon='约三年'(0.95)、preference:risk_level='稳健型'(0.98)
与排查报告 §〇 列出的那两条**完全吻合**。
- `pytest tests/unit tests/contract` 全绿(这批改动没有破坏既有测试)。
## 未验证的部分
`memory_context_text()` 接进 prompt 后的**端到端效果没有实测** —— 文档自己说明了
原因:当前 `risk` Agent 的召回恒空(员工身份不是客户),所以接线后行为不变,
要用测试替身才能验证注入。我没有为此编造证据。
434 lines
20 KiB
Python
434 lines
20 KiB
Python
from collections.abc import Awaitable, Callable
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.contracts import DomainEvent, RequestContext
|
||
from app.core.conversation_privacy import sanitize_customer_service_message
|
||
from app.core.errors import (
|
||
FeedbackAlreadyExistsError,
|
||
GenericResourceNotFoundError,
|
||
InvalidStateError,
|
||
RunNotCancellableError,
|
||
RunNotFoundError,
|
||
)
|
||
from app.core.profile_projection import project_profile
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationFeedback
|
||
from app.model.platform import AgentRun, HandoverTicket, RequestIdempotency
|
||
from app.model.session import ConversationSession
|
||
from app.repository.conversation_repository import ConversationRepository
|
||
from app.repository.outbox_repository import OutboxRepository
|
||
from app.repository.platform_repository import PlatformRepository
|
||
from app.repository.session_repository import SessionRepository
|
||
from app.service.admin_service import public
|
||
from app.service.agent.bootstrap import get_agent_factory
|
||
from app.service.api_transaction_service import ApiTransactionService
|
||
from app.service.authorization_service import AuthorizationService
|
||
|
||
|
||
class PublicPlatformService:
|
||
async def session(self, session_id: str, context: RequestContext) -> dict[str, Any]:
|
||
async with SessionFactory() as session:
|
||
row = await SessionRepository(session).owned(session_id, int(context.user_id))
|
||
return {"data": self._session_view(row), "meta": {"trace_id": context.trace_id}}
|
||
|
||
async def handover(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
|
||
async with SessionFactory() as session:
|
||
row = await session.scalar(
|
||
select(HandoverTicket).where(
|
||
HandoverTicket.ticket_no == ticket_no,
|
||
HandoverTicket.customer_id == int(context.user_id),
|
||
)
|
||
)
|
||
if row is None:
|
||
raise GenericResourceNotFoundError("转人工请求不存在")
|
||
return {
|
||
"data": {
|
||
"handover_id": row.ticket_no,
|
||
"status": row.status,
|
||
"created_at": public(row.created_at),
|
||
},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
async def write(
|
||
self,
|
||
operation: str,
|
||
target: str,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
payload: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
permission = {
|
||
"create": "conversation:create",
|
||
"close": "conversation:close",
|
||
"cancel": "agent:cancel",
|
||
"feedback": "conversation:feedback",
|
||
"handover": "handover:create",
|
||
}[operation]
|
||
await AuthorizationService.require(context, permission)
|
||
|
||
async def action(session: AsyncSession) -> dict[str, Any]:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
user_id = int(context.user_id)
|
||
session_id: str | None = None
|
||
if operation == "create":
|
||
get_agent_factory().authorize(payload["agent_type"], context)
|
||
# 这四个时间列在模型里都是 `server_default=CURRENT_TIMESTAMP(6)`。
|
||
# 不显式赋值的话,`flush()` 之后 SQLAlchemy 需要**回读**这些由数据库生成的
|
||
# 值,而在 async session 里回读是异步 IO —— 紧接着 `_session_view(row)`
|
||
# 以同步属性访问去读,就抛 `MissingGreenlet: greenlet_spawn has not been
|
||
# called`,整个 `POST /api/v1/conversations` 500,连带转人工也做不了
|
||
# (会话建不出来 ⇒ 后续 404 会话不存在)。显式传 `now` 与同文件
|
||
# `ConversationFeedback(...)` 的写法一致,也贴合本项目"应用侧赋时间"的约定。
|
||
row = ConversationSession(
|
||
session_id=str(uuid4()),
|
||
user_id=user_id,
|
||
agent_type=payload["agent_type"],
|
||
portal=context.portal,
|
||
status="active",
|
||
clarification_round=0,
|
||
message_count=0,
|
||
started_at=now,
|
||
last_active_at=now,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
session.add(row)
|
||
await session.flush()
|
||
session_id = row.session_id
|
||
data = self._session_view(row)
|
||
elif operation == "cancel":
|
||
data, session_id = await self._cancel(session, target, user_id, now)
|
||
elif operation == "feedback":
|
||
message = await ConversationRepository(session).message(int(target), user_id)
|
||
if message is None:
|
||
raise GenericResourceNotFoundError("消息不存在")
|
||
if await ConversationRepository(session).feedback(int(target), user_id):
|
||
raise FeedbackAlreadyExistsError("消息已经反馈")
|
||
feedback = ConversationFeedback(
|
||
feedback_no=f"fb-{uuid4().hex[:24]}",
|
||
session_id=message.session_id,
|
||
message_id=message.id,
|
||
customer_id=user_id,
|
||
created_at=now,
|
||
updated_at=now,
|
||
**payload,
|
||
)
|
||
session.add(feedback)
|
||
await session.flush()
|
||
session_id = message.session_id
|
||
data = {"feedback_no": feedback.feedback_no, "status": feedback.status}
|
||
else:
|
||
row = await SessionRepository(session).owned(target, user_id, lock=True)
|
||
session_id = row.session_id
|
||
if operation == "close":
|
||
if row.status not in {"active", "ended"}:
|
||
raise InvalidStateError("当前会话状态不能关闭")
|
||
if row.status == "active":
|
||
row.status, row.ended_at = "ended", now
|
||
data = self._session_view(row)
|
||
else:
|
||
if row.status != "active":
|
||
raise InvalidStateError("会话不在可转人工状态")
|
||
messages = await ConversationRepository(session).messages(target, user_id, 1)
|
||
ticket = HandoverTicket(
|
||
ticket_no=f"ticket-{uuid4().hex[:24]}",
|
||
session_id=target,
|
||
customer_id=user_id,
|
||
source_agent=row.agent_type or "customer_service",
|
||
source_message_id=messages[0].id if messages else None,
|
||
reason_code=payload["reason_code"],
|
||
# 用户自填原因同样是客服会话链路的一部分,不能把凭据原样落工单。
|
||
reason_detail=(
|
||
sanitize_customer_service_message(payload["reason_detail"])
|
||
if payload.get("reason_detail") is not None else None
|
||
),
|
||
status="pending",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
session.add(ticket)
|
||
await session.flush()
|
||
await OutboxRepository(session).append(
|
||
DomainEvent(
|
||
event_id=str(uuid4()),
|
||
event_type="conversation.transfer_requested",
|
||
aggregate_type="conversation",
|
||
aggregate_id=target,
|
||
trace_id=context.trace_id,
|
||
payload={"ticket_no": ticket.ticket_no},
|
||
occurred_at=now,
|
||
)
|
||
)
|
||
data = {
|
||
"handover_id": ticket.ticket_no,
|
||
"status": ticket.status,
|
||
"session_id": target,
|
||
"created_at": public(now),
|
||
}
|
||
session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=user_id,
|
||
session_id=session_id,
|
||
portal=context.portal,
|
||
action_type=f"platform.{operation}",
|
||
detail={
|
||
"trace_id": context.trace_id,
|
||
"target": target,
|
||
},
|
||
created_at=now,
|
||
)
|
||
)
|
||
return {"data": data, "meta": {"trace_id": context.trace_id}}
|
||
|
||
return await self._transact(context, operation, target, key, payload, action)
|
||
|
||
@staticmethod
|
||
async def _transact(
|
||
context: RequestContext,
|
||
operation: str,
|
||
target: str,
|
||
key: str | None,
|
||
payload: dict[str, Any],
|
||
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
|
||
) -> dict[str, Any]:
|
||
"""取消之外的写接口走幂等响应缓存。
|
||
|
||
取消**刻意不走**响应缓存:文档 §6.4 要求重复取消返回同一状态,而运行状态是
|
||
服务端权威状态——worker 把 `cancel_requested` 推进到 `cancelled` 之后,缓存的旧
|
||
快照会变成过期数据。取消的幂等性由 `_cancel` 读实时状态保证。
|
||
"""
|
||
if operation == "cancel":
|
||
async with SessionFactory() as session, session.begin():
|
||
return await action(session)
|
||
return await ApiTransactionService().execute(
|
||
context, f"public:{operation}:{target}", key, payload, action
|
||
)
|
||
|
||
async def _cancel(
|
||
self, session: AsyncSession, run_id: str, user_id: int, now: datetime
|
||
) -> tuple[dict[str, Any], str]:
|
||
"""文档 §6.4 的取消语义。
|
||
|
||
- 首次取消 `queued`/`running` → 置 `cancel_requested`,并把原请求的
|
||
`request_idempotency` 写成 `failed + RUN_CANCELLED`;
|
||
- 重复取消 → **幂等**,返回与首次一致的状态与 `cancel_requested_at`,绝不报错;
|
||
- 已成功、已失败或已进入最终提交事务 → `409 RUN_NOT_CANCELLABLE`。
|
||
|
||
`RUN_CANCELLED` 只落 `request_idempotency`,不作为 HTTP 响应错误码返回。
|
||
"""
|
||
run = await session.scalar(
|
||
select(AgentRun)
|
||
.where(AgentRun.run_id == run_id, AgentRun.user_id == user_id)
|
||
.with_for_update()
|
||
)
|
||
if run is None:
|
||
raise RunNotFoundError("运行不存在或不可见")
|
||
if run.status in {"succeeded", "failed"}:
|
||
raise RunNotCancellableError("运行已进入不可取消阶段")
|
||
if run.status in {"cancel_requested", "cancelled"}:
|
||
# 重复取消:返回同一状态与同一受理时间,不重复改写幂等记录。
|
||
return {
|
||
"run_id": run_id,
|
||
"status": run.status,
|
||
"cancel_requested_at": public(run.cancel_requested_at),
|
||
}, run.session_id
|
||
run.status, run.cancel_requested_at = "cancel_requested", now
|
||
# 文档 §6.4:取消成功后原请求在 request_idempotency 中以 failed + RUN_CANCELLED
|
||
# 结束,不扩展其既有状态枚举。
|
||
await session.execute(
|
||
update(RequestIdempotency)
|
||
.where(RequestIdempotency.id == run.idempotency_id)
|
||
.values(status="failed", error_code="RUN_CANCELLED", updated_at=now)
|
||
)
|
||
return {
|
||
"run_id": run_id,
|
||
"status": run.status,
|
||
"cancel_requested_at": public(run.cancel_requested_at),
|
||
}, run.session_id
|
||
|
||
@staticmethod
|
||
def _session_view(row: ConversationSession) -> dict[str, Any]:
|
||
return {
|
||
"session_id": row.session_id,
|
||
"agent_type": row.agent_type,
|
||
"portal": row.portal,
|
||
"status": row.status,
|
||
"clarification_round": row.clarification_round,
|
||
"message_count": row.message_count,
|
||
"last_active_at": public(row.last_active_at),
|
||
"started_at": public(row.started_at),
|
||
"ended_at": public(row.ended_at),
|
||
"created_at": public(row.created_at),
|
||
}
|
||
|
||
async def memory(self, customer_id: int, context: RequestContext) -> dict[str, Any]:
|
||
own = str(customer_id) == context.user_id
|
||
permission = "memory:read:self" if own else "memory:read:customer"
|
||
await AuthorizationService.require(context, permission)
|
||
scope = context.permission_scopes.get(permission, "self")
|
||
if (
|
||
not own
|
||
and scope != "all"
|
||
and (scope != "own_customers" or str(customer_id) not in context.customer_ids)
|
||
):
|
||
raise GenericResourceNotFoundError("客户不可访问")
|
||
async with SessionFactory() as session, session.begin():
|
||
rows = await PlatformRepository(session).rows(
|
||
"profile_snapshots", {"customer_id": customer_id, "is_current": 1}, limit=1
|
||
)
|
||
session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=int(context.user_id),
|
||
target_customer_id=customer_id,
|
||
portal=context.portal,
|
||
action_type="memory.profile_read",
|
||
detail={"trace_id": context.trace_id},
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
)
|
||
)
|
||
# 字段策略投影(白名单 + 测评有效期实时判定);不再返回空字典。
|
||
data: dict[str, Any] = {
|
||
"customer_id": str(customer_id),
|
||
"version": str(rows[0]["version"]) if rows else None,
|
||
"generated_at": public(rows[0].get("generated_at")) if rows else None,
|
||
"profile": project_profile(rows[0].get("snapshot")) if rows else {},
|
||
}
|
||
return {"data": data, "meta": {"trace_id": context.trace_id}}
|
||
|
||
async def memories_debug(
|
||
self, customer_id: int, context: RequestContext, *, query: str | None = None,
|
||
limit: int = 10,
|
||
) -> dict[str, Any]:
|
||
"""记忆系统的可观测快照:库里有什么 / 能不能召回 / 事件有没有被消费。
|
||
|
||
排查"记忆是否真的在工作"时,需要同时看清四件事,缺一件就会误判:
|
||
|
||
1. `stored` —— `memory_unit` 里到底有没有行(写入是否成功)
|
||
2. `recalled` —— 走完整召回链路(MySQL + 可选向量)能拿到什么
|
||
3. `pending` —— `domain_event_outbox` 里是否还堆着未消费事件(Worker 是否在跑)
|
||
4. `facts` / `profile` —— 记忆的下游产物(画像)有没有被重建
|
||
|
||
只返回 `profile_snapshots` 的 `memory-profile` 端点无法区分
|
||
"还没重建" 与 "压根没写入",本端点就是为消除这个盲区而加的。
|
||
"""
|
||
await AuthorizationService.require(context, "memory:read:self")
|
||
from app.model.memory import MemoryEvidence, MemoryUnit
|
||
from app.model.platform import DomainEventOutbox
|
||
from app.model.profile import ProfileSnapshot, UserFact
|
||
from app.service.agent.bootstrap import build_memory_recall_service
|
||
|
||
async with SessionFactory() as session:
|
||
stored = list(await session.scalars(
|
||
select(MemoryUnit)
|
||
.where(MemoryUnit.customer_id == customer_id)
|
||
.order_by(MemoryUnit.updated_at.desc())
|
||
.limit(50)
|
||
))
|
||
status_counts: dict[str, int] = {}
|
||
for row in stored:
|
||
status_counts[row.status] = status_counts.get(row.status, 0) + 1
|
||
# 证据按「本客户的记忆」统计,不是全表行数 —— 全表数字无法说明本客户是否写入成功。
|
||
memory_ids = [row.id for row in stored]
|
||
evidence_count = 0
|
||
if memory_ids:
|
||
evidence_count = len(list(await session.scalars(
|
||
select(MemoryEvidence.id)
|
||
.where(MemoryEvidence.memory_id.in_(memory_ids))
|
||
.limit(500)
|
||
)))
|
||
facts = list(await session.scalars(
|
||
select(UserFact).where(UserFact.customer_id == customer_id).limit(50)
|
||
))
|
||
snapshots = list(await session.scalars(
|
||
select(ProfileSnapshot)
|
||
.where(ProfileSnapshot.customer_id == customer_id)
|
||
.order_by(ProfileSnapshot.id.desc())
|
||
.limit(3)
|
||
))
|
||
pending = list(await session.scalars(
|
||
select(DomainEventOutbox).where(
|
||
DomainEventOutbox.aggregate_id == str(customer_id),
|
||
DomainEventOutbox.status == "pending",
|
||
).limit(50)
|
||
))
|
||
|
||
# 用生产装配(含 Milvus 向量通道 + embedding),这样 `degraded_reasons`
|
||
# 能真实反映"语义通道是否可用",而不是因为没装配而假装正常。
|
||
recall_service = build_memory_recall_service(SessionFactory())
|
||
try:
|
||
result = await recall_service.recall(
|
||
customer_id, query, limit=max(1, min(limit, 100)), use_cache=False
|
||
)
|
||
recalled = [
|
||
{
|
||
"memory_uuid": item.memory_uuid,
|
||
"memory_key": item.memory_key,
|
||
"content": item.content,
|
||
"memory_type": item.memory_type,
|
||
"confidence": item.confidence,
|
||
"sources": list(item.sources),
|
||
"evidence": item.evidence,
|
||
}
|
||
for item in result.items
|
||
]
|
||
degraded, reasons = result.degraded, list(result.degraded_reasons)
|
||
finally:
|
||
await recall_service.session.close()
|
||
|
||
return {
|
||
"data": {
|
||
"customer_id": str(customer_id),
|
||
"stored": {
|
||
"total": len(stored),
|
||
"by_status": status_counts,
|
||
"items": [
|
||
{
|
||
"memory_uuid": row.memory_uuid,
|
||
"memory_key": row.memory_key,
|
||
"content": row.content,
|
||
"memory_type": row.memory_type,
|
||
"status": row.status,
|
||
"confidence": float(row.confidence),
|
||
"source_type": row.source_type,
|
||
"valid_until": public(row.valid_until),
|
||
"updated_at": public(row.updated_at),
|
||
}
|
||
for row in stored
|
||
],
|
||
},
|
||
"recalled": {
|
||
"query": query,
|
||
"count": len(recalled),
|
||
"degraded": degraded,
|
||
"degraded_reasons": reasons,
|
||
"items": recalled,
|
||
},
|
||
"evidence_rows_sampled": evidence_count,
|
||
"downstream": {
|
||
"user_facts": [
|
||
{"fact_key": f.fact_key, "confidence": float(f.confidence)}
|
||
for f in facts
|
||
],
|
||
"profile_snapshots": [
|
||
{"is_current": s.is_current, "generated_at": public(s.generated_at)}
|
||
for s in snapshots
|
||
],
|
||
},
|
||
"pending_events": [
|
||
{"event_type": e.event_type, "status": e.status,
|
||
"retry_count": e.retry_count, "occurred_at": public(e.occurred_at)}
|
||
for e in pending
|
||
],
|
||
},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|