共同祖先 bbf623a;主干 54 个提交、118 个文件;本线 25 个文件;9 个冲突文件。 主干这次把 **ZSY 的整条投影实现合进来了(PR #7)**,而本线此前的提交正是 移植并修正同一套代码 —— 因此冲突的本质是"同一功能两份实现并存",取舍错了会把 已修好的缺陷又带回来。逐项取舍与理由见 `docs/39-主干合并对策记录.md`。 ## 取舍(9 个冲突) 取本线: - `app/infrastructure/milvus_profile_projection.py` —— 主干是 ZSY 原版,含两处必炸点: ① `customer_id` 要求 int 而本仓所有生产者都写 `str` ⇒ 每个事件必然失败; ② 不可投影的 `memory_key` 直接 raise ⇒ 一条 `constraint:` 记忆毒死整客户整批。 本线版已放宽为「接受纯数字字符串」与「跳过并留痕」。 - `memory_sync_outbox_worker.py` / `conversation_privacy.py` / `risk_questionnaire.py` —— 代码逐行一致,仅注释与说明文字详略不同(`risk_questionnaire.py` 两边**独立做了 完全相同的修复**,都改成 re-export `app.model.profile`)。 - 两个投影测试文件 —— 本线是他那份的**超集**(4→10、4→5 例,包含他全部用例)。 两边合并: - `app/worker/runtime.py`:`__init__` 两边各加一个参数,都要。 - `app/service/agent/implementations/customer_service.py`:import 取并集; `COMPANY` 取主干的「奶龙基金责任有限公司」("奶龙"是本项目实际品牌名,主干多处出现), `HOTLINE`/`SERVICE_HOURS` **取本线的修复**(主干仍是占位符 `400-XXX-XXXX`, 本线已改为引用 `customer_service_rules` 的唯一来源 —— 这是 A1 缺陷修复, 否则同一客服给客户两个不同号码)。 - `AGENTS.md`:表数/Agent 清单取主干(90/89、7 个 Agent),本线的 `-X utf8` 与两条 outbox 易错点保留,测试基线按合并后实测重算。 ## 消费端只保留一套(本次最重要的一处) 合并后曾出现**两套消费者读同一个 `memory_sync_outbox`**:`__main__.py`(PR #7) 与 `runtime.consume_profile_projections()`(本线),而**两者的 neo4j handler 不同** —— 前者用 ZSY 的 `Neo4jProfileProjection`(按客户各建私有节点), 后者用主干 `ProfileGraphProjectionService`(共享 tag 节点、只投影已确认事实)。 同一事件被谁领到结果不定,等于"同一事实在图里有两种说法",正是**方案 A 要避免的状态**。 现只保留 runtime 那一套(带 `memory_sources` 兜底、neo4j 复用主干服务), 删除 `__main__.py` 的重复接线;装配入口职责仍在该文件(注入 `relationships` / `projection_cleaner`),Milvus 客户端由 `bootstrap` 工厂惰性构造、缺配置时显式降级。 副作用:`app/infrastructure/neo4j_profile_projection.py` 不再被生产代码引用,成为 **死代码**(本线未删,属架构师线,其单测仍在)—— 待架构师决定删或明确分工。 ## 顺带修掉的 3 个继承缺陷(主干同样存在,PR #7 后未整套复跑故未发现) 1. `tools/seed_test_rbac.py` **少建 `review_t`(9004)账号** —— 两个集成测试都依赖它 ("账号存在但无权限应返回 200 空集而非 404"、`PLACEHOLDER_ACCOUNTS`)。 同时把用户↔角色绑定从 `zip(..., strict=True)` 改为**显式配对表**:原写法隐含 "USERS 与 ROLES 一一对应",一加不绑角色的账号就 ValueError、整个种子跑不完 (commit 在最后,外部表现是"什么都没发生")。 2. `CustomerProfileCandidateService._write_profile_snapshot` **漏写 `current_customer_id`** —— 该列不是生成列而是普通可空列 + 唯一键 `uk_profile_snapshot_current`, 不写则唯一键形同虚设(多个 NULL 不冲突),且旧当前版本也没清该列、补写就会撞键。 现旧值置 None、新值显式写入(与 `ProfileGenerationService._clear_current` 一致)。 3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在", 跑 `seed_test_rbac.py` + `set_user_password.py` 后转绿;已在 `AGENTS.md` 记明, 避免被误判成代码缺陷。 ## 文档 - 新增 `docs/39-主干合并对策记录.md`(逐文件取舍 + 理由 + 遗留) - `docs/37` 订正一处过时说法:曾写 `current_customer_id` 无人使用且故意不映射, 实际 `app/model/profile.py` 已映射且有人使用(详见该文档 §6.2 的订正块) - 文档编号:主干已占 29–36,本线两份文档让号至 `docs/37`、`docs/38` ## 验证(合并后实测) - `pytest tests`(全量)→ `2 failed, 1396 passed, 2 skipped` - `pytest tests/integration` → `102 passed, 1 skipped`(修上述 1、2 后从 15 failed 归零) - `mypy app` → `Success: no issues found in 245 source files` - `tools/audit_schema.py` → 89 张业务表无缺失/意外(未改动任何表结构) - `tools/check_authoritative_docs.py` → 52 份文档无编号冲突 - `tools/check_rbac_seed_consistency.py` → 通过 那 2 个失败是既有环境项(`test_offsite_document_recognition_adapter.py` 断言请求体 中文原文而 httpx 序列化成 `\uXXXX`),与本次合并无关。
245 lines
13 KiB
Python
245 lines
13 KiB
Python
from datetime import UTC, datetime
|
||
from decimal import Decimal
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.contracts import AgentResult, DomainEvent
|
||
from app.core.errors import RunLeaseLostError
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationMessage
|
||
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket, RequestIdempotency
|
||
from app.model.risk import RiskUser
|
||
from app.model.session import ConversationSession
|
||
from app.service.customer_service_handover_context import (
|
||
MAX_SUMMARY_MESSAGES,
|
||
CustomerServiceHandoverContext,
|
||
build_customer_service_handover_context,
|
||
)
|
||
|
||
#: 治理层追加免责声明时使用的分隔形状(`app/service/agent/governance.py` 里定义)。
|
||
#: 这里只用于**审计留痕**,不参与任何判定:判据是"末尾是否出现这个形状"。
|
||
_GOVERNANCE_APPEND_MARKERS: tuple[str, ...] = ("\n\n本内容仅为投资分析参考",)
|
||
|
||
|
||
def _governance_rewrote(result: AgentResult) -> bool:
|
||
"""治理层是否改写过这次输出(用于审计)。
|
||
|
||
两条可观测痕迹(都不改协议、只读结果本身):
|
||
1. **追加了固定免责声明**:正文末尾出现治理层使用的分隔形状;
|
||
2. **拦截并替换**:命中禁用词/硬规则时治理层会把回复换成安全话术并置 `transfer_required`。
|
||
|
||
保守取值:任一条成立即记 True。它只是审计信息,判错方向的代价是"多标了一次",
|
||
不会影响业务行为——因此宁可宽一点,也不为了精确而改动治理协议。
|
||
"""
|
||
text = result.result.text or ""
|
||
appended = any(text.endswith(marker) or marker in text
|
||
for marker in _GOVERNANCE_APPEND_MARKERS)
|
||
return appended or bool(result.result.transfer_required)
|
||
|
||
|
||
class AgentPersistenceService:
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self.session = session
|
||
|
||
async def complete_run(
|
||
self, run_id: str, result: AgentResult, memory_extraction_requested: bool = True,
|
||
*, worker_id: str | None = None, profile_candidate_requested: bool = False,
|
||
) -> int:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
async with self.session.begin():
|
||
run = await self.session.scalar(
|
||
select(AgentRun).where(AgentRun.run_id == run_id).with_for_update()
|
||
)
|
||
if run is None:
|
||
raise ValueError("run not found")
|
||
if result.run_id != run_id:
|
||
raise ValueError("result belongs to another run")
|
||
if run.status == "succeeded" and run.result_message_id is not None:
|
||
return run.result_message_id
|
||
if worker_id is not None and (
|
||
run.status != "running" or run.worker_id != worker_id
|
||
or run.locked_until is None or run.locked_until <= now
|
||
):
|
||
raise RunLeaseLostError("运行租约失效或已取消")
|
||
if run.status not in {"queued", "running"}:
|
||
raise RunLeaseLostError("不能覆盖运行终态")
|
||
clarification_round = 0
|
||
if run.agent_type == "customer_service":
|
||
session_row = await self.session.scalar(select(ConversationSession).where(
|
||
ConversationSession.session_id == run.session_id,
|
||
ConversationSession.user_id == run.user_id,
|
||
).with_for_update())
|
||
if session_row is not None:
|
||
clarification_round = session_row.clarification_round
|
||
if result.result.clarification_required:
|
||
session_row.clarification_round = min(
|
||
session_row.clarification_round + 1, 2
|
||
)
|
||
else:
|
||
session_row.clarification_round = 0
|
||
message = ConversationMessage(
|
||
session_id=run.session_id, customer_id=run.user_id, portal="agent",
|
||
role="assistant", content=result.result.text,
|
||
trace_id=run.trace_id, created_at=now,
|
||
intent=result.result.intent.intent if result.result.intent else None,
|
||
confidence=(Decimal(str(result.result.intent.confidence))
|
||
if result.result.intent else None),
|
||
source_references=[ref.model_dump(mode="json")
|
||
for ref in result.result.source_references],
|
||
# `tool_calls` 是**唯一能承载附加信息的现成 JSON 列**(`conversation_message`
|
||
# 没有 `transfer_required` 列,加列要迁移,而规则 4 禁止改既有字段定义)。
|
||
# 因此把「转人工标记」作为 `calls` 的**兄弟键**放进来:
|
||
# {"calls": [...], "transfer_required": bool, "transfer_reason": str|None}
|
||
# 之所以必须落库:`docs/05` §6.3 规定 `GET /agent-runs/{run_id}` 的
|
||
# `result` 里要有 `transfer_required` / `transfer_reason`,而它此前
|
||
# **既没落库也没出参** —— 前端只能靠"回答里是否含兜底话术开头"来猜要不要转人工
|
||
# (`docs/24` 自己把这称为权宜之计)。落库后读写两侧才有同一份真相。
|
||
# 读侧允许 `calls` 是裸列表(历史行),见 `RunQueryService.get`。
|
||
tool_calls={
|
||
"calls": [call.model_dump(mode="json")
|
||
for call in result.result.tool_calls],
|
||
"transfer_required": bool(result.result.transfer_required),
|
||
"transfer_reason": result.result.transfer_reason,
|
||
},
|
||
)
|
||
self.session.add(message)
|
||
await self.session.flush()
|
||
handover_ticket: HandoverTicket | None = None
|
||
handover_context: CustomerServiceHandoverContext | None = None
|
||
if result.result.transfer_required:
|
||
# 访客 subject 不是正式用户主键,先按 RiskUser 查询,查不到则保留空归属。
|
||
ticket_customer_id = await self.session.scalar(
|
||
select(RiskUser.id).where(RiskUser.id == run.user_id)
|
||
)
|
||
recent_messages = list(await self.session.scalars(
|
||
select(ConversationMessage)
|
||
.where(ConversationMessage.session_id == run.session_id)
|
||
.order_by(ConversationMessage.id.desc())
|
||
.limit(MAX_SUMMARY_MESSAGES)
|
||
))
|
||
recent_messages.reverse()
|
||
confidence = (
|
||
Decimal(str(result.result.intent.confidence))
|
||
if result.result.intent else None
|
||
)
|
||
reason_code = result.result.transfer_reason or "agent_requested"
|
||
handover_context = build_customer_service_handover_context(
|
||
reason_code=reason_code,
|
||
clarification_round=clarification_round,
|
||
confidence=confidence,
|
||
source_references=result.result.source_references,
|
||
messages=recent_messages,
|
||
)
|
||
handover_ticket = HandoverTicket(
|
||
ticket_no=f"ticket-{uuid4().hex[:24]}",
|
||
session_id=run.session_id,
|
||
customer_id=ticket_customer_id,
|
||
source_agent=run.agent_type,
|
||
source_message_id=message.id,
|
||
intent=(result.result.intent.intent if result.result.intent else None),
|
||
confidence=confidence,
|
||
reason_code=reason_code,
|
||
reason_detail=handover_context.reason_detail,
|
||
conversation_summary=handover_context.conversation_summary,
|
||
source_references=handover_context.source_references,
|
||
status="pending", created_at=now, updated_at=now,
|
||
)
|
||
self.session.add(handover_ticket)
|
||
run.result_message_id = message.id
|
||
run.status = "succeeded"
|
||
run.result_version = 1
|
||
run.completed_at = now
|
||
run.updated_at = now
|
||
run.locked_until = None
|
||
session_row = await self.session.scalar(select(ConversationSession).where(
|
||
ConversationSession.session_id == run.session_id,
|
||
ConversationSession.user_id == run.user_id,
|
||
).with_for_update())
|
||
if session_row is not None and result.result.intent is not None:
|
||
session_row.last_intent = result.result.intent.intent
|
||
session_row.updated_at = now
|
||
self.session.add(InteractionAudit(
|
||
actor_type="agent", actor_id=run.user_id, target_customer_id=run.user_id,
|
||
session_id=run.session_id, portal="agent", action_type="agent.run_completed",
|
||
# `agent_type` 必须落进审计:治理层(`PlatformGovernance.review`)会**改写对外
|
||
# 输出**(追加固定免责声明、命中禁用词时整条替换成安全话术),事后要能回答
|
||
# "这次改写是哪个 Agent 触发的、改写到了什么程度"。
|
||
# `governance_rewrite` 记录治理是否动过输出:正文里出现固定话术的追加形状,
|
||
# 或该次运行被标记为需转人工(拦截分支会置 `transfer_required`)。
|
||
# 不改表结构:`detail` 是 JSON 列,加键不需要迁移(AGENTS.md 规则 4)。
|
||
detail={
|
||
"run_id": run_id,
|
||
"result_message_id": message.id,
|
||
"agent_type": run.agent_type,
|
||
"governance_rewrite": _governance_rewrote(result),
|
||
},
|
||
created_at=now,
|
||
))
|
||
if handover_ticket is not None:
|
||
assert handover_context is not None
|
||
self.session.add(InteractionAudit(
|
||
actor_type="agent", actor_id=run.user_id,
|
||
target_customer_id=handover_ticket.customer_id,
|
||
session_id=run.session_id, portal="agent",
|
||
action_type="agent.handover_requested",
|
||
detail={
|
||
"run_id": run_id, "ticket_no": handover_ticket.ticket_no,
|
||
"reason_code": handover_ticket.reason_code,
|
||
"clarification_round": clarification_round,
|
||
"source_reference_count": len(handover_context.source_references),
|
||
},
|
||
created_at=now,
|
||
))
|
||
await self.session.execute(
|
||
update(RequestIdempotency)
|
||
.where(RequestIdempotency.id == run.idempotency_id)
|
||
.values(status="completed", result_message_id=message.id, updated_at=now)
|
||
)
|
||
events = [DomainEvent(
|
||
event_id=str(uuid4()), event_type="agent.run_completed", aggregate_type="agent_run",
|
||
aggregate_id=run_id, trace_id=run.trace_id,
|
||
payload={"run_id": run_id}, occurred_at=now,
|
||
)]
|
||
if memory_extraction_requested:
|
||
events.append(DomainEvent(
|
||
event_id=str(uuid4()), event_type="memory.extraction_requested",
|
||
aggregate_type="agent_run", aggregate_id=run_id, trace_id=run.trace_id,
|
||
payload={"run_id": run_id, "message_id": message.id,
|
||
"customer_id": run.user_id}, occurred_at=now,
|
||
))
|
||
if profile_candidate_requested:
|
||
# 候选画像只允许由已登录客服会话触发;Worker 会再次校验身份标记。
|
||
events.append(DomainEvent(
|
||
event_id=str(uuid4()),
|
||
event_type="customer_profile.candidate_requested",
|
||
aggregate_type="agent_run", aggregate_id=run_id, trace_id=run.trace_id,
|
||
payload={
|
||
"run_id": run_id, "message_id": message.id,
|
||
"customer_id": run.user_id,
|
||
"actor_type": "authenticated_customer",
|
||
},
|
||
occurred_at=now,
|
||
))
|
||
if handover_ticket is not None:
|
||
assert handover_context is not None
|
||
events.append(DomainEvent(
|
||
event_id=str(uuid4()), event_type="conversation.transfer_requested",
|
||
aggregate_type="conversation", aggregate_id=run.session_id,
|
||
trace_id=run.trace_id,
|
||
payload={
|
||
"ticket_no": handover_ticket.ticket_no,
|
||
"handover_context": handover_context.event_metadata,
|
||
},
|
||
occurred_at=now,
|
||
))
|
||
for event in events:
|
||
self.session.add(DomainEventOutbox(
|
||
event_id=event.event_id, event_type=event.event_type,
|
||
aggregate_type=event.aggregate_type, aggregate_id=event.aggregate_id,
|
||
trace_id=event.trace_id, payload=event.payload, occurred_at=now,
|
||
created_at=now, updated_at=now,
|
||
))
|
||
return message.id
|