chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)

- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
This commit is contained in:
张胜宇
2026-09-21 21:26:30 +08:00
parent e239eb778b
commit 9675df8453
330 changed files with 111939 additions and 8681 deletions
+41 -11
View File
@@ -10,6 +10,7 @@ from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.actor import VISITOR_ACTOR_TYPE, anonymous_context, is_visitor
from app.core.config import Settings, get_settings
from app.core.contracts import AgentRequest, AgentRequestMetadata, AgentResult, RequestContext
from app.core.errors import AgentError, RecoverableAgentError, RunLeaseLostError
@@ -77,10 +78,20 @@ _UNSET: Any = object()
#:
#: 候选链路本身(`CustomerProfileCandidateWorker` → 用户确认 → 管理员复核)属**记忆子系统**,
#: 不由任何单一 Agent 独占;这里只决定「哪些 Agent 的对话可以产出候选」。
#: 客服 Agent 当前处于重构期,故本集合为空——**判定结果与重构前等价**(重构前也只有客服
#: 命中该白名单),新客服 Agent 注册后把它的 `agent_type` 填回本集合即可接通。
#: **当前为空集,且不是「重构期临时状态」**:`DEC-19` 裁定 (a) 明确「客服不产生画像候选」,
#: 而客服是重构前唯一的候选来源,故候选链路整体关闭。要重新打开必须先改 `DEC-19` 口径,
#: 而不是往本集合里加 `agent_type`——那样等于绕过合规裁定。
PROFILE_CANDIDATE_AGENT_TYPES: frozenset[str] = frozenset()
#: **永不写长期记忆**的 agent_type 黑名单(`DEC-19`:短期会话记忆=开 / 长期记忆召回=关)。
#:
#: 客服会话正文含账户、持仓、身份等高敏内容;一旦被抽成 `memory_unit`,就会被**跨会话、
#: 跨场景**召回进后续任意对话的生成上下文,等同于把合规裁定绕开。这里保留**显式**闸门
#: (而不是指望下游 `should_extract_memory()` 恰好返回 False),使「客服永不写长期记忆」
#: 成为一条可测试的不变量:重构期该闸门曾被误删,`test_worker_runtime_mysql` 立刻抓到
#: 客服 run 产出 `memory.extraction_requested`。
NO_LONG_TERM_MEMORY_AGENT_TYPES: frozenset[str] = frozenset({"customer_service"})
@dataclass(frozen=True)
class ProjectionCleanupOutcome:
@@ -96,6 +107,20 @@ class ProjectionCleaner(Protocol):
async def cleanup(self, *, memory_uuid: str, operation: str) -> ProjectionCleanupOutcome: ...
def _stored_subject(tool_calls: Any) -> str:
"""从消息的 ``tool_calls`` JSON 列取出口声明的主语(``E-05``)。
该列历史上是**裸列表**(`calls` 之外没有兄弟键),也可能来自不含 `topic` 的
旧写入 —— 这两种情况一律返回空串,读侧于是回落到文本反解。格式差异不会让
某一轮突然丢掉主语。
"""
if isinstance(tool_calls, dict):
value = tool_calls.get("topic")
if isinstance(value, str):
return value.strip()
return ""
class WorkerRuntime:
def __init__(
self, factory: AgentFactory | None = None, settings: Settings | None = None,
@@ -176,12 +201,10 @@ class WorkerRuntime:
) -> RequestContext:
"""按受理事件中的可信身份恢复最小执行权限。"""
identity = RequestContext(user_id=actor_id, trace_id=trace_id)
if actor_type == "visitor":
return identity.model_copy(update={
"roles": ("visitor",),
"permissions": ("agent:run", "knowledge:query"),
"data_scope": "public",
})
if actor_type == VISITOR_ACTOR_TYPE:
# `G-01`:与 API 入口(`app/core/security.py`)共用同一构造点。
# 此前这里是**第二份手写副本**,两侧漂移时只在异步链路暴露。
return anonymous_context(user_id=actor_id, trace_id=trace_id)
return await self.resolve_identity(identity)
@staticmethod
@@ -189,8 +212,14 @@ class WorkerRuntime:
*, agent_type: str, context: RequestContext, message: str,
result: AgentResult, business_events: tuple[str, ...] | list[str],
) -> bool:
"""长期记忆抽取只接收非访客身份的明确业务事实。"""
if "visitor" in context.roles:
"""长期记忆抽取只接收非访客、且不在长期记忆黑名单内的明确业务事实。"""
if agent_type in NO_LONG_TERM_MEMORY_AGENT_TYPES:
# 客服走「画像字段级只读」链路,不写画像、不写长期记忆(`DEC-19` 裁定 (a))。
logger.info(
"memory extraction skipped: agent_type=%s 不写长期记忆(DEC-19)", agent_type
)
return False
if is_visitor(context):
logger.info("memory extraction skipped: 访客身份不写长期记忆")
return False
tool_result = any(call.status == "succeeded" for call in result.result.tool_calls)
@@ -218,7 +247,7 @@ class WorkerRuntime:
"""仅为白名单内 Agent、已登录且 self 范围内的用户生成待确认画像候选。"""
if agent_type not in PROFILE_CANDIDATE_AGENT_TYPES:
return False
if "visitor" in context.roles:
if is_visitor(context):
return False
if not {"customer", "authenticated_user"}.intersection(context.roles):
return False
@@ -453,6 +482,7 @@ class WorkerRuntime:
turns.append(ConversationTurn(
role="assistant" if str(row.role) == "assistant" else "user",
content=content,
subject=_stored_subject(row.tool_calls),
))
return tuple(turns)