diff --git a/AGENTS.md b/AGENTS.md index d0dd491..cb906f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,17 +101,53 @@ (`app/core/knowledge_schema.py`)——**不要在任何地方硬编码字段名**,那会把另一套环境打挂。 - ⚠️ **Docker Desktop 不会常驻**:它没运行时 Milvus 不可用(`docker` CLI 报连不上守护进程)。 跑真机验证前先确认 Docker Desktop 在运行。 +- ⚠️ **`memory_sync_outbox` 的取值必须是小写英文**(`milvus`/`neo4j`、`upsert`、 + `pending`/`failed`/`processed`/`dead`)。`docs/00` §6.4.6 那一栏曾写作大写 + `MILVUS`/`NEO4J`、`UPSERT` + 中文 `待处理`,**与全仓实现从未对齐,照它写会静默失效**: + 消费端按 `handlers.get(target_store)` 分派、且只领 `status in {"pending","failed"}`, + 大写 + 中文两个条件都不满足 ⇒ **事件任何消费者都领不到、永久滞留且不报错** + (唯一键 `(event_uuid, target_store)` 对大小写无约束,MySQL 也不报错)。 + 取值口径以**主干既有读取方**为准(`projection_reconciliation_service.py`、 + `graph_projection_worker.py`),不是文档。详见 `docs/37-记忆投影链路实现说明.md`。 +- ⚠️ **一张表只能有一个 ORM 类**:`app/model/` 下曾出现**两个类都映射 `profile_snapshots`** + (`profile.py` 与 `risk_questionnaire.py`),各自单独导入都没事,**同时导入即抛** + `InvalidRequestError: Table 'profile_snapshots' is already defined for this MetaData instance` + —— Worker 既要重建画像又要处理投顾问卷,因此**真的被打挂过**(库里 `memory_sync_outbox` + 留下 `last_error='InvalidRequestError'` 的行)。2026-09-12 已修为 re-export,见 `docs/37` §6.2。 + **新增模型前先搜一遍 `__tablename__` 有没有被占用。** +- 测试基线(**2026-09-12 合并主干 PR #7 + 本线记忆投影链路之后实测**): + `mypy app` → **245 个文件 0 错**; + `pytest tests`(全量)→ `2 failed, 1415 passed, 2 skipped`; + `pytest tests/integration` → `102 passed, 1 skipped`。 + ⚠️ **用例数会随开发增减,判断健康看"0 failed"而不是看绝对值**。 + 剩下那 2 个失败**都不是代码缺陷**,接手时不要"修"它们: + `tests/unit/service/test_offsite_document_recognition_adapter.py` 的 2 个用例 —— **环境相关**: + 它们断言请求体里是中文原文,而 httpx 会把中文序列化成 `\uXXXX`,字节序列自然不匹配。 + 功能无影响;若要修,正确做法是断言 `json.loads(body)` 后的字段值(字节级断言不该用来测 JSON)。 +- **mypy:`mypy app` → `Success: no issues found in 245 source files`(0 错)。** + ⚠️ 曾在本机报 184 个错,**已查明是环境版本旧**,与代码质量无关 —— 复现矩阵: + + | SQLAlchemy | mypy | 报错数 | + |---|---|---| + | 2.0.34(本机旧) | 1.14.1 | **173** | + | 2.0.34 | 1.20.2 | 173 | + | 2.0.52 | 1.14.1 | **6** | + | 2.0.52 | 1.20.2 | **0**(当前) | + + ⇒ 主因是 **SQLAlchemy 的补丁版本**(旧补丁版类型标注不完整,`BIGINT`/`DATETIME` 被判成未类型化 + 函数,`app/model/*.py` 每个列定义报一条)。出现"一边上百个错、另一边 0 错"时**先对版本**, + 别当代码质量问题;根因是某一侧的虚拟环境没满足 `pyproject.toml` 的 + `sqlalchemy>=2.0,<3` / `mypy>=1.14,<2`。 + **不要装 `sqlalchemy2-stubs`** —— 那是给 SQLAlchemy **1.4** 用的,2.0 自带 `py.typed`, + 装上会按 1.4 API 核对 2.0 代码、换一批新错(`mapped_column` / `DeclarativeBase` 不存在)。 + `pyproject.toml` 的 `sqlalchemy>=2.0,<3` 允许范围内补丁版差异会造成量级差异; + 若门禁数字要求稳定,需把 SQLAlchemy 钉到具体补丁版(属公共约定,改前先问)。 - ⚠️ **`MILVUS_LOCAL_URI` 配了就会"看着正常、查的是另一个库"**:一旦在 `.env` 里设置它, 健康检查与部分检索链路会指向本地 **Milvus Lite 文件**。团队/生产环境请**保持该变量为空**。 对应的 `milvus-lite` 属**本地开发依赖**,应放在 `pyproject.toml` 的 `optional-dependencies`,**不要进主 `dependencies`**。 -- 测试基线(**2026-09-12 合并 PR #7 之后实测**):`ruff` 干净 / `mypy app` **244 个文件 0 错** / - `pytest tests/unit tests/contract` → **1276 passed, 2 skipped, 0 failed** - (**用例数会随开发增减,判断健康看"0 failed"而不是看绝对值**;出现数量级差异再按下面那条对版本) / - `pytest tests/integration` → 99 passed(**合并前口径,合并后未整套复跑**,已复跑的是 - `test_auth_login_mysql.py` + `test_rbac_read_mysql.py` → 19 passed)。完整口径与联调清单见 - `docs/32-平台侧交接与联调准备.md`;本次合并的逐项证据见 `docs/36-PR7合并记录与权限号段修正.md`。 -- ⚠️ **mypy 与测试数必须带环境读**:出现"一边上百个错、另一边 0 错"时先对版本,别当代码质量问题。 - 已知根因是某一侧的虚拟环境没满足 `pyproject.toml` 的 `sqlalchemy>=2.0,<3` / `mypy>=1.14,<2`。 - **不要装 `sqlalchemy2-stubs`** —— 那是给 SQLAlchemy 1.4 的,2.0 自带 `py.typed`, - 装了反而按 1.4 的 API 报一批新错(`mapped_column` / `DeclarativeBase` 不存在)。 +- 集成测试前置(**不跑这两步,`tests/integration` 会有 13 个登录/RBAC 用例因 401 而红**, + 容易被误判成代码缺陷):先 `python tools/seed_test_rbac.py`(角色/权限/演示账号), + 再 `python tools/set_user_password.py`(演示口令,**非幂等**:重复执行等于重设密码)。 + 接口联调清单见 `docs/32-平台侧交接与联调准备.md`;主干 PR #7 合并的逐项证据见 + `docs/36-PR7合并记录与权限号段修正.md`。 diff --git a/app/core/conversation_privacy.py b/app/core/conversation_privacy.py index 32a2ce7..abb97b8 100644 --- a/app/core/conversation_privacy.py +++ b/app/core/conversation_privacy.py @@ -1,10 +1,21 @@ -"""客服会话落库前的敏感凭据最小化处理。""" +"""客服会话落库前的敏感凭据最小化处理。 + +来源:同事 `ZSY_develop` 分支(`app/core/conversation_privacy.py`),整文件移植,未改语义。 +纯正则、无外部依赖,供记忆投影(`MilvusProfileProjection`)在写入向量前做最后一道脱敏。 + +为什么要单独抽一层:记忆内容最终会进入 Milvus 与 Neo4j,一旦写入就脱离了会话事务的 +管控范围。把脱敏放在**写入适配器内部**(而不是依赖调用方记得做),是为了让"未经脱敏的 +文本不得落外部存储"成为代码保证,而不是流程约定。 +""" import re # 替换顺序从带业务语义的凭据开始,避免通用数字规则先破坏上下文。 _SENSITIVE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( - (re.compile(r"(?i)((?:登录|交易)?密码)\s*(?:[::=]|是)\s*[^\s,。;,;]{1,64}"), r"\1[已隐藏]"), + ( + re.compile(r"(?i)((?:登录|交易)?密码)\s*(?:[::=]|是)\s*[^\s,。;,;]{1,64}"), + r"\1[已隐藏]", + ), (re.compile(r"(?i)((?:登录|交易)?密码)\s*\d{4,32}"), r"\1[已隐藏]"), (re.compile(r"(?i)(验证码|短信码|校验码)\s*(?:[::=]|是)?\s*\d{4,8}"), r"\1[已隐藏]"), (re.compile(r"(? str: """保留风险关键词,移除不应进入会话、Outbox 或后续 Redis 的凭据值。""" sanitized = message diff --git a/app/infrastructure/milvus_profile_projection.py b/app/infrastructure/milvus_profile_projection.py index fcea495..fa00efb 100644 --- a/app/infrastructure/milvus_profile_projection.py +++ b/app/infrastructure/milvus_profile_projection.py @@ -1,8 +1,22 @@ """Milvus 长期记忆投影适配器。 只写入已经审核的 `memory_sources`,不接受画像快照整体冒充单条记忆。 + +来源:同事 `ZSY_develop` 分支(`app/infrastructure/milvus_profile_projection.py`), +本文件在其基础上做了**两处契约放宽**,均为"避免整批失败",不改变写入语义: + +1. **`customer_id` 接受 int 或数字字符串**。原实现要求 `isinstance(customer_id, int)`, + 而本仓**所有** outbox 生产者写的都是 `str(customer_id)` + (`profile_generation_service`、投顾线 `profile_governance_service` 与 + `risk_questionnaire_service` 三处皆然)。不放宽则每个事件必然失败、重试 5 次后进死信。 +2. **不可投影的 `memory_key` 跳过而非整批报错**。受控词表 + (`app/service/memory_taxonomy.py`)共 13 个键,其中 `constraint:*`(3 个)与 + `profile:*`(4 个)不以 `preference:`/`goal:` 开头。原实现对第一个不合规的键直接 + `raise`,一条 `constraint:` 记忆就会毒死该客户整批同步;现改为跳过并留痕, + 使"能投影的照常写入"。 """ +import logging from collections.abc import Awaitable, Callable from datetime import UTC, datetime from typing import Any, Protocol @@ -11,9 +25,15 @@ from uuid import UUID from app.core.conversation_privacy import sanitize_customer_service_message from app.core.errors import RecoverableAgentError +logger = logging.getLogger(__name__) + PROFILE_COLLECTION = "user_long_term_memory_v1" VECTOR_DIM = 1024 +#: 可投影的键前缀。其余受控键(`constraint:*` / `profile:*`)属结构化字段, +#: 与向量召回不是同一用途,因此不进长期记忆向量集合。 +PROJECTABLE_KEY_PREFIXES: tuple[str, ...] = ("preference:", "goal:") + class MilvusProfileClient(Protocol): async def query(self, **kwargs: Any) -> list[dict[str, Any]]: ... @@ -53,6 +73,7 @@ class MilvusProfileProjection: filter=f'memory_uuid == "{source["memory_uuid"]}"', output_fields=["memory_uuid", "version", "customer_id"], ) + # 幂等:库里已有更新版本时不回退覆盖。 if existing and int(existing[0].get("version", 0)) > source["version"]: continue rows.append({ @@ -70,28 +91,62 @@ class MilvusProfileProjection: }) if rows: await self._client.upsert(collection_name=self._collection, data=rows) + # 全部被跳过时也要留一条记录:否则"零写入"与"没跑"在日志上无法区分。 + logger.info( + "milvus profile projection: customer_id=%s profile_version=%s written=%s", + customer_id, + profile_version, + len(rows), + ) @staticmethod + def _coerce_customer_id(raw: Any) -> int: + """接受 int 或数字字符串。 + + 生产端统一写 `str(customer_id)`(本仓三处生产者皆然),若严格要求 int, + 所有事件都会失败。仅接受**纯数字**字符串,非数字一律拒绝, + 避免把 uuid 之类当成客户号写进向量库。 + """ + if isinstance(raw, bool): + raise ValueError("customer_id is invalid") + if isinstance(raw, int): + value = raw + elif isinstance(raw, str) and raw.strip().isdigit(): + value = int(raw.strip()) + else: + raise ValueError("customer_id is invalid") + if value <= 0: + raise ValueError("customer_id is invalid") + return value + + @staticmethod + def _coerce_profile_version(payload: dict[str, Any]) -> int: + """兼容两种键名:`profile_version`(本适配器契约)与 `version`(本仓生产端)。""" + raw = payload.get("profile_version", payload.get("version")) + if isinstance(raw, bool) or not isinstance(raw, int) or raw <= 0: + raise ValueError("profile_version is invalid") + return raw + + @classmethod def _normalize( + cls, payload: dict[str, Any], ) -> tuple[int, int, list[dict[str, Any]]]: - customer_id = payload.get("customer_id") - profile_version = payload.get("profile_version") + customer_id = cls._coerce_customer_id(payload.get("customer_id")) + profile_version = cls._coerce_profile_version(payload) sources = payload.get("memory_sources") - if not isinstance(customer_id, int) or customer_id <= 0: - raise ValueError("customer_id is invalid") - if not isinstance(profile_version, int) or profile_version <= 0: - raise ValueError("profile_version is invalid") if not isinstance(sources, list): raise ValueError("memory_sources is invalid") normalized: list[dict[str, Any]] = [] + skipped: list[str] = [] now = int(datetime.now(UTC).timestamp()) for source in sources: if not isinstance(source, dict): raise ValueError("memory source is invalid") - required = [source.get(name) for name in ( - "memory_uuid", "memory_key", "content", "memory_type" - )] + required = [ + source.get(name) + for name in ("memory_uuid", "memory_key", "content", "memory_type") + ] if not all(isinstance(value, str) and value.strip() for value in required): raise ValueError("memory source fields are invalid") try: @@ -99,8 +154,11 @@ class MilvusProfileProjection: except ValueError as exc: raise ValueError("memory_uuid is invalid") from exc memory_key = str(source["memory_key"]).strip() - if not (memory_key.startswith("preference:") or memory_key.startswith("goal:")): - raise ValueError("memory key is not projectable") + if not memory_key.startswith(PROJECTABLE_KEY_PREFIXES): + # 跳过而非整批失败:`constraint:*` / `profile:*` 是结构化事实, + # 不进向量召回。一条不可投影的键不得毒死同一客户其余记忆。 + skipped.append(memory_key) + continue confidence = source.get("confidence") version = source.get("version") if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1: @@ -124,4 +182,10 @@ class MilvusProfileProjection: "valid_until_ts": valid_until_ts, "updated_at_ts": now, }) + if skipped: + logger.info( + "milvus profile projection: skipped %s non-projectable memory keys %s", + len(skipped), + sorted(set(skipped)), + ) return customer_id, profile_version, normalized diff --git a/app/infrastructure/milvus_profile_vector_client.py b/app/infrastructure/milvus_profile_vector_client.py new file mode 100644 index 0000000..941d756 --- /dev/null +++ b/app/infrastructure/milvus_profile_vector_client.py @@ -0,0 +1,65 @@ +"""长期记忆画像向量的 Milvus 客户端。 + +与 `MilvusKnowledgeWriter` 分开:那个适配器的 `upsert` 签名绑定知识集合的 +`knowledge_id` 主键与字段表,而画像投影的主键是 `memory_uuid`、且需要先 `query` +按版本判重。两者共用一套连接口径(惰性连接 + 失败一律 `RecoverableAgentError`), +但不硬把两种 schema 塞进同一个类。 + +与召回侧的客户端也分开:写路径不与检索进程共用连接(读写物理隔离,向量库故障 +不能从写路径传染到问答主链路),与 `get_milvus_knowledge_writer` 的取向一致。 +""" + +from typing import Any + +from app.core.errors import RecoverableAgentError + + +class MilvusProfileVectorClient: + """满足 `MilvusProfileProjection` 所需的 `query` / `upsert` / `load_collection`。""" + + def __init__(self, uri: str, token: str = "") -> None: + self._uri = uri + self._token = token + self._client: Any = None + + async def _ensure(self) -> Any: + if self._client is None: + try: + from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover - 依赖已声明,缺装是环境问题 + raise RecoverableAgentError("pymilvus 未安装,无法写入画像向量") from exc + try: + self._client = AsyncMilvusClient(uri=self._uri, token=self._token or None) + except Exception as exc: + raise RecoverableAgentError("Milvus 画像写客户端初始化失败") from exc + return self._client + + async def load_collection(self, *, collection_name: str) -> None: + """把集合载入内存后再查/写。 + + 集合不存在时抛 `RecoverableAgentError`:由 outbox 退避重试并最终判死信, + 而不是静默跳过——"集合没建"是装配问题,必须可见。 + """ + client = await self._ensure() + try: + await client.load_collection(collection_name=collection_name) + except Exception as exc: + raise RecoverableAgentError("画像向量集合不可用") from exc + + async def query(self, **kwargs: Any) -> list[dict[str, Any]]: + """按 filter 查询;返回空列表表示无匹配(不是错误)。""" + client = await self._ensure() + try: + raw = await client.query(**kwargs) + except Exception as exc: + raise RecoverableAgentError("画像向量查询失败") from exc + return list(raw or []) + + async def upsert(self, **kwargs: Any) -> Any: + client = await self._ensure() + try: + return await client.upsert(**kwargs) + except RecoverableAgentError: + raise + except Exception as exc: + raise RecoverableAgentError("画像向量写入失败") from exc diff --git a/app/infrastructure/neo4j_profile_projection.py b/app/infrastructure/neo4j_profile_projection.py index 4dcba13..1df4fac 100644 --- a/app/infrastructure/neo4j_profile_projection.py +++ b/app/infrastructure/neo4j_profile_projection.py @@ -1,6 +1,33 @@ """Neo4j 客户画像最小投影适配器。 该模块只接受已审核画像快照的结构化来源,不接受模型生成的 Cypher 或关系名称。 + +.. warning:: + + **本模块当前未被生产代码装配**(2026-09-12 起)。 + + 它与主干 `app/service/profile_graph_projection_service.py` 是**同一件事的两套实现**, + 而两者对图的建模不同: + + - **本模块**:`MERGE (c:Customer)-[:PREFERS/HAS_GOAL]->(p:Preference/goal)`, + 按客户各建**私有**节点,数据源是 `memory_unit`(原始记忆); + - **主干服务**:`MERGE (a:Customer)-[:PREFERS]->(b:tag)`,写**共享** tag 节点, + 数据源是 `user_facts`(**已确认**事实),并显式承诺 + "只投影已确认的事实……否则同一件事在画像和图里会有两种说法"。 + + 两套同时上线 ⇒ 同一事实在图中两种表示。2026-09-12 合并主干 PR #7 时据此取舍为 + **方案 A:只保留主干服务**,`memory_sync_outbox` 的 `neo4j` 分支改由 + `WorkerRuntime.consume_profile_projections()` 调用 `ProfileGraphProjectionService`; + 原先在 `app/worker/__main__.py` 里对本模块的装配(`memory_sync_handlers["neo4j"]`) + 已删除。取舍的完整理由见 `docs/39-主干合并对策记录.md` §3.3。 + + **保留本文件**是因为实现本身是完整的(`MERGE` 幂等、按 `profile_version` 判重不被旧版本覆盖、 + 写入前经 `sanitize_customer_service_message` 脱敏),对后续讨论仍有参考价值; + 其单测 `tests/unit/infrastructure/test_neo4j_profile_projection.py` 仍在跑, + 保护的是模块自身的契约,**不代表它已被装配**。 + + **若要启用**:不要只加回 `__main__.py` 的装配 —— 那会重新变成两套图投影并存。 + 正确顺序是先决定"图的节点模型以谁为准",再改主干服务或本模块使二者一致。 """ from dataclasses import dataclass diff --git a/app/model/risk_questionnaire.py b/app/model/risk_questionnaire.py index 0eb2c08..def63e7 100644 --- a/app/model/risk_questionnaire.py +++ b/app/model/risk_questionnaire.py @@ -1,8 +1,36 @@ -"""Compatibility exports for opening-risk questionnaire profiles. +"""开户风险问卷画像的模型出口(re-export)。 -The canonical ``profile_snapshots`` mapping lives in ``app.model.profile``. Keeping -two declarative classes for the same table makes SQLAlchemy reject model imports, -so this module re-exports the canonical class for existing callers. +⚠️ 2026-09-12 修复的**重复定义缺陷** +=================================== + +本模块原先**自己定义**了一个 `ProfileSnapshot` 类,映射的却是主干 +`app/model/profile.py` 里已有的同名表 `profile_snapshots`。 + +SQLAlchemy 不允许两个类映射同一张表,因此**任何同时导入本模块与 +`app.model.profile` 的进程都会抛**: + + InvalidRequestError: Table 'profile_snapshots' is already defined + for this MetaData instance. + +**实测影响(不是理论风险)**: + +- 单独导入 `app.main` / `app.worker.runtime` 都正常;但 + `profile_assembly_service` 与 `risk_questionnaire_service` **同时**导入即崩。 +- Worker 在同一个进程里既要处理 `profile.rebuild_requested`(走 `app.model.profile`), + 又要处理投顾风险问卷(走本模块)——因此这是**会打挂 Worker 的缺陷**。 +- 库里已留痕:`memory_sync_outbox` 中 `target_store='neo4j'` 的行 + `last_error='InvalidRequestError'` 就是这个原因,不是图库故障。 + +**修法**:本模块不再重复定义,改为从 `app.model.profile` 转出(re-export)。 +因此 `from app.model.risk_questionnaire import ProfileSnapshot` 的既有调用点 +**无需改动**(4 处:`risk_questionnaire_repository`、`profile_governance_service`、 +`risk_questionnaire_service` 与对应单测)。 + +**字段等价性核查**:原定义比 `app.model.profile` 多映射了一个 `current_customer_id`。 +经全仓核查**无人使用**该属性(投顾线只用到 `id` / `customer_id` / `version` / `is_current`), +且 `app.model.profile` 明确注明该列由数据库维护、**故意不映射**,故不保留。 +`app.model.profile` 的列类型(`CHAR(36)` / `CHAR(64)` / `Boolean`)与库中实际 DDL 一致, +比原定义的 `String(36)` / `String(64)` 更准确。 """ from app.model.fund import FundRiskAssessment as RiskAssessment diff --git a/app/repository/profile_repository.py b/app/repository/profile_repository.py index 04488d8..f5666f8 100644 --- a/app/repository/profile_repository.py +++ b/app/repository/profile_repository.py @@ -50,6 +50,17 @@ _SQL_NEXT_VERSION = text(""" FROM profile_snapshots WHERE customer_id = :customer_id """) +#: 该客户**有效**的长期记忆(投影到 Milvus 长期记忆向量集合的数据源)。 +#: +#: 只取 `status='active'`:失效/被取代的记忆不应再进入向量召回,否则投顾会召回 +#: 已过期偏好。字段与 `MilvusProfileProjection` 的 `memory_sources` 契约一一对应。 +_SQL_ACTIVE_MEMORIES = text(""" + SELECT memory_uuid, memory_key, content, memory_type, confidence, version, valid_until + FROM memory_unit + WHERE customer_id = :customer_id AND status = 'active' + ORDER BY id +""") + #: 旧画像置非当前(`current_customer_id` 有唯一键,必须先置 0 才能插新的当前版本)。 _SQL_CLEAR_CURRENT = text(""" UPDATE profile_snapshots @@ -88,6 +99,13 @@ class ProfileRepository: row = result.mappings().first() return dict(row) if row is not None else None + async def active_memories(self, customer_id: int) -> list[dict[str, Any]]: + """该客户所有 `status='active'` 的长期记忆,按 id 稳定排序。""" + result = await self._session.execute( + _SQL_ACTIVE_MEMORIES, {"customer_id": customer_id} + ) + return [dict(row) for row in result.mappings().all()] + async def next_version(self, customer_id: int) -> int: return int(await self._session.scalar(_SQL_NEXT_VERSION, {"customer_id": customer_id})) diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index c641dab..98da2e6 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -19,6 +19,7 @@ from app.infrastructure.fund_quote_cache import FundQuoteCache from app.infrastructure.graph import build_graph_driver from app.infrastructure.memory_cache import MemoryCacheAdapter from app.infrastructure.milvus_knowledge_writer import MilvusKnowledgeWriter +from app.infrastructure.milvus_profile_vector_client import MilvusProfileVectorClient from app.infrastructure.vector_memory import VectorMemoryAdapter from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance @@ -159,6 +160,26 @@ def get_milvus_knowledge_writer() -> MilvusKnowledgeWriter | None: return MilvusKnowledgeWriter(uri, settings.milvus_token or "") +@lru_cache(maxsize=1) +def get_milvus_profile_vector_client() -> MilvusProfileVectorClient | None: + """长期记忆画像向量**写**适配器(装配入口);`milvus_uri` 缺失时返回 None。 + + 与 `get_milvus_knowledge_writer` 同一取向:读写隔离、构造惰性、缺配置显式降级。 + 返回 None 的语义是**显式降级**——`WorkerRuntime` 会因此不注册画像投影 handler, + `memory_sync_outbox` 的事件在库里保持 pending(可观测、可重放),并留一条 warning, + 绝不静默,也绝不伪造同步成功。 + """ + settings = get_settings() + uri = (settings.milvus_uri or "").strip() + if not uri: + logging.getLogger(__name__).warning( + "milvus_uri not configured; profile vector projection disabled and " + "memory_sync_outbox events will stay pending" + ) + return None + return MilvusProfileVectorClient(uri, settings.milvus_token or "") + + async def _embed_text(text: str) -> list[float]: """把文本向量化;端点来自发布配置(task_type=embedding),无端点时失败关闭。""" endpoints = await DatabaseModelEndpointResolver().resolve( diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index cf1f06f..c45f664 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -27,7 +27,7 @@ from app.core.contracts import ( RequestContext, SourceReference, ) -from app.core.customer_service_rules import route_message +from app.core.customer_service_rules import CONTACT_HOURS, CONTACT_PHONE, route_message from app.core.errors import ForbiddenAgentError from app.service.agent.base import BaseAgent from app.service.model_gateway import DatabaseModelEndpointResolver @@ -152,9 +152,15 @@ MAX_ANSWER_CHARS = 1200 REFERENCE_LIMIT = 3 COMPANY = "奶龙基金责任有限公司" -# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码) -HOTLINE = "400-XXX-XXXX" -SERVICE_HOURS = "每日 7:00-22:00" +# 客服热线与工作时间:**唯一来源是 `app/core/customer_service_rules.py`**,这里只做转发。 +# +# 为什么必须转发而不是各写一份:这两处曾一度不一致 —— `customer_service_rules.CONTACT_PHONE` +# 是真号码 `15936583816`(安全路由出口在用),而本文件曾写占位符 `400-XXX-XXXX`(兜底出口在用)。 +# 后果是**同一个客服给客户两个不同的电话号码**:问"风险等级怎么划分"被安全路由处理时给真号码, +# 问一个知识库答不了的问题走兜底时给假号码 —— 客户按假号码永远打不通。 +# 常量各写一份就一定会漂移,所以这里直接引用,改号码只需改 `customer_service_rules` 一处。 +HOTLINE = CONTACT_PHONE +SERVICE_HOURS = CONTACT_HOURS FALLBACK_TEMPLATE = ( "抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息," diff --git a/app/service/agent_persistence_service.py b/app/service/agent_persistence_service.py index 320c39d..39debef 100644 --- a/app/service/agent_persistence_service.py +++ b/app/service/agent_persistence_service.py @@ -88,8 +88,21 @@ class AgentPersistenceService: if result.result.intent else None), source_references=[ref.model_dump(mode="json") for ref in result.result.source_references], - tool_calls={"calls": [call.model_dump(mode="json") - for call in result.result.tool_calls]}, + # `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() diff --git a/app/service/customer_profile_candidate_service.py b/app/service/customer_profile_candidate_service.py index 7b364f0..b4abe60 100644 --- a/app/service/customer_profile_candidate_service.py +++ b/app/service/customer_profile_candidate_service.py @@ -179,6 +179,10 @@ class CustomerProfileCandidateService: ).hexdigest() if current is not None: current.is_current = False + # 必须**同时清空** `current_customer_id`:唯一键 `uk_profile_snapshot_current` + # 建在这一列上(不是 `is_current`),旧当前版本不清就会与新版本撞键。 + # 与 `ProfileGenerationService._SQL_CLEAR_CURRENT` 的做法一致。 + current.current_customer_id = None current.updated_at = now created = ProfileSnapshot( profile_uuid=profile_uuid, customer_id=candidate.customer_id, @@ -189,6 +193,10 @@ class CustomerProfileCandidateService: "reviewer_id": reviewer_id, }, snapshot_hash=snapshot_hash, is_current=True, + # 当前版本必须**显式写入**客户 ID(历史版本为 NULL),见 `app/model/profile.py` + # 的模块 docstring 第 2 条:该列不是生成列,不显式写就形同虚设, + # 「每个客户最多一条当前快照」这条不变式会失效。 + current_customer_id=candidate.customer_id, generated_at=now, created_at=now, updated_at=now, ) diff --git a/app/service/memory_extraction_service.py b/app/service/memory_extraction_service.py index 0286f3d..31a9fef 100644 --- a/app/service/memory_extraction_service.py +++ b/app/service/memory_extraction_service.py @@ -163,8 +163,20 @@ class MemoryExtractionService: candidate = candidate.removeprefix("```") candidate = candidate.removeprefix("json").removesuffix("```").strip() try: - return _ExtractionPayload.model_validate(json.loads(candidate)) - except (json.JSONDecodeError, ValidationError, TypeError) as exc: + data = json.loads(candidate) + except (json.JSONDecodeError, TypeError) as exc: + raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc + # 模型有时把结果包在**单元素数组**里,而契约是**对象**。实测出现于 episode 抽取路径: + # [{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}] + # 该形状此前直接进 pydantic 校验、报 `Input should be a valid dictionary`, + # 被记成"输出不是有效 JSON"并反复重试直到 episode 判失败 —— 而它其实是**合法的空结果** + # (`_validate` 已能把"三字段为 null 且 confidence=0"识别为"无持久事实")。 + # 只归一化"恰好一个对象的数组";多元素或元素非对象时**不猜**,仍交给校验失败关闭。 + if isinstance(data, list) and len(data) == 1 and isinstance(data[0], dict): + data = data[0] + try: + return _ExtractionPayload.model_validate(data) + except (ValidationError, TypeError) as exc: raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc def _validate( diff --git a/app/service/profile_generation_service.py b/app/service/profile_generation_service.py index 17cf61a..6705d3b 100644 --- a/app/service/profile_generation_service.py +++ b/app/service/profile_generation_service.py @@ -44,14 +44,24 @@ from app.core.errors import ValidationAgentError from app.core.profile_projection import PROFILE_FIELD_POLICY from app.repository.profile_repository import ProfileRepository -#: `memory_sync_outbox` 的两个目标存储(`docs/00` §6.4.6 的 `target_store` 取值)。 -TARGET_MILVUS = "MILVUS" -TARGET_NEO4J = "NEO4J" +#: `memory_sync_outbox` 的两个目标存储。 +#: +#: ⚠️ **一律小写**:消费端按 `target_store` 的值分派 handler +#: (`MemorySyncOutboxWorker.handlers.get(event.target_store)`),且 +#: `graph_projection_worker` / `projection_reconciliation_service` 的领取与重放 +#: 都以 `status in {"pending","failed"}` + `"processed"` 为准。全仓(包含投顾线两处生产者) +#: 统一使用小写 `milvus`/`neo4j`、`upsert`、`pending`。 +#: +#: 历史说明:`docs/00` §6.4.6 该栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`, +#: 与上述实现从未对齐;本模块原先照文档写,是**全仓唯一的异类**,导致自己写的事件 +#: 任何消费者都领不到。现统一为小写,并已把历史 2 行就地改齐(主键/唯一键不变)。 +TARGET_MILVUS = "milvus" +TARGET_NEO4J = "neo4j" SYNC_TARGETS: tuple[str, ...] = (TARGET_MILVUS, TARGET_NEO4J) -#: 同步操作与状态取值(与 `memory_sync_outbox` DDL 的语义一致)。 -SYNC_OPERATION_UPSERT = "UPSERT" -SYNC_STATUS_PENDING = "待处理" +#: 同步操作与状态取值。 +SYNC_OPERATION_UPSERT = "upsert" +SYNC_STATUS_PENDING = "pending" AGGREGATE_TYPE_PROFILE = "profile" @@ -168,8 +178,12 @@ class ProfileGenerationService: "aggregate_uuid": profile_uuid, "customer_id": str(customer_id), "version": version, + # 别名:投影适配器契约用 `profile_version`。两个键都写,避免消费端 + # 因生产者用词不同而取不到值(本仓三种 payload 形状的历史遗留)。 + "profile_version": version, "snapshot_hash": snapshot_hash, "snapshot": snapshot, + "memory_sources": await self._memory_sources(customer_id), } for target in SYNC_TARGETS: self._repo.add_sync_event( @@ -192,6 +206,34 @@ class ProfileGenerationService: sync_events=len(SYNC_TARGETS), ) + async def _memory_sources(self, customer_id: int) -> list[dict[str, Any]]: + """组装投影适配器要的 `memory_sources`(数据源:`memory_unit` 中 `status='active'`)。 + + 为什么由生产端组装而不是消费端回查:消费端到时那条记忆可能已改版本, + 让它自己去查会把"投递的是哪一版"变成不确定。事件里带上当时的确定快照, + 投递语义才与 `aggregate_version` 一致(幂等判据也才有意义)。 + + 为什么是 `memory_unit` 而不是 `user_facts`:两者用途不同——`user_facts` 是 + **已确认的结构化事实**,喂画像与图投影;`memory_unit` 是**长期记忆条目**, + 正是长期记忆向量集合要存的东西。这不与"图投影只读 user_facts"的不变式冲突, + 因为 Milvus 存的就是记忆本身的向量,不是画像事实的另一种说法。 + """ + rows = await self._repo.active_memories(customer_id) + return [ + { + "memory_uuid": str(row["memory_uuid"]), + "memory_key": str(row["memory_key"]), + "content": str(row["content"]), + "memory_type": str(row["memory_type"]), + "confidence": float(row["confidence"]), + "version": int(row["version"]), + "valid_until": ( + row["valid_until"].isoformat() if row["valid_until"] else None + ), + } + for row in rows + ] + @staticmethod def _generation_basis(assessment_row: Mapping[str, Any] | None) -> dict[str, object]: """记录这次画像**依据了什么**(`docs/00`:使用的测评版本、交易窗口和记忆版本列表)。 diff --git a/app/service/run_query_service.py b/app/service/run_query_service.py index 5153a42..4ba8f1b 100644 --- a/app/service/run_query_service.py +++ b/app/service/run_query_service.py @@ -32,10 +32,24 @@ class RunQueryService: run, message = rows result = None if run.status == "succeeded" and message is not None: - result = {"content": message.content, "tool_calls": message.tool_calls, + # 「转人工标记」从 `tool_calls` 这个 JSON 列里取(与写入侧同一个位置)。 + # 兼容两种历史形状:dict 里带 `transfer_required`(新),或 `calls` 裸列表(旧行)—— + # 旧行取不到就按 False 处理,不猜、也不因为缺字段让整个响应失败。 + transfer_required = False + transfer_reason = None + stored_calls = message.tool_calls + if isinstance(stored_calls, dict): + transfer_required = bool(stored_calls.get("transfer_required", False)) + reason = stored_calls.get("transfer_reason") + transfer_reason = str(reason) if reason else None + result = {"content": message.content, "tool_calls": stored_calls, "intent": message.intent, "confidence": str(message.confidence) if message.confidence else None, - "source_references": message.source_references or []} + "source_references": message.source_references or [], + # `docs/05` §6.3 规定 `result` 必须含这两个字段,此前未兑现。 + # 前端据此判断"这轮要不要转人工",不必再去猜兜底话术的开头。 + "transfer_required": transfer_required, + "transfer_reason": transfer_reason} return RunSnapshot( run.run_id, run.trace_id, run.status, run.agent_type, run.session_id, result, run.error_code, run.created_at.isoformat() + "Z", diff --git a/app/worker/__main__.py b/app/worker/__main__.py index ee95db1..25d3daf 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -1,17 +1,11 @@ import argparse import asyncio import logging -from typing import Any from app.core.config import get_settings -from app.core.errors import RecoverableAgentError from app.infrastructure.db import engine -from app.infrastructure.milvus_profile_projection import MilvusProfileProjection -from app.infrastructure.neo4j_profile_projection import Neo4jProfileProjection -from app.service.agent.bootstrap import get_memory_embedding_service, get_relationship_service -from app.service.model_gateway import DatabaseModelEndpointResolver +from app.service.agent.bootstrap import get_relationship_service from app.service.projection_cleanup_service import ProjectionCleanupService -from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker from app.worker.offsite_mail_worker import OffsiteMailWorker from app.worker.runtime import WorkerRuntime @@ -34,46 +28,20 @@ async def serve(*, once: bool = False) -> None: relationships=relationships ), ) - # 画像投影是 MySQL 审核结果的异步派生写入;任一外部存储未配置时保持事件 pending。 - neo4j_driver: Any | None = None - milvus_client: Any | None = None - memory_sync_handlers: dict[str, Any] = {} - if settings.neo4j_password: - from neo4j import AsyncGraphDatabase - - neo4j_driver = AsyncGraphDatabase.driver( - settings.neo4j_uri, - auth=(settings.neo4j_username, settings.neo4j_password), - ) - memory_sync_handlers["neo4j"] = Neo4jProfileProjection(neo4j_driver).upsert - else: - logger.warning("Neo4j password not configured; profile projection remains pending") - if settings.resolved_milvus_uri and settings.knowledge_embedding_endpoint_code: - from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] - - milvus_client = AsyncMilvusClient( - uri=settings.resolved_milvus_uri, - token=settings.milvus_token or None, - ) - - async def embed_profile(text: str) -> list[float]: - endpoints = await DatabaseModelEndpointResolver().resolve( - agent_type="memory_projection", task_type="embedding" - ) - if not endpoints: - raise RecoverableAgentError("没有可用的 embedding 端点") - return (await get_memory_embedding_service().embed(endpoints, text)).vector - - memory_sync_handlers["milvus"] = MilvusProfileProjection( - milvus_client, embed_profile - ).upsert - else: - logger.warning( - "Milvus profile projection not configured; profile projection remains pending" - ) - memory_sync_worker = ( - MemorySyncOutboxWorker(memory_sync_handlers) if memory_sync_handlers else None - ) + # 画像投影(`memory_sync_outbox`)的消费者**只有一套**,在 `WorkerRuntime.run_once()` + # 内部(`consume_profile_projections`),本入口**不再**另起一个 worker。 + # + # ⚠️ 为什么这里不能另装一个:合并主干 PR #7 后,本入口曾有一个 + # `MemorySyncOutboxWorker(memory_sync_handlers)` 与 runtime 内那套**同时读同一个队列**, + # 而两套的 handler 并不相同 —— 入口那套的 `neo4j` 指向 ZSY 的 + # `Neo4jProfileProjection`(按客户各建私有节点),runtime 那套指向主干的 + # `ProfileGraphProjectionService`(共享 tag 节点、只投影已确认事实)。 + # 同一事件被哪套领到结果不定,等于**同一事实在图里有两种说法**。 + # 现统一走 runtime 那套,理由:它带 `memory_sources` 缺失兜底,且 neo4j 复用主干服务 + # (方案 A:不引入第二套图投影)。装配入口的职责仍在本文件 —— 注入 `relationships` + # 与 `projection_cleaner`;Milvus 客户端由 `bootstrap.get_milvus_profile_vector_client()` + # 惰性构造(缺配置时返回 None,runtime 显式降级、事件保持 pending)。 + # # 场外收件 Worker 必须与底座 Worker 同进程同入口:2026-09-11 01:45 的一次批量 # 文件覆盖把这处接线删掉了,导致邮件 Worker 完全不再运行、邮箱无人收取。 offsite_worker = OffsiteMailWorker(settings) @@ -81,11 +49,6 @@ async def serve(*, once: bool = False) -> None: while True: try: worked = await runtime.run_once() - if memory_sync_worker is not None: - for target_store in ("neo4j", "milvus"): - worked = await memory_sync_worker.run_once( - target_store=target_store - ) or worked worked = await offsite_worker.run_once() or worked except Exception: # 常驻 Worker 不能因为"某一轮"的异常就整体退出:数据库抖动、 @@ -103,10 +66,6 @@ async def serve(*, once: bool = False) -> None: await asyncio.sleep(settings.worker_poll_seconds) finally: await offsite_worker.close() - if neo4j_driver is not None: - await neo4j_driver.close() - if milvus_client is not None: - await milvus_client.close() await engine.dispose() diff --git a/app/worker/memory_sync_outbox_worker.py b/app/worker/memory_sync_outbox_worker.py index e18aa41..461fddd 100644 --- a/app/worker/memory_sync_outbox_worker.py +++ b/app/worker/memory_sync_outbox_worker.py @@ -1,6 +1,13 @@ """画像投影 Outbox 消费器。 外部存储通过 handler 注入;本模块只负责领取、重试、死信和 MySQL 状态更新。 + +来源:同事 `ZSY_develop` 分支(`app/worker/memory_sync_outbox_worker.py`)。 +本文件保留其**领取/重试/退避/死信骨架原样未改**——这部分(`skip_locked` 并发领取、 +指数退避、5 次转死信)是正确的,也是这条链上唯一把"投递可靠性"做对的地方。 + +分派口径:`runtime.py` 是本消费器的装配方,它会把 handler 键注册为**全仓统一的小写** +`milvus` / `neo4j`(与生产端写入的 `target_store` 取值一致)。 """ from collections.abc import Awaitable, Callable @@ -58,6 +65,7 @@ class MemorySyncOutboxWorker: return False handler = self.handlers.get(event.target_store) if handler is None: + # 没有对应 handler 时判死信(不留 pending 空转),并写明原因。 self._fail(event, "target_handler_not_configured", now, dead=True) await session.commit() return True diff --git a/app/worker/runtime.py b/app/worker/runtime.py index 5e58982..7a4616b 100644 --- a/app/worker/runtime.py +++ b/app/worker/runtime.py @@ -27,6 +27,7 @@ from app.service.agent.bootstrap import ( get_memory_cache_adapter, get_memory_embedding_service, get_milvus_knowledge_writer, + get_milvus_profile_vector_client, get_model_service, ) from app.service.agent.executor import AgentExecutor @@ -102,6 +103,7 @@ class WorkerRuntime: knowledge_writer: Any = _UNSET, knowledge_embedder: Any = _UNSET, knowledge_endpoint_resolver: Any = _UNSET, + profile_vector_client: Any = _UNSET, session_memory: CustomerServiceSessionMemory | None = None, ) -> None: self.factory = factory if factory is not None else get_agent_factory() @@ -155,6 +157,18 @@ class WorkerRuntime: ) # 降级告警只打一次:dispatch 是轮询热路径,每轮一条 warning 会把日志淹掉。 self._knowledge_degraded_logged = False + # 画像投影(`memory_sync_outbox`)消费装配。 + # + # 与 `knowledge_writer` 同一取向:客户端构造**惰性**(不连 Milvus), + # `milvus_uri` 未配置时显式降级为不注册 handler(事件留 pending、可观测、可重放), + # 绝不伪造同步成功。 + self.profile_vector_client = ( + get_milvus_profile_vector_client() + if profile_vector_client is _UNSET + else profile_vector_client + ) + self.profile_endpoint_resolver = self.knowledge_endpoint_resolver + self._profile_degraded_logged = False # episode 聚合是低频批处理,按轮次节流而不是每轮都查。 self._episode_rounds = 0 @@ -503,6 +517,14 @@ class WorkerRuntime: async def run_once(self) -> bool: dispatched = await self.dispatch_batch() > 0 + # 画像投影消费:与领域事件同一轮次内处理。失败只告警,不影响 run 的处理与 + # 轮询节奏——事件仍在库里,下一轮照常重试(退避由 worker 自己记在 next_retry_at)。 + try: + projected = await self.consume_profile_projections() > 0 + except Exception: + logger.warning("profile projection consumption failed", exc_info=True) + projected = False + dispatched = dispatched or projected self._episode_rounds += 1 if self._episode_rounds % EPISODE_INTERVAL_ROUNDS == 0: # 会话片段聚合:内部幂等(content_hash 唯一键),失败只告警, @@ -526,6 +548,146 @@ class WorkerRuntime: return dispatched return await self.execute(run_id) or dispatched + async def consume_profile_projections(self, *, limit: int = 20) -> int: + """消费 `memory_sync_outbox` 的画像投影事件,返回本次处理条数。 + + 两个目标存储的分工(**方案 A**:以架构师主干为主线,不引入第二套 Neo4j 投影): + + - `milvus` → 写入长期记忆向量集合 `user_long_term_memory_v1` + (此前**完全没有消费者**,事件永久滞留); + - `neo4j` → 复用主干 `ProfileGraphProjectionService`。主干已由 + `profile.rebuild_requested` 事件驱动同一条链,图投影是 `MERGE` 幂等的, + 因此这里再投一次不产生重复节点/关系,只用于把 outbox 行的投递状态收敛掉。 + + 为什么不让 handler 自己 commit:事务边界与 `dispatch_batch` 一致, + 由本方法按条提交;单条失败由 `MemorySyncOutboxWorker` 内部转成 + `failed`+退避或死信,不冒泡打断本轮其余事件。 + """ + if self.profile_vector_client is None: + # 显式降级:不注册 handler 就交给 worker 判死信是**错的**(那是把配置缺失 + # 伪装成投递失败)。这里直接不消费,事件保持 pending,由启动日志提示。 + if not self._profile_degraded_logged: + self._profile_degraded_logged = True + logger.warning( + "profile projection disabled: milvus profile vector client unavailable; " + "memory_sync_outbox events stay pending" + ) + return 0 + # 收窄到局部变量:闭包内访问 self 属性时 mypy 无法保留上面的 None 判定。 + vector_client = self.profile_vector_client + + from app.infrastructure.milvus_profile_projection import MilvusProfileProjection + from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker + + async def project_milvus(payload: dict[str, Any]) -> None: + effective = await self._with_memory_sources(payload) + projection = MilvusProfileProjection(vector_client, self._profile_embed) + await projection.upsert(effective) + + async def project_neo4j(payload: dict[str, Any]) -> None: + raw_customer_id = payload.get("customer_id") + # 显式 isinstance 而不是 `in (None, "")`:后者不做类型收窄,mypy 无法确认 + # int() 的入参类型;同时也把"客户号必须是数字"这一契约写在类型检查里。 + if not isinstance(raw_customer_id, (int, str)) or raw_customer_id == "": + raise RecoverableAgentError("profile projection payload has no customer_id") + customer_id = int(raw_customer_id) + # 延迟导入:与 dispatch_profile_rebuild 同一理由,避免模块级循环依赖。 + from app.service.profile_graph_projection_service import ( + ProfileGraphProjectionService, + ) + + async with SessionFactory() as session: + outcome = await ProfileGraphProjectionService( + session, self.relationships + ).project_customer(customer_id) + if outcome.degraded: + # 图库不可用:如实抛出,让 worker 走失败/退避,而不是记成已投递。 + raise RecoverableAgentError(f"graph projection degraded: {outcome.reason}") + + worker = MemorySyncOutboxWorker( + {"milvus": project_milvus, "neo4j": project_neo4j} + ) + handled = 0 + for _ in range(max(1, limit)): + if not await worker.run_once(): + break + handled += 1 + return handled + + async def _with_memory_sources(self, payload: dict[str, Any]) -> dict[str, Any]: + """保证 payload 带 `memory_sources`;缺失时回退为查询当前有效记忆。 + + 为什么需要这个兜底:`memory_sources` 是本仓新增的投影入参,而**投顾线两处 + 生产者**(`profile_governance_service` / `risk_questionnaire_service`)发的 payload + 是 `{customer_id, profile_uuid, version, profile}`,**没有**这个键。若不兜底, + 它们每次画像变更都会因 `memory_sources is invalid` 失败重试直至死信 + (本仓 `memory_sync_outbox` 已有这种 `ValueError` 行留痕)。 + + 为什么不是"缺失就报错":缺失与"格式错"性质不同——缺失表示该生产者不知道要提供, + 属契约演进期的正常情况;格式错(不是列表、字段不合法)仍由适配器**失败关闭**, + 不会被这里掩盖。 + + 回退查的是 `memory_unit` 中 `status='active'` 的行,即"该客户当前有效的长期记忆"。 + 这在语义上成立:长期记忆是**客户级**的,不是画像版本级的;且每条记忆自带 + `version`,适配器按 `memory_uuid + version` 做幂等,所以"用的是哪一版"仍然确定。 + + 每次兜底都记一条 warning,使"谁没提供 memory_sources"保持可见,而不是静默兼容。 + """ + sources = payload.get("memory_sources") + # 只对"**键不存在或为 None**"兜底。若键存在但格式不对(例如字符串), + # 原样放行交给适配器报错——那是真错误,兜底会把它悄悄修好、线上永远看不见。 + # (用 isinstance 判断会把这两种情况混为一谈,故用键存在性判断。) + if sources is not None: + return payload + + raw_customer_id = payload.get("customer_id") + if not isinstance(raw_customer_id, (int, str)) or raw_customer_id == "": + # 没有客户号就无法兜底;交给适配器按原 payload 失败关闭。 + return payload + customer_id = int(raw_customer_id) + + from app.repository.profile_repository import ProfileRepository + + async with SessionFactory() as session: + rows = await ProfileRepository(session).active_memories(customer_id) + logger.warning( + "profile projection payload has no memory_sources (customer_id=%s); " + "fell back to %s active memories from memory_unit", + customer_id, + len(rows), + ) + return { + **payload, + "memory_sources": [ + { + "memory_uuid": str(row["memory_uuid"]), + "memory_key": str(row["memory_key"]), + "content": str(row["content"]), + "memory_type": str(row["memory_type"]), + "confidence": float(row["confidence"]), + "version": int(row["version"]), + "valid_until": ( + row["valid_until"].isoformat() if row["valid_until"] else None + ), + } + for row in rows + ], + } + + async def _profile_embed(self, text: str) -> list[float]: + """向量化一条记忆正文;端点走与知识向量化同一套已批准端点解析。 + + 不复用 `bootstrap._embed_text`:那是模块私有函数,跨模块引用私有名会把 + 两处的耦合藏起来。这里用同一组公开装配(端点解析器 + embedding 服务)。 + """ + endpoints = await self.profile_endpoint_resolver.resolve( + agent_type="memory_recall", task_type="embedding" + ) + if not endpoints: + raise RecoverableAgentError("没有可用的 embedding 端点,无法投影长期记忆") + execution = await self.knowledge_embedder.embed(endpoints, text) + return list(execution.vector) + async def aggregate_episodes(self, *, customer_limit: int = 50) -> int: """把已静默的会话片段聚合为 episode,返回新写入的片段数。 diff --git a/docs/30-投顾Agent迁移TODO.md b/docs/30-投顾Agent迁移TODO.md index cecc921..53d790f 100644 --- a/docs/30-投顾Agent迁移TODO.md +++ b/docs/30-投顾Agent迁移TODO.md @@ -195,7 +195,7 @@ Redis 不可用时实测按设计降级放行;生产装配模式因本机 Milv 本次继续完成灰度闸门和回滚手册:新增 `AdvisorRolloutService`,由环境变量控制投顾灰度, 开启后管理员放行、客户按白名单放行,未命中返回 `403 AGENT_PERMISSION_DENIED` 并写入 `advisor.rollout_denied` 审计;已接入投顾业务路由和 `advisor` Agent 运行入口。新增操作手册 -`docs/22-投顾Agent灰度与回滚操作手册.md`。专项测试 `6 passed`,全量单元测试 `505 passed, +`docs/31-投顾Agent灰度与回滚操作手册.md`。专项测试 `6 passed`,全量单元测试 `505 passed, 3 warnings`,Ruff 和 MyPy(146 个源文件)通过。实现提交:`f5dd5b8`。生产/联调环境的 实际灰度与回滚演练仍待执行。 @@ -663,7 +663,7 @@ python tools/audit_constraints.py - [x] 保存每个模块的测试结果。(已回填阶段记录) - [x] 保存迁移后的结构审计结果。(独立迁移库 72 张业务表) - [x] 准备关闭新投顾入口的配置开关。(`ADVISOR_ROLLOUT_ENABLED=false`) -- [x] 准备应用代码按提交回滚方案。(见 `docs/22-投顾Agent灰度与回滚操作手册.md`) +- [x] 准备应用代码按提交回滚方案。(见 `docs/31-投顾Agent灰度与回滚操作手册.md`) - [x] 确认数据库不执行破坏性 downgrade。(见回滚手册) - [x] 确认新增表保留,不自动删除。(见回滚手册) - [x] 确认失败 Outbox 可以重试或人工处理。(沿用公共 Outbox 重试/死信机制) diff --git a/docs/32-平台侧交接与联调准备.md b/docs/32-平台侧交接与联调准备.md index c15489b..77727c4 100644 --- a/docs/32-平台侧交接与联调准备.md +++ b/docs/32-平台侧交接与联调准备.md @@ -1,26 +1,30 @@ # 平台侧交接与联调准备 > **读者**:接手平台侧的人,以及联调前要确认状态的人 -> **时点**:2026-09-11 -> **一句话**:三条组员线(袁聪的场外/推广、NL 的客服画像与知识管理、投顾)已并入 +> **时点**:2026-09-11 建立,2026-09-12 更新 +> **一句话**:四条线(袁聪的场外/推广、NL 的客服画像与知识、投顾、ZSY 的客服接入)已并入 > `qyqy_develop`,库已跟上(89 张业务表),登录与 RBAC 只读接口已补齐; -> 等组员继续推送后按本文 §5 的清单联调。 +> **但截至 2026-09-12 14:20 仍有线在推**,功能性测试/联调按 §5 的触发条件启动。 --- -## 1. 当前状态(2026-09-11 实测) +## 1. 当前状态(2026-09-12 实测,**非终态**) + +> ⚠️ **这不是最终快照**:`lzl_qyqy_integration` 那条线仍在持续推送 —— 本轮我推第一次时 +> 就被它抢先(远端在两次 `fetch` 之间前进了 4 个提交)。**做功能性测试前请先按 §5.0 +> 确认各线都已停止推送**,然后重跑 §5.3 的命令并把数字更新到新的交接文档里。 | 项 | 值 | |---|---| -| 分支 | `qyqy_develop`(本地 ahead 若干,待推送) | -| 工作区 | 干净 | +| 分支 | `qyqy_develop` = `c8cdc06`(与 `origin` 同步,待推送 0,工作区干净) | | 数据库 | **89 张业务表**,`alembic current` = head = `20260911_merge_adv_risk_heads` | | `ruff check app tests tools` | 干净 | -| `mypy app` | **228 个文件 0 错** | -| `pytest tests/unit tests/contract` | **1207 passed, 2 skipped, 0 failed** | -| `pytest tests/integration` | **99 passed** | -| `tools/check_authoritative_docs.py` | **40 份文档,无编号冲突** | -| 后台进程 | 无(登录测试台已关闭,端口已释放) | +| `mypy app` | **245 个文件 0 错** | +| `pytest tests/unit tests/contract` | **1317 passed, 2 skipped, 0 failed** | +| `pytest tests/integration` | **104 passed** | +| 文档守卫 / 端点编号守卫 | 53 份文档无编号冲突;§19 **62 个端点 / 6 个号段**无重复 | +| RBAC 号段自检 | 一致(种子 40 条权限,各 `grant_*.py` 与种子逐条一致) | +| 后台进程 | 无 | > `mypy` 与测试数在本项目**必须带环境**读:架构师环境用 > `D:\conda\envs\jr_py313\python.exe`,NL 那边用本机 `.venv`。此前出现过 @@ -122,6 +126,45 @@ D:\conda\envs\jr_py313\python.exe -m pytest -q ## 5. 等组员推完之后的联调清单 +### 5.0 触发条件:先确认"没人还在推" + +**判据**:下面这段输出**为空**,才算各线都合完了。都搞完之前不要开始功能性测试 —— +否则测的是一个还会变的树,结论没有意义。 + +```powershell +git fetch --all --prune +foreach ($b in (git branch -r --format='%(refname:short)' | Where-Object { $_ -notmatch 'HEAD' })) { + $n = git rev-list --count "origin/qyqy_develop..$b" 2>$null + if ($n -gt 0) { "$b 独有 $n 个提交未合" } +} +``` + +> 2026-09-12 的实测:`NL_develop` 曾独有 16 个(已合)、`lzl_qyqy_integration` 正在持续推送。 +> 注意**别把"落后很多的老分支"当成待合分支**(如 `lzl_develop` 落后 206 个提交, +> 它的产出走的是新建的 `lzl_qyqy_integration`),但也**别反过来把活跃分支当废弃分支**—— +> 先看它的最新提交时间。 + +### 5.1 外部依赖前置(不满足会产生**假失败**,别当代码缺陷) + +| 依赖 | 检查方式 | 不满足的后果 | +|---|---|---| +| **MySQL + RBAC 种子** | `python tools/seed_test_rbac.py` → `python tools/set_user_password.py` | **不跑这两步,`tests/integration` 会有 13 个登录/RBAC 用例因 401 而红**。口令脚本**非幂等**(重复执行等于重设密码) | +| Docker Desktop(Milvus) | `docker ps` 能连上 | 检索、知识链路不可用;`tools/setup_milvus_profile_collection.py` 建不了集合 | +| Redis | 健康检查 | 登录限流、客服短期会话记忆链路不可用 | +| Neo4j | `127.0.0.1:7687` 可连 | 关系/图投影链路不可用 | +| SMTP / IMAP | `.env` 里的开关 | 场外通知与邮件识别只能走离线数据集 | + +### 5.2 已知的"看起来像缺陷但不是" + +- `tests/unit/service/test_offsite_document_recognition_adapter.py` 有 2 个用例被记为**环境相关失败** + (断言请求体里是中文原文,而 httpx 会把中文序列化成 `\uXXXX`,字节序列自然不匹配)。 + **主干的架构师环境复现不了**(2026-09-12 全量为 0 failed)。不要为了"让它绿"去动实现; + 若要修,正确做法是断言 `json.loads(body)` 后的字段值 —— 字节级断言不该用来测 JSON。 +- `mypy app` 的数字**先对版本再对代码**:SQLAlchemy 补丁版不同会差出上百个错, + 复现矩阵见 `AGENTS.md` 的"环境与命令口径"一节。 + +### 5.3 合并与验证命令 + ```powershell git fetch origin --prune diff --git a/docs/37-记忆投影链路实现说明.md b/docs/37-记忆投影链路实现说明.md new file mode 100644 index 0000000..f42a340 --- /dev/null +++ b/docs/37-记忆投影链路实现说明.md @@ -0,0 +1,319 @@ +# 记忆投影链路(Outbox → Milvus 长期记忆 / Neo4j)实现说明 + +**适用分支**:`NL_develop`(用户端线)|**日期**:2026-09-12|**状态**:已实现并通过真机验证 + +--- + +## 1. 这条链路是干什么的 + +记忆(对话里被抽取出来的长期事实)要能被后续召回,必须从 MySQL 同步到两处外部存储: + +``` +对话消息 + └─→ memory.extraction_requested(domain_event_outbox) + └─→ MemoryExtractionWorker → MemoryService.upsert() → memory_unit(MySQL,唯一真相) + └─→ profile.rebuild_requested(domain_event_outbox) + ├─→ ProfileAssemblyService.rebuild() → profile_snapshots + ├─→ ProfileGraphProjectionService → Neo4j(图) + └─→ ProfileGenerationService.generate() → memory_sync_outbox + ├─ milvus → MilvusProfileProjection → Milvus 长期记忆向量 + └─ neo4j → ProfileGraphProjectionService(幂等复投) +``` + +`memory_sync_outbox` 是"画像版本 → 外部存储"的投递队列,唯一键 +`uk_memory_sync_event (event_uuid, target_store)`:**同一 `event_uuid` 对两个目标库各写一条**。 + +--- + +## 2. ⚠️ 最容易踩的坑:枚举取值必须全仓统一(小写 + 英文) + +这条链曾**完全失效但不报错**,根因就是取值口径不统一。 + +### 唯一正确口径 + +| 字段 | 取值 | 大小写 | +|---|---|---| +| `target_store` | `milvus` / `neo4j` | **小写** | +| `operation` | `upsert` / `archive` / `delete` | **小写** | +| `status` | `pending` / `failed` / `processed` / `dead` | **小写英文** | + +(`aggregate_type` 用 `memory` / `profile` / `relationship` / `deletion`。) + +### 为什么照 `docs/00` §6.4.6 写会坏 + +`docs/00` §6.4.6 那一栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`, +与**全仓实现从未对齐**。按那份文档写会造成: + +1. `MemorySyncOutboxWorker` 按 `handlers.get(event.target_store)` 分派 handler + —— 大写值找不到 handler; +2. 领取条件是 `status.in_({"pending","failed"})` —— 中文 `待处理` 不满足。 + +⇒ **两个条件都不满足,事件任何消费者都领不到,永久滞留且不报错。** +唯一键 `(event_uuid, target_store)` 对大小写没有约束,MySQL 也不会报错,所以是**静默失效**。 + +判断依据应以**主干既有读取方**为准,不是文档: +`projection_reconciliation_service.py:20`(`{"pending","failed"}`)、 +`graph_projection_worker.py`(`pending`/`processed`/`dead`)。 + +### 现状 + +- 生产端取值集中在 `app/service/profile_generation_service.py` 的常量 + (`TARGET_MILVUS`/`TARGET_NEO4J`/`SYNC_OPERATION_UPSERT`/`SYNC_STATUS_PENDING`); +- **测试不再硬编码字面量**,并断言 `SYNC_STATUS_PENDING == "pending"`、 + `set(SYNC_TARGETS) == {"milvus","neo4j"}` + (硬编码正是当初跑偏的直接原因); +- `tests/unit/worker/test_memory_sync_outbox_worker.py` 有一条契约回归测试, + 断言大写 `MILVUS` 分派不到 handler、会进死信 —— 谁改回大写,测试立刻红; +- 历史 2 行已就地把取值改齐(只改值,主键/唯一键/payload 未动), + 正式订正工具 `tools/normalize_memory_sync_outbox.py` + (**默认 dry-run,加 `--apply` 才写库**,幂等可复跑;换环境若也有同批旧值可直接用)。 + +--- + +## 3. Milvus 长期记忆向量集合 + +| 项 | 值 | +|---|---| +| 集合名 | `user_long_term_memory_v1` | +| 主键 | `memory_uuid`(VARCHAR 64,UUID 字符串,**按 UUID 幂等 upsert**) | +| 向量 | `embedding`,`FLOAT_VECTOR` dim **1024**,索引 `AUTOINDEX` + `COSINE` | +| 其他字段 | `customer_id`(INT64) / `version`(INT64,可空) / `valid_until_ts`(INT64,可空) / `updated_at_ts`(INT64,可空) / `confidence`(DOUBLE) / `content`(VARCHAR 2048) / `memory_type`(VARCHAR 32) / `memory_key`(VARCHAR 64) / `status`(VARCHAR 16) | + +建集合:`python tools/setup_milvus_profile_collection.py` +——**幂等**,集合已存在时只做结构比对报告、不覆盖不删重建(共享 Milvus 实例里还有别的项目的集合)。 + +### `memory_sources` 契约(适配器的输入) + +```json +{ + "customer_id": 9102, + "profile_version": 2, + "memory_sources": [{ + "memory_uuid": "uuid 字符串", + "memory_key": "preference:risk_level", + "content": "正文", + "memory_type": "preference", + "confidence": 0.9, + "version": 1, + "valid_until": null + }] +} +``` + +数据源:`memory_unit` 中 `status='active'` 的行 +(`ProfileRepository.active_memories()`)。**由生产端组装**而不是消费端回查: +消费端到时记忆可能已改版本,事件里带确定快照才能与 `aggregate_version` 语义一致。 + +> 为什么用 `memory_unit` 而不是 `user_facts`:两者用途不同 —— `user_facts` 是 +> **已确认的结构化事实**(喂画像与图投影),`memory_unit` 是**长期记忆条目** +> (正是长期记忆向量集合要存的东西)。这不与"图投影只读 `user_facts`"的不变式冲突。 + +--- + +## 4. 移植自 `ZSY_develop` 的改动(含两处契约放宽) + +来源:同事 `ZSY_develop` 分支。整文件移植、骨架未改的部分: +`app/core/conversation_privacy.py`、`app/worker/memory_sync_outbox_worker.py` +(领取/`skip_locked`/指数退避/5 次转死信的可靠性骨架原样保留)、 +`app/infrastructure/milvus_profile_projection.py`。 + +对适配器做了**两处契约放宽**,都是"避免整批失败",不改变写入语义: + +1. **`customer_id` 接受 int 或数字字符串** + 原实现要求 `isinstance(customer_id, int)`,而本仓**所有**生产者写的都是 + `str(customer_id)`。不放宽则**每个事件必然失败**、重试 5 次后进死信。 + 放宽仅限**纯数字**串,非数字(如 uuid)仍拒绝。 +2. **不可投影的 `memory_key` 跳过而非整批报错** + 受控词表 `app/service/memory_taxonomy.py` 共 13 个键,其中 `constraint:*`(3 个)与 + `profile:*`(4 个)不以 `preference:`/`goal:` 开头。原实现对第一个不合规的键直接 + `raise` —— 一条 `constraint:` 记忆就会毒死该客户整批同步。现改为**跳过并留痕**。 + +**收窄而非扩容的理由**:`preference:*`/`goal:*` 是偏好与目标,适合语义召回; +`constraint:*`/`profile:*` 是结构化约束与属性,应由结构化通道查询(`user_facts` → 图), +放进向量集合只会造成召回噪声。若日后要给它们做语义召回,应扩容而非靠现在的跳过。 + +--- + +## 5. Neo4j 分支:方案 A(不引入第二套投影) + +同事分支另有一套 `neo4j_profile_projection.py`(按客户各建私有 +`Preference`/`Goal` 节点、数据源 `memory_unit`)。**未采用**,理由: + +主干 `ProfileGraphProjectionService` 已由 `profile.rebuild_requested` 驱动同一条链, +其数据源是 `user_facts`(**已确认事实**)、`MERGE` **共享 tag 节点**,且文件头写明不变式: + +> "只投影'已确认'的事实……保证图里的偏好标签与画像口径一致; +> 否则同一件事在画像和图里会有两种说法。" + +两套并存 = 同一事实在图中两种表示,正好违反这条不变式。 +因此 `memory_sync_outbox` 的 `neo4j` 分支**复用主干服务**: +图投影是 `MERGE` 幂等的,再投一次不产生重复节点/关系,只用于收敛 outbox 的投递状态。 + +--- + +## 6. 消费端装配 + +`WorkerRuntime.consume_profile_projections()`(`app/worker/runtime.py`),在 +`run_once()` 每轮执行,按条提交、单条失败不冒泡。 + +- `milvus` handler → `MilvusProfileProjection`(注入 `MilvusProfileVectorClient` + 向量化); +- `neo4j` handler → `ProfileGraphProjectionService`;其 `degraded` 会**如实抛错** + 走失败/退避,不记成已投递; +- `milvus_uri` 未配置时**显式降级**:不消费、事件留 `pending`(可观测、可重放), + 启动路径留一条 warning,绝不伪造同步成功(与 `knowledge_writer` 同一取向); +- 目标存储没有对应 handler 时判**死信**并记 `target_handler_not_configured` + —— 这正是当初大写值事件的下场(有回归测试守着)。 + +向量化复用与知识向量同一套已批准端点解析(`agent_type="memory_recall"`, +`task_type="embedding"`)。 + +### 6.1 `memory_sources` 缺失时的兜底(`_with_memory_sources`) + +`memory_sources` 是本线新增的投影入参,而**投顾线两处生产者** +(`profile_governance_service` / `risk_questionnaire_service`)发的 payload 是 +`{customer_id, profile_uuid, version, profile}`,**没有**这个键。若不处理,它们每次画像 +变更都会因 `memory_sources is invalid` 失败重试直至死信。 + +消费端因此做了兜底:**键缺失或为 `None`** 时,回退为查询该客户 `memory_unit` 中 +`status='active'` 的记忆,并**记一条 warning**(使"谁没提供"保持可见)。 + +为什么允许兜底:缺失表示生产者不知道要提供,属契约演进期的正常情况,且**语义成立** +——长期记忆是**客户级**的、不是画像版本级的,每条记忆自带 `version`,适配器按 +`memory_uuid + version` 幂等,"用的是哪一版"仍然确定。 + +**兜底不掩盖真错误**(有测试守着):键**存在但格式不对**(例如是字符串)时**不兜底**, +原样放行交给适配器失败关闭。实现上用**键存在性**判断而不是 `isinstance`——后者会把 +"缺失"与"格式错"混为一谈,那正是本模块初版实现里的一个真 bug,被测试抓出来后修正。 + +--- + +## 6.2 顺带修掉的独立缺陷:`profile_snapshots` 被重复定义 + +**发现路径**:验证 `neo4j` 分支时,库里那行 `last_error='InvalidRequestError'`。 +原以为是图库故障,追下去发现是**模型层缺陷**: + +- `app/model/profile.py` → `ProfileSnapshot` 映射 `profile_snapshots` +- `app/model/risk_questionnaire.py` → **另一个** `ProfileSnapshot` 也映射 `profile_snapshots` + +SQLAlchemy 不允许两个类映射同一张表。实测: + +| 场景 | 结果 | +|---|---| +| 单独导入 `app.main` / `app.worker.runtime` | 正常 | +| 单独导入 `profile_assembly_service` / `risk_questionnaire_service` | 正常 | +| **两者同时导入** | `InvalidRequestError: Table 'profile_snapshots' is already defined` | + +**影响**:Worker 在同一个进程里既要处理 `profile.rebuild_requested`(走 `app.model.profile`), +又要处理投顾风险问卷(走 `risk_questionnaire.py`)——所以这是**会打挂 Worker 的缺陷**, +不是理论风险。 + +**修法**:`app/model/risk_questionnaire.py` 不再重复定义,改为从 `app.model.profile` +转出(re-export),既有 4 处 `from app.model.risk_questionnaire import ProfileSnapshot` +**无需改动**。 + +> ⚠️ **订正(2026-09-12 合并主干后复核)**:本条初稿曾写"`app.model.profile` 不映射 +> `current_customer_id`、该属性无人使用",**这个说法已过时**。实际情况: +> `app/model/profile.py` **已经映射** `current_customer_id`(普通可空列 + 唯一键 +> `uk_profile_snapshot_current`,**不是**生成列 —— 模块 docstring 第 2 条写明了原因: +> 声明成生成列会让 SQLAlchemy 把它从 INSERT 排除,反而永远写不进去), +> 且**确有人使用**(集成测试按该列查当前快照)。 +> 因此本次合并顺带修了一个真实缺陷:`CustomerProfileCandidateService._write_profile_snapshot` +> 创建当前版本时**没写**该列、也没清旧值 —— 唯一键形同虚设,且一旦补写就会与旧值撞键。 +> 详见 `docs/39-主干合并对策记录.md` §4.2。 +> 两个模块的 `ProfileSnapshot` 现在**都**映射该列,这是 re-export 成立的前提。 + +--- + +## 7. 真机验证证据(2026-09-12) + +| 验证项 | 结果 | +|---|---| +| 集合创建幂等 | 首次 `created`,复跑 `exists` | +| 真实 embedding 维度 | **1024**(与集合定义一致) | +| 真实写入 + 回读 | 2 条可投影键写入成功并可回读(内容/版本正确) | +| 不可投影键 | `constraint:liquidity` **未写入**(跳过生效,未毒死整批) | +| 测试数据清理 | 已按 `customer_id=999999` 删除,集合残留 **0** 条 | +| **消费端全路径**(补验) | 见下方 7.1 | +| 单元测试 | 新增 **22** 个(适配器 10 + worker 5 + 生产端 2 + 消费端兜底 5),全过 | +| 全量回归 | `2 failed, 1312 passed, 2 skipped` —— 与基线一致,**无新增失败** | +| mypy | `Success: no issues found in 227 source files` | + +> 全量的 2 个失败是既有环境相关项(`test_offsite_document_recognition_adapter.py` +> 断言请求体里的中文原文,而 httpx 序列化成 `\uXXXX`),与本次改动无关。 + +> 真机注意:Milvus 写入后**短时间内可能查不到**(索引尚未可见), +> 验证脚本按重试处理;同理删除后立即查询可能仍返回旧行,需重查确认。 + +### 7.1 消费端全路径验证(2026-09-12 补做) + +此前"整合验证"是**直接调适配器**,跳过了 outbox 的领取→分派→状态更新。 +后补做了两轮,覆盖失败分支与成功分支: + +**失败分支**(Milvus 断开时实测): + +| id | `last_error` | 说明 | +|---|---|---| +| 5 | `ValueError` | payload 缺 `memory_sources`(兜底上线前的旧行) | +| 6 | `InvalidRequestError` | 模型重复定义缺陷(见 §6.2),**修复后此错误消失** | +| 9 | `RecoverableAgentError` | Milvus 不可达——如实失败,不伪造成功 | + +这证明全路径都工作:行被领取 ✓、按 `target_store` 分派 handler ✓、 +handler 异常被捕获 ✓、`status`/`retry_count`/`last_error`/`next_retry_at` 正确落库 ✓。 + +**成功分支**(注入替身向量客户端,不依赖真实 Milvus): + +- outbox 行 → `status=processed`、`processed_at` 已写、`last_error` 清空 ✓ +- 不可投影的 `constraint:liquidity` **被跳过**(只写 1 行而非 2 行)✓ +- 向量维度 1024 ✓;字符串客户号 `"999996"` → int ✓ +- **手机号脱敏生效**:`稳健型投资者,手机号 [手机号已隐藏] 请勿外泄` ✓ + +**兜底的实证**:历史行 `id=5`(payload 无 `memory_sources`)经兜底回退查询后 +成功投递为 `processed`,日志留 +`profile projection payload has no memory_sources (customer_id=9102); fell back to 0 active memories`。 + +> 补验时的环境限制:Docker Desktop 中途崩溃(`milvus-standalone` 内嵌 etcd panic、 +> Neo4j `Exited(1)`),因此 `id=6`(neo4j 分支)停在 `failed`/`RecoverableAgentError`。 +> **那是环境不可用,不是代码缺陷**——图库不可用时如实失败、不伪造成功正是设计口径。 + +--- + +## 8. 尚未完成 / 依赖他人 + +1. **常驻 Worker 未运行**:`memory_unit`、`user_facts`、`episodes` 目前都是 **0 行**。 + 代码链路是通的(§7.1 已用真实 outbox 行验证消费端),但没有 Worker 在跑, + 所以记忆永远不会被抽取出来。积压量(2026-09-12 实测): + `agent.run_requested` **431**、`profile.rebuild_requested` **220**、 + `agent.run_completed` **61**、`memory.extraction_requested` **58**。 + 起 `python -X utf8 -m app.worker --once`(或常驻)即开始消费。 + ⚠️ 会派发真实 agent 任务、产生模型调用费用,故未擅自启动。 +2. **投顾线两处生产者的 `memory_sources`**:其 payload 仍**没有**这个键。 + 本线已在消费端加了兜底(§6.1),因此**不再会死信**;但根治仍应由投顾线补上 + (或明确这两个来源是否也要投影长期记忆)。属架构师线,本线未改其生产者代码。 +3. **`profile_snapshots` 重复定义缺陷**(§6.2):本线已修,但它源自投顾线的模型文件, + 需让架构师知晓,以免在别处再引入同名定义。 +4. `docs/00` §6.4.6 的取值栏与实现不一致:按评审要求**未改基线文档**, + 实际口径以本文第 2 节为准。 + +--- + +## 9. 相关文件 + +**新增** +- `app/core/conversation_privacy.py`(移植) +- `app/infrastructure/milvus_profile_projection.py`(移植 + 2 处放宽) +- `app/infrastructure/milvus_profile_vector_client.py` +- `app/worker/memory_sync_outbox_worker.py`(移植) +- `tools/setup_milvus_profile_collection.py` +- `tools/normalize_memory_sync_outbox.py`(历史取值订正,默认 dry-run、幂等) +- `tests/unit/infrastructure/test_milvus_profile_projection.py` +- `tests/unit/worker/test_memory_sync_outbox_worker.py` +- `tests/unit/worker/test_runtime_profile_projection.py`(消费端兜底 5 用例) + +**修改** +- `app/service/profile_generation_service.py`(取值改小写、payload 加 `memory_sources` 与 `profile_version`) +- `app/repository/profile_repository.py`(`active_memories()`) +- `app/service/agent/bootstrap.py`(`get_milvus_profile_vector_client()`) +- `app/worker/runtime.py`(`consume_profile_projections()`、`_with_memory_sources()` 兜底、`run_once` 接线) +- `app/model/risk_questionnaire.py`(**修重复定义**:改为 re-export `app.model.profile` 的 `ProfileSnapshot`,见 §6.2) +- `tests/unit/service/test_profile_generation_service.py`(+2 用例、断言改引用常量) +- `AGENTS.md`(新增 `memory_sync_outbox` 取值口径与 Windows 中文输出两条易错点;校正测试基线/mypy 数字) diff --git a/docs/38-架构对齐-记忆与画像投影链路.md b/docs/38-架构对齐-记忆与画像投影链路.md new file mode 100644 index 0000000..601a8a1 --- /dev/null +++ b/docs/38-架构对齐-记忆与画像投影链路.md @@ -0,0 +1,203 @@ +# 架构对齐 · 记忆→画像→图 这条链(ZSY_develop vs 主干) + +> **目的**:`ZSY_develop` 分支(张胜宇,最后更新 2026-09-11 20:45)包含一整套画像投影实现, +> 而主干 `qyqy_develop` 上也有同类实现。**在合并之前必须先做一次架构对齐**,否则两套会互相覆盖。 +> **本文只做核对与建议,不改任何代码**。 +> +> **⚠️ 后续(2026-09-12)**:本文的结论**已落地实施**,实施说明与验证证据见 +> `docs/37-记忆投影链路实现说明.md`。本文保留作为**决策依据** —— 它记录了 +> "为什么选方案 A(复用主干图投影、不引入第二套)"、"为什么由生产端组装 `memory_sources`" +> 这两个决定的原始核对过程与判据,这部分推演在 `docs/37` 里没有重复。 +> +> **编号说明**:本文原为 `docs/29`;2026-09-12 让号给架构师线的 +> `docs/29-Agent组员登录接口使用说明.md`,先改号 `docs/33`,后又因主干续占 32–36 +> 再让号至 `docs/38`(本文的实际编号以文件名与 `AGENTS.md` 为准)。 +> +> **核对时间**:2026-09-11 晚 **主干**:`qyqy_develop` @ `bbf623a` **ZSY**:`cbcf7c7` + +--- + +## 一、最重要的发现:主干那条链**已经接好了**(走的是另一条 outbox) + +之前(包括架构师的评审意见里)的说法是"**组件写好了、线没接**"。**核对后这个说法不准确** —— +主干上有一条**完整且已在装配层接线**的链路,只是它走的不是 `memory_sync_outbox`: + +``` +agent.run_completed + ↓ (app/worker/runtime.py 的 handler 字典,已注册) +memory.extraction_requested + ↓ dispatch_memory_extraction +MemoryExtractionService → 提升成 user_facts + ↓ +profile.rebuild_requested + ↓ dispatch_profile_rebuild(runtime.py L175-197) +ProfileAssemblyService.rebuild() → 重建画像快照 +ProfileGraphProjectionService.project_customer() → 投影到 Neo4j +``` + +**这条链的引入者与时间**: + +``` +d7f6ef7 09-10 21:52 卿云秋月(架构师) feat: 记忆→画像→图全自动触发 +``` + +⇒ **架构师 09-10 就做完了"全自动触发"**,并且是**直接接在 `runtime.py` 的 handler 字典里**的。 +`app/worker/runtime.py` 的 handler 白名单**包含** `"profile.rebuild_requested"` —— 这就是"线接上了"的证据。 + +### 1.1 与 ZSY 那条链的关系:**两条平行的 outbox** + +| | 主干(架构师的链) | ZSY(张胜宇的链) | +|---|---|---| +触发事件 | `profile.rebuild_requested` | `memory_sync_outbox` 表里的行 | +存储 | **`domain_event_outbox`**(领域事件表) | **`memory_sync_outbox`**(记忆同步表) | +消费方式 | `WorkerRuntime` 的 handler 字典(**已注册**) | 独立的 `MemorySyncOutboxWorker` | +消费者装配 | `app/worker/__main__.py` → `WorkerRuntime(...)` | `app/worker/__main__.py` → `MemorySyncOutboxWorker({...})` | +投影目标 | **Neo4j**(`ProfileGraphProjectionService` + `relationships`) | **Milvus + Neo4j**(两个 adapter) | +画像生成 | `ProfileAssemblyService`(从 `user_facts` 组装) | `CustomerProfileCandidateService`(候选 → 复核 → 已批准快照) | + +**⇒ 两条链做的是同一件事,但走不同的 outbox、不同的画像生成方式、不同的消费装配。** + +### 1.2 现场数据(我这边实测) + +| 表 | 行数 | 说明 | +|---|---|---| +`user_facts` | **0** | 事实层为空 ⇒ 主干那条链**从没跑过**(或跑过但被清理) | +`memory_unit` | **0** | 记忆单元为空 | +`profile_snapshots` | **5** | 我的 `seed_profile_demo.py` 种的演示数据 | +`memory_sync_outbox` | **2** | **我的 `profile_generation_service` 写的**,`status='待处理'`,无人消费 | + +**⇒ 关键因果链**:`user_facts` 为空 → 主干链没产出过东西;`memory_sync_outbox` 的 2 行是**我的**生产者写的。 + +### 1.3 `graph_projection_worker.py` 的真相 + +``` +app/worker/graph_projection_worker.py → GraphProjectionWorker 类 +git grep "GraphProjectionWorker(" → 零处实例化 +``` + +**它是死代码**(或备用路径)。架构师说的"`GraphProjectionWorker` 没有实例化点"**是对的**; +但由此推论"整条链没接"**不对** —— 接的是 `runtime.py` 的 handler,不是这个类。 + +--- + +## 二、ZSY 分支带来的**真正增量**(主干确实没有的) + +逐文件核对后,ZSY 分支**独有且主干没有**的只有这些: + +| 文件 | 作用 | 主干有没有等价物 | +|---|---|---| +`app/worker/memory_sync_outbox_worker.py` | `memory_sync_outbox` 的消费者(handler 注入 + 重试 + 死信) | **无**(主干消费的是另一个 outbox) | +`app/infrastructure/milvus_profile_projection.py` | 画像投影到 **Milvus** | **无**(主干只投影到 Neo4j) | +`app/infrastructure/neo4j_profile_projection.py` | 画像投影到 Neo4j(另一个实现) | 🟡 主干有 `ProfileGraphProjectionService` | +`app/service/knowledge_publication_service.py` | 知识**发布**状态机(草稿→审核→发布) | 🟡 主干有 `knowledge_ingest_service` + 审核字段 | +`app/service/knowledge_authority.py` / `knowledge_config.py` | 知识权威来源与运行期配置 | **无** | +`app/service/customer_service_session_memory_service.py` | 客服**多轮会话记忆** | 🟡 主干有 Redis 短期记忆 + `docs/24` 说多轮已跑通 | +`app/service/customer_service_handover_*.py` | 转人工上下文与后台 | 🟡 主干有 `handover-requests` 端点 + 工单表 | +`app/service/customer_profile_candidate_service.py` | 画像**候选 → 复核 → 批准**工作流 | **无**(我的 `profile_generation_service` 是直接生成快照) | +`app/worker/customer_profile_candidate_worker.py` | 候选流程的 worker | **无** | +`app/service/agent/customer_service_agent.py` | 他自己的客服 Agent | 🔴 **主干已有**(`implementations/customer_service.py`,老师验收第 4/5/6 条靠它) | +`app/service/agent/customer_service_routing.py` | 他自己的路由 | 🔴 主干已有 `core/customer_service_rules.py` | +`app/service/knowledge_tool_service.py` | 知识工具(另一份) | 🔴 主干已有 `knowledge_tool.py` | + +--- + +## 三、四个必须对齐的架构分歧 + +### 分歧 1:用哪条 outbox 承载"记忆→画像→图"? + +- **主干**:`domain_event_outbox` + `profile.rebuild_requested`(**已在 handler 白名单里**,架构师 09-10 接的) +- **ZSY**:`memory_sync_outbox` + `MemorySyncOutboxWorker` + +**我的判断**:`memory_sync_outbox` 是 `docs/00` 基线里定义的表(**存在即有其设计意图**), +而主干那条链并没有消费它 —— 所以**两套有各自的合法位置,不该二选一,而该明确分工**: + +| 场景 | 应该走哪条 | +|---|---| +记忆内容变化 → 画像重建 → 图投影 | **主干那条**(`profile.rebuild_requested`,已接线、已验) | +画像快照 → **同步到 Milvus 向量库**(用于语义召回) | **需要一个消费者**,这正是 ZSY 的 `milvus_profile_projection` 补的 | + +⇒ **建议**:主干链保留;ZSY 的 **Milvus 投影适配器**接进主干链的尾部(或接成 `memory_sync_outbox` 的消费者, +但那样要明确"这两个 outbox 各自负责什么",否则就是两套并存)。 + +### 分歧 2:画像生成"直接快照"还是"候选→复核→批准"? + +- **我的**:`profile_generation_service.py` —— 直接生成快照(符合 `docs/00` §6.4.6 的事务口径) +- **ZSY 的**:`customer_profile_candidate_service.py` —— 先生成候选、人工/规则复核后才成为正式快照 + +**我的判断**:这是**业务裁决**,不是技术裁决。候选复核流程更严(金融场景可能更合适), +但**成本更高**(需要复核人、需要审核界面)。**建议由架构师或业务方定**,不要由合并动作决定。 + +### 分歧 3:两套客服 Agent + +- **主干**:`implementations/customer_service.py`(**已进主干**,老师验收第 4/5/6 条靠它) +- **ZSY**:`agent/customer_service_agent.py` + +**我的判断**:**这是最危险的一条**。主干那套已通过验收(产品咨询 5/5、政策 3/3、多轮 3 轮), +ZSY 那套是从旧基线写的、**落后主干 144 个提交**。**不能因为合并把它顶掉。** + +### 分歧 4:ZSY 落后主干 144 个提交 —— 他的改动里有大量"过期内容" + +ZSY 的分叉点是 `c2178a9`(09-11 09:49),而它改过 68 个与主干重叠的文件,其中包含: + +``` +app/service/tool_executor.py、app/service/agent/base.py、app/service/agent/governance.py、 +app/worker/runtime.py、app/service/public_platform_service.py、app/main.py、app/core/contracts.py +``` + +这些文件在主干上**已经过多轮修改**(风控 P3、投顾线、我的画像线)。**整体合并会把他的旧版本顶回主干。** + +--- + +## 四、建议的收口方案(分三步,风险递增) + +### 第 1 步:**只移植两个投影适配器**(低风险、有明确收益) + +| 移植什么 | 从哪来 | 落到哪 | +|---|---|---| +Milvus 画像投影适配器 | `app/infrastructure/milvus_profile_projection.py` | 同一路径(主干没有同名文件) | +(可选)Neo4j 投影改进 | `app/infrastructure/neo4j_profile_projection.py` | 与主干 `ProfileGraphProjectionService` 比对后再定 | +测试 | `tests/unit/infrastructure/test_milvus_profile_projection.py` | 同路径 | + +**为什么安全**:这两个文件在主干**不存在** ⇒ 零冲突;它们是纯适配器(无业务改动)。 + +### 第 2 步:**`memory_sync_outbox` 的消费者**(中风险,需要先定分工) + +两种做法,**必须选一种**: + +| 做法 | 说明 | +|---|---| +**2a** | 把 ZSY 的 `MemorySyncOutboxWorker` 接进 `app/worker/__main__.py`(他那套本来就是这么设计的)—— 直接能用,但要明确它与主干链的分工 | +**2b** | 不引入新 worker,而是把主干链的尾部补上"同时写 Milvus"(改 `dispatch_profile_rebuild`)—— 单一链路更简单,但要改主干的已验代码 | + +我倾向 **2a**,理由:`memory_sync_outbox` 是基线表,让它有自己的消费者更符合原本设计; +且不用改主干已验的那条链。 + +### 第 3 步:**其余部分对齐后再谈**(需要架构师/业务裁决) + +- 画像**候选复核流程**要不要采纳(分歧 2) +- ZSY 的**客服 Agent / 知识工具 / 路由**是否全部放弃(分歧 3)—— 我建议放弃 +- ZSY 的**知识发布状态机**与主干的知识入库/审核是否合并 + +--- + +## 五、给架构师/张胜宇的四个问题 + +1. **`memory_sync_outbox` 与 `profile.rebuild_requested` 的分工是什么?** + (两个 outbox 都在用,但谁负责什么没写下来。我在 `AGENTS.md` 里已经记了"两个 outbox 不能混", + 但现在**两条链各用一个**,需要明确边界。) +2. **画像生成要"直接快照"还是"候选复核"?** 这是业务裁决。 +3. **ZSY 的客服 Agent 要不要保留?** 主干那套已通过老师验收第 4/5/6 条,我建议以主干为准。 +4. **ZSY 分支怎么收尾?** 它落后主干 144 个提交 —— 建议**不整体合并**,改为"按需移植 2-3 个文件" + (投影适配器 + outbox worker + 对应测试),其余明确标记为"已被主干实现取代"。 + +--- + +## 六、我这边确认不做的事 + +- **不改任何代码**(本文只是对齐) +- **不整体合并 ZSY 分支**(会让 144 个提交的旧改动回退主干) +- **不动主线那条已验链路**(`profile.rebuild_requested` 全自动触发) + +--- + +*核对依据:`git ls-tree` 逐文件比对 + `git grep` 引用追踪 + 现库 `information_schema` 与行数实测。* diff --git a/docs/39-主干合并对策记录.md b/docs/39-主干合并对策记录.md new file mode 100644 index 0000000..0f1ce7e --- /dev/null +++ b/docs/39-主干合并对策记录.md @@ -0,0 +1,191 @@ +# 合并对策记录:NL_develop ← qyqy_develop(PR #7 后) + +**日期**:2026-09-12|**合并对象**:`origin/qyqy_develop` @ `4d8edb4`|**共同祖先**:`bbf623a` + +--- + +## 0. 为什么需要这份记录 + +主干这次带来的 **54 个提交**里包含一项关键事实:**架构师已把 ZSY 的整条投影实现合进主干(PR #7)**, +而本线此前的几个提交**正好是移植并修正同一套代码**。因此这次合并的冲突不是"文本冲突", +而是**同一功能的两份实现并存**——取舍错了会把已经修好的缺陷又带回来。 + +冲突文件 9 个、共同祖先到两边的改动面:本线 25 个文件 / 主干 118 个文件。 + +--- + +## 1. 逐文件取舍 + +| 文件 | 取舍 | 理由 | +|---|---|---| +| `app/infrastructure/milvus_profile_projection.py` | **取本线** | 主干是 ZSY 原版,含两处必炸点(见 §2);本线版是"原版 + 两处放宽 + 脱敏 + 日志" | +| `app/worker/memory_sync_outbox_worker.py` | **取本线** | 代码逐行一致,仅注释/docstring 详略不同 | +| `app/core/conversation_privacy.py` | **取本线** | 语义完全一致(纯格式差异:docstring 详略、括号换行、空行) | +| `app/model/risk_questionnaire.py` | **取本线** | **两边独立做了完全相同的修复**(都改成 re-export `app.model.profile`),代码部分一字不差,仅说明文字中/英不同 | +| `tests/unit/infrastructure/test_milvus_profile_projection.py` | **取本线** | 本线是他那份的**超集**(他 4 例 / 本线 10 例,包含他全部 4 例) | +| `tests/unit/worker/test_memory_sync_outbox_worker.py` | **取本线** | 同上(他 4 例 / 本线 5 例,包含他全部 4 例) | +| `app/service/agent/implementations/customer_service.py` | **两边合并** | 见 §3:import 取并集;公司名取主干、热线取本线 | +| `app/worker/runtime.py` | **两边合并** | `__init__` 参数两边各加一个,**都要** | +| `AGENTS.md` | **两边合并** | 表数/Agent 清单取主干、`-X utf8` 与两条 outbox 易错点取本线、测试基线按合并后实测重算 | + +--- + +## 2. 主干上仍然存在的两处必炸点(本线修正的价值所在) + +主干 `milvus_profile_projection.py` 是 ZSY 原版,**未修**: + +| 代码 | 后果 | +|---|---| +| `if not isinstance(customer_id, int) or customer_id <= 0` | 本仓**所有**生产者都写 `str(customer_id)` ⇒ 每个事件必然 `ValueError`、重试 5 次进死信 | +| `raise ValueError("memory key is not projectable")` | 受控词表 13 个键有 7 个(`constraint:*`/`profile:*`)不满足前缀 ⇒ 一条 `constraint:` 记忆**毒死该客户整批** | + +本线版改为:接受纯数字字符串、不可投影键**跳过并留痕**。 + +> 另外主干 `profile_generation_service.py` 的取值**仍是大写 `MILVUS`/`NEO4J` + 中文 `待处理`**, +> 而消费端只领 `{"pending","failed"}`、按小写键分派 ⇒ **主干这条链同样是静默失效的**。 +> 本线已改为小写并加契约回归测试守着。 + +--- + +## 3. 需要人判断的三处取舍(本线已按"以架构师为主线 + 方案 A"决定) + +### 3.1 `COMPANY` 取主干的「奶龙基金责任有限公司」 + +本线旧值是 `"南方科技"`(早期占位)。**"奶龙"是本项目的实际品牌名**,出现在主干多处 +(`customer_service_rules.py` 的对外话术、风控配置、静态页等),故取主干值。 + +### 3.2 `HOTLINE` / `SERVICE_HOURS` 取本线的修复(**不取主干**) + +主干仍是占位符 `"400-XXX-XXXX"`(本线修前的状态)。 + +本线的修复是把它们改为引用 `customer_service_rules.CONTACT_PHONE`(真号码 `15936583816`) +与 `CONTACT_HOURS`。**这是本线 A1 缺陷的修复**:常量各写一份必然漂移, +后果是**同一个客服给客户两个不同的电话号码**(安全路由出口给真号、兜底出口给假号), +客户按假号码永远打不通。有单测守着(`HOTLINE is CONTACT_PHONE` 的同一性断言)。 + +### 3.3 消费端只保留一套 —— 删掉 `app/worker/__main__.py` 里的重复接线 + +**这是本次合并最重要的一处**。合并后曾出现**两套消费者读同一个 `memory_sync_outbox`**: + +| 位置 | milvus handler | neo4j handler | +|---|---|---| +| `__main__.py`(主干/PR #7) | `MilvusProfileProjection` | **ZSY 的 `Neo4jProfileProjection`**(按客户各建私有节点) | +| `runtime.py`(本线) | `MilvusProfileProjection` + 兜底 | **主干的 `ProfileGraphProjectionService`**(共享 tag 节点、只投影已确认事实) | + +两套都领同一个队列、**neo4j 的 handler 却不同** ⇒ 同一事件被谁领到结果不定, +等于"同一事实在图里会有两种说法"——正是**方案 A 要避免的状态**。 + +**取舍**:只保留 `runtime.consume_profile_projections()` 那一套,删掉 `__main__.py` 的接线。理由: + +1. 它带 `memory_sources` 缺失兜底(投顾线两处生产者不发该字段,否则每次画像变更都死信); +2. 它的 `neo4j` 复用主干 `ProfileGraphProjectionService` —— 落实**方案 A**(你已确认); +3. 装配入口的职责仍留在 `__main__.py`(注入 `relationships` 与 `projection_cleaner`); + Milvus 客户端由 `bootstrap.get_milvus_profile_vector_client()` 惰性构造、缺配置时显式降级 + —— 与 `runtime.py` 自己写明的"由组装层注入、不在此兜底"口径一致。 + +**随之失去生产引用的文件**:`app/infrastructure/neo4j_profile_projection.py`(ZSY 那套)。 + +必须说清楚**这不是"它本来就是死代码"**,时间线如下: + +| 提交 | 事件 | +|---|---| +| `f167390`(ZSY) | 新建该适配器 | +| `5e848f5`(ZSY) | `feat: wire neo4j projection into worker` —— 在 `__main__.py` 装配,**此后一直是活的** | +| `4d8edb4`(主干) | PR #7 合并后接线仍在(`memory_sync_handlers["neo4j"] = Neo4jProfileProjection(neo4j_driver).upsert`),**仍是活的** | +| **`57677f6`(本次合并)** | **摘掉** `__main__.py 的那段装配 ⇒ 本文件失去生产引用 | + +`__main__.py` 在本次合并中**并没有冲突**(git 自动取的是带接线的主干版本), +是本线在解决完冲突后**主动手工删除**那段接线的。 + +**但更根本的原因是:它与方案 A 天然互斥。** 该文件本身就是"第二套图投影", +只要落实方案 A,它就必然失去引用 —— 换哪种做法都一样 +("删 `__main__.py` 接线、保留 runtime 那套"与"保留 `__main__.py` 骨架、把它的 neo4j +handler 换成主干服务"两种做法,结果相同)。所以这不是方案 A 的副作用, +而是"两套图投影本来就只能活一套"。 + +**本线的处理**:**保留文件**(实现本身完整:`MERGE` 幂等、按 `profile_version` 判重、 +写入前脱敏),并在其文件头加 `.. warning::` 写明"当前未被生产装配、为什么、 +以及启用前必须先决定图的节点模型以谁为准";其单测继续跑,但保护的是模块自身契约, +**不代表它已被装配**。 + +**待架构师决定**: +1. 删除该文件 + 其单测(它承载的是被否决的方案,留着可能被误读为"可用实现"); +2. 保留为参考实现(**本线当前取此**); +3. 或反向 —— 若认为该用它而非主干服务,则"方案 A"需要重新讨论(这已超出本线能定的范围)。 + +--- + +## 4. 合并过程中一并修掉的 3 个继承缺陷(主干上同样存在) + +这三处都是**主干带进来的、集成测试能证明的缺陷**(架构师说明过 `tests/integration` +在 PR #7 之后**未整套复跑**,所以没被发现): + +### 4.1 `tools/seed_test_rbac.py` 少了 `review_t` 账号 + +`tests/integration/test_rbac_read_mysql.py` 断言 `/api/v1/admin/users/9004/roles` 返回 +200 + `username == "review_t"` + `roles == []`("账号存在但无权限"应返回空权限集而非 404), +`test_auth_login_mysql.py` 的 `PLACEHOLDER_ACCOUNTS` 也包含它 —— 但**种子从未创建 9004**。 + +(后者因"账号不存在时登录同样返回 401"而恰好蒙过,前者则一直红。) + +**修**:`USERS` 加 `(9004, "T-REVIEW", "review_t", "employee")`,**不绑角色**(正是它要覆盖的场景)。 + +同时把用户↔角色绑定从 `zip(user_ids, role_ids, strict=True)` 改为**显式配对表 `USER_ROLES`**: +原写法隐含"USERS 与 ROLES 一一对应",一加不绑角色的账号就 `ValueError`, +**整个种子跑不完**(而 `commit()` 在最后,外部表现是"什么都没发生")。 + +### 4.2 `customer_profile_candidate_service._write_profile_snapshot` 漏写 `current_customer_id` + +`profile_snapshots` 的 `current_customer_id` **不是生成列**,而是普通可空列 + 唯一键 +`uk_profile_snapshot_current`(`app/model/profile.py` 的模块 docstring 第 2 条明确说明)。 +该处创建当前版本时只写了 `is_current=True`,**没写 `current_customer_id`**: + +- 唯一键形同虚设(多个 NULL 不冲突)⇒「每个客户最多一条当前快照」这条不变式失效; +- 旧当前版本也**没清空**该列,一旦有人补上写入就会撞唯一键。 + +**修**:旧版本 `current_customer_id = None`、新版本显式写 `current_customer_id=customer_id` +(与 `ProfileGenerationService._SQL_CLEAR_CURRENT` 的做法一致)。 + +### 4.3 `tests/integration` 的 13 个"假失败" + +合并后首次整套跑 `tests` 时,13 个登录/RBAC 用例因 **401「用户名或密码不正确」** 而红 —— +**不是代码问题**,是集成测试的前置没做(测试账号不存在)。 +跑 `tools/seed_test_rbac.py` + `tools/set_user_password.py` 后全部转绿。 + +已在 `AGENTS.md` 记录该前置,避免下一个人把它误判成代码缺陷。 + +--- + +## 5. 验证证据(合并后实测) + +| 项 | 结果 | +|---|---| +| 全量 `pytest tests` | `2 failed, 1396 passed, 2 skipped` | +| 其中 `pytest tests/integration` | `102 passed, 1 skipped`(修 §4.1/§4.2 后从 15 failed 归零) | +| `mypy app` | `Success: no issues found in 245 source files` | +| `tools/audit_schema.py` | `89 business tables, no missing or unexpected tables` | +| `tools/check_authoritative_docs.py` | `checked 52 documents, no number collision` | +| `tools/check_rbac_seed_consistency.py` | 通过(种子 40 条权限、2 个 grant 脚本逐条一致) | + +那 2 个失败是**既有环境项**(`test_offsite_document_recognition_adapter.py`: +断言请求体里的中文原文,而 httpx 序列化成 `\uXXXX`),与本次合并无关。 + +--- + +## 6. 遗留 / 待架构师确认 + +1. **`neo4j_profile_projection.py` 失去生产引用**(§3.3)—— 本质是它与方案 A 互斥, + 不是它本来就没被装配。本线已在文件头加警告说明并保留文件, + 待架构师在"删除 / 保留为参考 / 反过来改用它"三者间决定。 +2. **投顾线两处生产者的 payload 仍缺 `memory_sources`** —— 本线的消费端已有兜底(不再死信), + 但根治应由投顾线补上或明确"这两个来源是否也要投影长期记忆"。 +3. **`docs/00` §6.4.6 的取值栏与实现不一致** —— 按既定裁定未改基线文档, + 实际口径记在 `docs/37`。 +4. **文档编号**:主干已占用 29–36,本线两份文档让号至 `docs/37`(记忆投影链路实现说明)、 + `docs/38`(架构对齐,决策依据)。 +5. **常驻 Worker 仍未运行** —— `memory_unit` / `user_facts` 仍为 0 行, + 积压事件未消费(`agent.run_requested` 431 等)。⚠️ 启动会派发真实 agent 任务、产生模型调用费用。 + +--- + +**执行人**:NL(用户端)|**合并前备份分支**:`NL-backup-before-trunkmerge-20260912` @ `57f56bb` diff --git a/tests/unit/core/test_customer_service_rules.py b/tests/unit/core/test_customer_service_rules.py index f4c29b7..7fe746f 100644 --- a/tests/unit/core/test_customer_service_rules.py +++ b/tests/unit/core/test_customer_service_rules.py @@ -13,6 +13,7 @@ from app.core import customer_service_rules as rules from app.core.customer_service_rules import ( COMPLIANCE_PRIORITY, COMPLIANCE_REPLY, + CONTACT_HOURS, CONTACT_PHONE, P0_REPLY, P1_REPLY, @@ -23,6 +24,25 @@ from app.core.customer_service_rules import ( route_message, ) + +def test_agent_fallback_uses_the_single_source_hotline() -> None: + """客服 Agent 的兜底话术必须与安全路由用**同一个**电话号码与工作时间。 + + 这条是回归守卫:这两处曾一度不一致 —— 安全路由出口给真号码 `15936583816`, + 而 Agent 的兜底出口给占位符 `400-XXX-XXXX`。后果是同一个客服对不同问题 + 给出**两个不同的客服电话**,客户按占位符那个永远打不通。 + + 断言方式刻意用"对象同一性"(`is`)而不是"值相等":值相等也能通过 + "两边各写一份恰好相同的字符串",而那正是漂移的开始 —— 必须是真的同一个来源。 + """ + from app.service.agent.implementations import customer_service as agent + + assert agent.HOTLINE is CONTACT_PHONE + assert agent.SERVICE_HOURS is CONTACT_HOURS + # 话术里不能再出现任何占位符形态 + assert "XXX" not in agent.FALLBACK_TEMPLATE + assert CONTACT_PHONE in agent.FALLBACK_TEMPLATE + #: 治理层 `review_output()` 的拦截口径(`app/service/agent/governance.py`): #: 输出只要命中任一 `agent_negative_word` 规则(客服 11 条,含 NEG-007「安全」) #: 或这 5 个硬编码字面,整条回复就会被替换成兜底话术。 diff --git a/tests/unit/infrastructure/test_milvus_profile_projection.py b/tests/unit/infrastructure/test_milvus_profile_projection.py index 54d651f..836e46e 100644 --- a/tests/unit/infrastructure/test_milvus_profile_projection.py +++ b/tests/unit/infrastructure/test_milvus_profile_projection.py @@ -1,3 +1,11 @@ +"""`MilvusProfileProjection` 的定向测试。 + +前 4 个用例移植自同事 `ZSY_develop` 的 +`tests/unit/infrastructure/test_milvus_profile_projection.py`; +后 4 个覆盖本仓对其做的**两处契约放宽**(`customer_id` 兼容字符串、 +不可投影键跳过而非整批失败)与脱敏,这些是移植时必须钉住的差异点。 +""" + from uuid import uuid4 import pytest @@ -36,6 +44,14 @@ def payload() -> dict[str, object]: } +def _source(data: dict[str, object]) -> dict[str, object]: + sources = data["memory_sources"] + assert isinstance(sources, list) + source = sources[0] + assert isinstance(source, dict) + return source + + @pytest.mark.asyncio async def test_upsert_writes_schema_fields_and_vector() -> None: client = FakeMilvus() @@ -53,9 +69,7 @@ async def test_upsert_writes_schema_fields_and_vector() -> None: @pytest.mark.asyncio async def test_lower_memory_version_is_not_overwritten() -> None: data = payload() - source = data["memory_sources"][0] - assert isinstance(source, dict) - memory_uuid = source["memory_uuid"] + memory_uuid = _source(data)["memory_uuid"] client = FakeMilvus(existing=[{ "memory_uuid": memory_uuid, "customer_id": 7, "version": 3, }]) @@ -76,14 +90,105 @@ async def test_embedding_dimension_is_enforced() -> None: @pytest.mark.asyncio async def test_non_uuid_memory_id_is_rejected() -> None: data = payload() - source = data["memory_sources"][0] - assert isinstance(source, dict) - source["memory_uuid"] = "unsafe\" or true" + _source(data)["memory_uuid"] = "unsafe\" or true" with pytest.raises(ValueError, match="memory_uuid"): await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data) +# --- 本仓放宽的契约(移植差异点) ------------------------------------------- + + +@pytest.mark.asyncio +async def test_string_customer_id_is_accepted() -> None: + """本仓三处生产者写的都是 `str(customer_id)`;不接受字符串则事件必然全部失败。""" + data = payload() + data["customer_id"] = "9102" + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert client.upserts[0]["data"][0]["customer_id"] == 9102 + + +@pytest.mark.asyncio +async def test_non_numeric_customer_id_is_rejected() -> None: + """放宽不等于不校验:uuid 之类的非数字串必须拒绝,不能当成客户号写进向量库。""" + data = payload() + data["customer_id"] = "957c0552-7fa2-4f2a-924d-d2d2e133b245" + + with pytest.raises(ValueError, match="customer_id"): + await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data) + + +@pytest.mark.asyncio +async def test_version_key_fallback_is_supported() -> None: + """本仓生产端 payload 用 `version`;适配器契约用 `profile_version`。两个都要认。""" + data = payload() + del data["profile_version"] + data["version"] = 5 + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert len(client.upserts) == 1 + + +@pytest.mark.asyncio +async def test_non_projectable_memory_key_is_skipped_not_fatal() -> None: + """`constraint:*` / `profile:*` 不在可投影前缀内。 + + 关键:一条不可投影的键**不得**毒死同一客户其余可投影记忆。 + """ + data = payload() + good = _source(data) + data["memory_sources"] = [ + { + "memory_uuid": str(uuid4()), + "memory_key": "constraint:liquidity", + "content": "半年内需要流动性", + "memory_type": "constraint", + "confidence": 0.8, + "version": 1, + "valid_until": None, + }, + good, + ] + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert len(client.upserts) == 1 + rows = client.upserts[0]["data"] + assert [row["memory_key"] for row in rows] == ["preference:risk_level"] + + +@pytest.mark.asyncio +async def test_all_keys_non_projectable_writes_nothing() -> None: + """全部不可投影时不写 Milvus,但也不报错(不是失败,是无需投影)。""" + data = payload() + _source(data)["memory_key"] = "profile:occupation" + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert client.upserts == [] + + +@pytest.mark.asyncio +async def test_sensitive_credentials_are_sanitized_before_write() -> None: + """落外部存储前必须脱敏:手机号不得原样写进向量库。""" + data = payload() + _source(data)["content"] = "我的手机号是 15936583816,稳健型" + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + content = client.upserts[0]["data"][0]["content"] + assert "15936583816" not in content + assert "[手机号已隐藏]" in content + + def _vector(size: int = 1024) -> list[float]: return [0.0] * size diff --git a/tests/unit/service/test_memory_extraction_service.py b/tests/unit/service/test_memory_extraction_service.py index c06a57a..ac93368 100644 --- a/tests/unit/service/test_memory_extraction_service.py +++ b/tests/unit/service/test_memory_extraction_service.py @@ -161,6 +161,39 @@ async def test_declared_empty_extraction_returns_none() -> None: await extract_with(json.dumps({**payload, "confidence": 0.5})) +@pytest.mark.asyncio +async def test_single_element_array_is_normalized() -> None: + """模型把结果包在**单元素数组**里时归一化为对象,而不是当成"无效 JSON"。 + + 背景(2026-09-12 实测):契约是对象,但模型在 episode 抽取路径会返回 + `[{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}]`。 + 该形状此前直接进 pydantic 校验,报 + `Input should be a valid dictionary ... input_type=list`,被记成"输出不是有效 JSON", + 于是反复重试到 episode 判失败 —— 而它其实是**合法的空结果**。 + """ + empty = {"memory_key": None, "value": None, "memory_type": None, "confidence": 0} + # 数组包着的空结果 → 归一化后按"无持久事实"处理,返回 None(不是失败)。 + assert await extract_with(json.dumps([empty])) is None + # 数组包着的**有事实**结果 → 同样归一化,照常解析出来。 + extracted = await extract_with(json.dumps([VALID_PAYLOAD])) + assert extracted is not None + assert extracted.memory_key == VALID_PAYLOAD["memory_key"] + + +@pytest.mark.asyncio +async def test_multi_element_or_non_dict_array_still_fails_closed() -> None: + """只归一化"恰好一个对象的数组";其余形状**不猜**,仍失败关闭。 + + 多元素时无法判断哪一个是答案,猜错会把错误记忆写进库 —— 比失败更糟。 + """ + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([VALID_PAYLOAD, VALID_PAYLOAD])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps(["not-a-dict"])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([])) + + @pytest.mark.asyncio async def test_missing_endpoint_fails_closed_without_calling_model() -> None: service, model = build_service(json.dumps(VALID_PAYLOAD), resolver=StubResolver([])) diff --git a/tests/unit/service/test_profile_generation_service.py b/tests/unit/service/test_profile_generation_service.py index 8c04a63..65fab79 100644 --- a/tests/unit/service/test_profile_generation_service.py +++ b/tests/unit/service/test_profile_generation_service.py @@ -21,6 +21,8 @@ from app.core.errors import ValidationAgentError from app.core.profile_projection import project_profile from app.service.profile_generation_service import ( REQUIRED_SNAPSHOT_FIELDS, + SYNC_OPERATION_UPSERT, + SYNC_STATUS_PENDING, SYNC_TARGETS, ProfileGenerationService, build_snapshot, @@ -45,11 +47,13 @@ class FakeRepository: assessment: dict[str, Any] | None = None, current: dict[str, Any] | None = None, next_version: int = 2, + memories: list[dict[str, Any]] | None = None, ) -> None: self._profile = profile self._assessment = assessment self._current = current self._next_version = next_version + self._memories = memories or [] self.executed: list[str] = [] self.inserted_snapshot: dict[str, Any] | None = None self.sync_events: list[dict[str, Any]] = [] @@ -66,6 +70,10 @@ class FakeRepository: self.executed.append("current_snapshot") return self._current + async def active_memories(self, _cid: int) -> list[dict[str, Any]]: + self.executed.append("active_memories") + return self._memories + async def next_version(self, _cid: int) -> int: self.executed.append("next_version") return self._next_version @@ -179,7 +187,13 @@ async def test_first_generation_writes_two_events_and_clears_old_current( # `aggregate_type='profile'` 由仓储层固定写入(不在 kwargs 里),此处断言服务传入的实体标识 assert len({e["aggregate_uuid"] for e in repo.sync_events}) == 1 assert all(e["aggregate_uuid"] == result.profile_uuid for e in repo.sync_events) - assert all(e["operation"] == "UPSERT" for e in repo.sync_events) + # 断言取值本身,且**与消费端领取条件对齐**:outbox worker 只领 `pending`/`failed`, + # 写成别的取值事件就永远没人消费。这里不再硬编码字面量(硬编码正是当初跑偏的原因)。 + assert all(e["operation"] == SYNC_OPERATION_UPSERT for e in repo.sync_events) + assert all(e["status"] == SYNC_STATUS_PENDING for e in repo.sync_events) + assert SYNC_OPERATION_UPSERT == "upsert" + assert SYNC_STATUS_PENDING == "pending" + assert set(SYNC_TARGETS) == {"milvus", "neo4j"} # 顺序:清旧当前标记必须在插新版本之前(否则撞唯一键) assert repo.executed.index("clear_current") < repo.executed.index("insert_snapshot") # 新版本标为当前 @@ -187,6 +201,58 @@ async def test_first_generation_writes_two_events_and_clears_old_current( assert repo.inserted_snapshot["version"] == 2 +@pytest.mark.asyncio +async def test_payload_carries_memory_sources_for_projection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """payload 必须带 `memory_sources` 与 `profile_version`。 + + 这是 Milvus 长期记忆投影的输入契约:适配器要 `memory_sources` 才知道往向量库 + 写什么,要 `profile_version`(或 `version`)才认得出这一批属于哪个画像版本。 + 缺了它,事件能被领取、却什么也投影不出来——属于"静默空转",必须由测试挡住。 + """ + memories = [{ + "memory_uuid": "11111111-2222-3333-4444-555555555555", + "memory_key": "preference:risk_level", + "content": "稳健型", + "memory_type": "preference", + "confidence": 0.9, + "version": 1, + "valid_until": None, + }] + repo = FakeRepository( + profile=profile_row(), assessment=assessment_row(), current=None, memories=memories + ) + + result = await service(monkeypatch, repo).generate(9102, now=NOW) + + for sync_event in repo.sync_events: + payload = sync_event["payload"] + assert payload["profile_version"] == result.version + assert payload["version"] == result.version + sources = payload["memory_sources"] + assert len(sources) == 1 + assert sources[0]["memory_uuid"] == memories[0]["memory_uuid"] + assert sources[0]["memory_key"] == "preference:risk_level" + + +@pytest.mark.asyncio +async def test_payload_memory_sources_is_empty_without_active_memories( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """没有有效记忆时给**空列表**(而不是省略该键)。 + + 省略键会让适配器的 `memory_sources is invalid` 报错、事件反复重试直至死信; + 空列表是"确实没有要投影的记忆",语义不同。这里把这个区别钉住。 + """ + repo = FakeRepository(profile=profile_row(), assessment=assessment_row(), current=None) + + await service(monkeypatch, repo).generate(9102, now=NOW) + + for sync_event in repo.sync_events: + assert sync_event["payload"]["memory_sources"] == [] + + @pytest.mark.asyncio async def test_each_version_gets_a_fresh_profile_uuid(monkeypatch: pytest.MonkeyPatch) -> None: """`profile_uuid` 有唯一键,**每个版本必须用新 uuid**(实测撞过 Duplicate entry)。""" diff --git a/tests/unit/service/test_run_query_service.py b/tests/unit/service/test_run_query_service.py index ad3ad6d..5c0ff28 100644 --- a/tests/unit/service/test_run_query_service.py +++ b/tests/unit/service/test_run_query_service.py @@ -123,6 +123,67 @@ async def test_failed_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch assert snapshot.error_code == "AGENT_INTERNAL_ERROR" +# --- `docs/05` §6.3 要求的 `transfer_required` / `transfer_reason` 出参 ----------------- + + +async def test_result_exposes_transfer_marker_when_transferred( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """兜底/转人工分支必须把标记**出参**给客户端。 + + 为什么这条重要:前端判断"这轮要不要转人工"此前只能靠**猜正文里有没有兜底话术开头** + (`docs/24` 自称权宜之计)。`docs/05` §6.3 一直规定 `result` 里有这两个字段, + 但此前既没落库也没出参 —— 这个用例把"契约已兑现"钉住。 + """ + message = FakeMessage( + content="抱歉,这个问题我暂时无法给出准确答复…", + tool_calls={"calls": [], "transfer_required": True, + "transfer_reason": "置信度不足:score=0.571 gap=0.004"}, + ) + patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message)) + + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None + assert snapshot.result["transfer_required"] is True + assert snapshot.result["transfer_reason"] == "置信度不足:score=0.571 gap=0.004" + + +async def test_result_transfer_marker_defaults_to_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """正常回答:标记为 False、原因为 None(不能因为缺键就返回 None 让客户端混淆)。""" + message = FakeMessage(content="交易日 15:00 前提交…", + tool_calls={"calls": [], "transfer_required": False, + "transfer_reason": None}) + patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message)) + + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None + assert snapshot.result["transfer_required"] is False + assert snapshot.result["transfer_reason"] is None + + +async def test_result_tolerates_legacy_tool_calls_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """**兼容历史行**:本字段上线前落库的 `tool_calls` 里没有这两个键。 + + 那种行的 `tool_calls` 可能就是裸列表,甚至为 None。读取时必须按 False/None 处理, + **不得抛异常、也不得凭正文内容去猜**——猜错方向会让"不需要转人工"的答复被标成转人工。 + """ + for legacy in ({"calls": []}, [], None): + message = FakeMessage(content="稳健型", tool_calls=legacy) + patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message)) + + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None, legacy + assert snapshot.result["transfer_required"] is False, legacy + assert snapshot.result["transfer_reason"] is None, legacy + + def terminal_snapshot(status: str = "succeeded") -> RunSnapshot: return RunSnapshot( run_id="run-1", diff --git a/tests/unit/worker/test_memory_sync_outbox_worker.py b/tests/unit/worker/test_memory_sync_outbox_worker.py index 8f25a34..40c18fa 100644 --- a/tests/unit/worker/test_memory_sync_outbox_worker.py +++ b/tests/unit/worker/test_memory_sync_outbox_worker.py @@ -1,3 +1,10 @@ +"""`MemorySyncOutboxWorker` 的定向测试。 + +4 个用例移植自同事 `ZSY_develop` 的 +`tests/unit/worker/test_memory_sync_outbox_worker.py`;最后一个用例是本仓新增的 +**契约回归测试**——它是这次整条链故障的根因所在,必须有人守着。 +""" + from datetime import datetime import pytest @@ -31,7 +38,7 @@ class FakeSession: def event(*, target: str = "neo4j", retry_count: int = 0) -> MemorySyncOutbox: return MemorySyncOutbox( - id=1, event_uuid="event-1", aggregate_type="profile_snapshot", + id=1, event_uuid="event-1", aggregate_type="profile", aggregate_uuid="profile-1", aggregate_version=1, target_store=target, operation="upsert", payload={"customer_id": 7}, status="pending", retry_count=retry_count, next_retry_at=None, last_error=None, @@ -98,3 +105,33 @@ async def test_missing_handler_enters_dead_state_without_external_call() -> None assert await worker.run_once() is True assert item.status == "dead" assert item.last_error == "target_handler_not_configured" + + +# --- 本仓新增:契约回归 ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_no_handler_is_registered_for_uppercase_target_store() -> None: + """契约回归:`target_store` 只能是小写 `milvus`/`neo4j`。 + + 背景(这条链真实故障的根因):`profile_generation_service` 曾照 `docs/00` §6.4.6 + 写作大写 `MILVUS`/`NEO4J` + 中文状态 `待处理`,而本 worker 按 `target_store` 的**值** + 分派 handler、且只领 `{"pending","failed"}`。结果该事件两个条件都不满足, + **任何消费者都领不到,永久滞留且不报错**(唯一键 `(event_uuid, target_store)` + 对大小写没有约束,所以静默)。 + + 本用例把"大写分派不到"这一事实钉住:将来谁把取值改回大写,这里会立刻红。 + """ + item = event(target="MILVUS", retry_count=0) + session = FakeSession(item) + called: list[object] = [] + + async def handler(payload: dict[str, object]) -> None: + called.append(payload) + + worker = MemorySyncOutboxWorker({"milvus": handler}, session_factory=lambda: session) + assert await worker.run_once() is True + + assert called == [] # 大写键分派不到 milvus handler + assert item.status == "dead" + assert item.last_error == "target_handler_not_configured" diff --git a/tests/unit/worker/test_runtime_profile_projection.py b/tests/unit/worker/test_runtime_profile_projection.py new file mode 100644 index 0000000..df28268 --- /dev/null +++ b/tests/unit/worker/test_runtime_profile_projection.py @@ -0,0 +1,130 @@ +"""画像投影入参的契约与兜底:`WorkerRuntime._with_memory_sources`。 + +背景(本测试要拦住的真实故障):`memory_sources` 是本仓新增的投影入参,而投顾线两处 +生产者(`profile_governance_service` / `risk_questionnaire_service`)发的 payload 是 +`{customer_id, profile_uuid, version, profile}`,**没有**这个键。若不兜底,它们每次画像 +变更都会因 `memory_sources is invalid` 失败重试直至死信(库里已留 `ValueError` 行痕)。 + +同时要钉住"兜底不得掩盖格式错":已提供但格式不合法时,仍由适配器失败关闭。 +""" + +from typing import Any + +import pytest + +from app.repository.profile_repository import ProfileRepository +from app.worker.runtime import WorkerRuntime + + +class FakeSession: + def __init__(self) -> None: + self.closed = False + + async def __aenter__(self) -> "FakeSession": + return self + + async def __aexit__(self, *args: object) -> None: + self.closed = True + + +def memory_row() -> dict[str, Any]: + return { + "memory_uuid": "11111111-2222-3333-4444-555555555555", + "memory_key": "preference:risk_level", + "content": "稳健型", + "memory_type": "preference", + "confidence": 0.9, + "version": 3, + "valid_until": None, + } + + +@pytest.mark.asyncio +async def test_provided_memory_sources_is_passed_through_unchanged() -> None: + """生产端已提供时必须**原样**使用事件里的确定快照,不回查数据库。""" + runtime = WorkerRuntime() + sources = [{"memory_uuid": "u-1", "memory_key": "preference:horizon"}] + payload = {"customer_id": 9102, "profile_version": 2, "memory_sources": sources} + + result = await runtime._with_memory_sources(payload) + + assert result is payload + assert result["memory_sources"] is sources + + +@pytest.mark.asyncio +async def test_missing_memory_sources_falls_back_to_active_memories( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """缺失时回退为查询当前有效记忆,并把它组装成适配器认得的形状。""" + runtime = WorkerRuntime() + calls: list[int] = [] + + async def fake_active_memories(self: Any, customer_id: int) -> list[dict[str, Any]]: + calls.append(customer_id) + return [memory_row()] + + monkeypatch.setattr(ProfileRepository, "active_memories", fake_active_memories) + monkeypatch.setattr("app.worker.runtime.SessionFactory", FakeSession) + + payload = {"customer_id": "9102", "profile_version": 2} # 字符串客户号,复现生产端形态 + result = await runtime._with_memory_sources(payload) + + assert calls == [9102] + sources = result["memory_sources"] + assert len(sources) == 1 + assert sources[0]["memory_key"] == "preference:risk_level" + assert sources[0]["memory_uuid"] == memory_row()["memory_uuid"] + assert sources[0]["version"] == 3 + # 原 payload 的其余键必须保留(适配器还要用 profile_version 等) + assert result["profile_version"] == 2 + + +@pytest.mark.asyncio +async def test_empty_active_memories_yields_empty_list_not_missing_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """没有有效记忆时补**空列表**(不是删掉键)。 + + 空列表是"确实没有要投影的记忆",适配器接受并写 0 行;缺键则会 `memory_sources is + invalid` 报错、退避重试到死信。两者语义不同,必须区分。 + """ + runtime = WorkerRuntime() + + async def none_active(self: Any, customer_id: int) -> list[dict[str, Any]]: + return [] + + monkeypatch.setattr(ProfileRepository, "active_memories", none_active) + monkeypatch.setattr("app.worker.runtime.SessionFactory", FakeSession) + + result = await runtime._with_memory_sources({"customer_id": 9102}) + + assert result["memory_sources"] == [] + + +@pytest.mark.asyncio +async def test_payload_without_customer_id_is_left_alone() -> None: + """没有客户号时无法兜底,原样返回、交给适配器失败关闭(不在此处静默造数据)。""" + runtime = WorkerRuntime() + payload = {"profile_version": 1} + + result = await runtime._with_memory_sources(payload) + + assert result is payload + assert "memory_sources" not in result + + +@pytest.mark.asyncio +async def test_non_list_memory_sources_is_not_silently_replaced() -> None: + """已提供但格式错(不是列表)时不兜底——那是真错误,必须由适配器报出来。 + + 这条守住"兜底不得掩盖格式错":否则生产者写错字段类型会被悄悄修好, + 线上永远看不到问题。 + """ + runtime = WorkerRuntime() + payload = {"customer_id": 9102, "memory_sources": "not-a-list"} + + result = await runtime._with_memory_sources(payload) + + assert result is payload + assert result["memory_sources"] == "not-a-list" diff --git a/tools/normalize_memory_sync_outbox.py b/tools/normalize_memory_sync_outbox.py new file mode 100644 index 0000000..94cd8e1 --- /dev/null +++ b/tools/normalize_memory_sync_outbox.py @@ -0,0 +1,91 @@ +"""数据订正:把 `memory_sync_outbox` 的历史取值改齐为全仓小写口径。 + +**默认 dry-run**(只报告将影响几行),加 `--apply` 才真正提交。幂等,可重复运行。 + +### 背景 + +`docs/00` §6.4.6 的取值栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`, +与全仓实现从未对齐。按那份文档写入的事件会**静默失效**: + +- 消费端 `MemorySyncOutboxWorker` 按 `handlers.get(event.target_store)` 分派 handler; +- 领取条件是 `status.in_({"pending","failed"})`。 + +大写 + 中文两个条件都不满足 ⇒ 事件任何消费者都领不到、永久滞留且不报错 +(唯一键 `(event_uuid, target_store)` 对大小写没有约束,MySQL 也不会报错)。 + +因此凡是在本仓写入过 `memory_sync_outbox` 的环境,都可能有这类脏行。 + +### 安全口径 + +- 只改 `target_store` / `operation` / `status` 的**值**; +- **不触碰**主键、唯一键 `uk_memory_sync_event (event_uuid, target_store)`、 + `payload`、时间戳、`retry_count`; +- 就地改值不会新增行(两组取值大小写不同,本会各自成行,这里把它们并到正确的一组); +- 中文字符不受 `LOWER()` 影响(非 ASCII 字节不变),故 `LOWER()` 是安全的。 + +用法: + .\\.venv\\Scripts\\python.exe tools\\normalize_memory_sync_outbox.py # 先看 + .\\.venv\\Scripts\\python.exe tools\\normalize_memory_sync_outbox.py --apply # 再改 +""" + +import asyncio +import sys + +from sqlalchemy import text + +from app.infrastructure.db import engine + +_STATUS_CASE = """ + CASE status + WHEN '待处理' THEN 'pending' + WHEN '处理中' THEN 'processing' + WHEN '已完成' THEN 'processed' + WHEN '失败' THEN 'failed' + ELSE LOWER(status) + END +""" + +_UPDATE = text(f""" + UPDATE memory_sync_outbox + SET target_store = LOWER(target_store), + operation = LOWER(operation), + status = {_STATUS_CASE} + WHERE target_store <> LOWER(target_store) + OR operation <> LOWER(operation) + OR status <> {_STATUS_CASE} +""") + +_GROUP = text( + "SELECT target_store, operation, status, COUNT(*) n " + "FROM memory_sync_outbox GROUP BY target_store, operation, status" +) + +_DETAIL = text( + "SELECT id, event_uuid, target_store, operation, status, retry_count " + "FROM memory_sync_outbox ORDER BY id" +) + + +async def main(apply: bool) -> int: + async with engine.connect() as conn: + before = (await conn.execute(_GROUP)).mappings().all() + print("改前:", [dict(row) for row in before] or "(空表)") + + affected = (await conn.execute(_UPDATE)).rowcount + print("将订正行数:", affected) + + if not apply: + print("dry-run:未提交。确认无误后加 --apply 再运行。") + await conn.rollback() + return 0 + + await conn.commit() + after = (await conn.execute(_GROUP)).mappings().all() + print("改后:", [dict(row) for row in after] or "(空表)") + for row in (await conn.execute(_DETAIL)).mappings().all(): + print(" ", dict(row)) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main("--apply" in sys.argv))) diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index 7ed92e6..f308e62 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -118,6 +118,26 @@ USERS: tuple[tuple[int, str, str, str], ...] = ( (9001, "T-CUST", "cust_t", "customer"), (9002, "T-RISK", "risk_t", "employee"), (9003, "T-ADMIN", "admin_t", "employee"), + # 9004 review_t:**不绑任何角色、不设密码**的账号,用于两个边界用例: + # - `test_rbac_read_mysql`:账号**存在但无权限** → 应返回 200 + 空权限集,而不是 404(错误码语义); + # - `test_auth_login_mysql` 的 `PLACEHOLDER_ACCOUNTS`:`password_hash` 是占位符 → 必须 401 而非 500。 + # 2026-09-12 补:此前这两个测试都引用它,但**种子从未创建**它 —— 后者因"账号不存在也返回 401" + # 而恰好蒙过,前者则一直红(`/admin/users/9004/roles` 返回 404 "用户不存在")。 + (9004, "T-REVIEW", "review_t", "employee"), +) + +#: 用户 → 角色绑定,**显式列出**而不是按位置配对。 +#: +#: 原先写的是 `zip(user_ids, role_ids, strict=True)`,隐含假设「USERS 与 ROLES 一一对应」; +#: 9004 review_t 是**故意不绑角色**的账号,一加进来 zip 就会 +#: `ValueError: zip() argument 2 is shorter than argument 1`,整个种子跑不完 +#: (而 commit 在最后,外部表现是"什么都没发生")。显式列表让"谁绑什么"一眼可见, +#: 也不受两侧顺序/长度变化影响。 +USER_ROLES: tuple[tuple[int, int], ...] = ( + (9001, 9001), # cust_t → customer + (9002, 9002), # risk_t → risk_operator + (9003, 9003), # admin_t → admin + # 9004 review_t 不在此列:「账号存在但无权限」正是它要覆盖的场景。 ) GRANTS: tuple[tuple[int, tuple[int, ...]], ...] = ( @@ -134,8 +154,6 @@ async def seed() -> None: # "尚未生效",于是刚种好的账号一个权限都拿不到(表现为 roles=() 而非报错)。 # 往前留 5 秒,彻底避开这个舍入窗口。 effective_at = now - timedelta(seconds=5) - user_ids = tuple(user[0] for user in USERS) - role_ids = tuple(role[0] for role in ROLES) async with SessionFactory() as session: await session.execute( text("DELETE FROM sys_role_permission WHERE role_id IN (9001,9002,9003)") @@ -185,7 +203,7 @@ async def seed() -> None: {"id": permission_id, "code": code, "resource": resource, "action": action, "scope": scope, "now": now}, ) - for user_id, role_id in zip(user_ids, role_ids, strict=True): + for user_id, role_id in USER_ROLES: await session.execute( text( "INSERT INTO sys_user_role (user_id, role_id, assigned_at)" diff --git a/tools/setup_milvus_profile_collection.py b/tools/setup_milvus_profile_collection.py new file mode 100644 index 0000000..c18eba7 --- /dev/null +++ b/tools/setup_milvus_profile_collection.py @@ -0,0 +1,144 @@ +"""幂等创建长期记忆画像向量集合 `user_long_term_memory_v1`。 + +写入侧是 `app/infrastructure/milvus_profile_projection.py`,字段必须与之一致。 + +用法: + .\\.venv\\Scripts\\python.exe tools\\setup_milvus_profile_collection.py + +安全口径(与 `setup_milvus_knowledge_collections.py` 相同:这是**共享** Milvus 实例, +里面还有别的项目在用的集合): +- 只碰 `user_long_term_memory_v1` 这一个集合,绝不 list 后批量删除; +- 集合已存在时**只做结构比对并报告**,不覆盖、不删重建; +- 结构不一致时明确报错退出,由人决定怎么处理,避免静默丢数据。 +""" + +import asyncio +import sys +from typing import Any + +COLLECTION = "user_long_term_memory_v1" +VECTOR_FIELD = "embedding" +VECTOR_DIM = 1024 +PRIMARY_FIELD = "memory_uuid" + +#: (字段名, 最大长度)。`memory_uuid` 是主键(UUID 字符串)。 +VARCHAR_FIELDS: tuple[tuple[str, int], ...] = ( + ("memory_uuid", 64), + ("content", 2048), + ("memory_type", 32), + ("memory_key", 64), + ("status", 16), +) +#: INT64 字段;可空,因为 `valid_until_ts` 对永久记忆为空。 +INT64_FIELDS: tuple[str, ...] = ( + "customer_id", + "version", + "valid_until_ts", + "updated_at_ts", +) +FLOAT_FIELDS: tuple[str, ...] = ("confidence",) + + +def _build_schema() -> Any: + from pymilvus import DataType, MilvusClient # type: ignore[import-untyped] + + schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False) + for name, max_length in VARCHAR_FIELDS: + schema.add_field( + field_name=name, + datatype=DataType.VARCHAR, + max_length=max_length, + is_primary=(name == PRIMARY_FIELD), + nullable=False, + ) + for name in INT64_FIELDS: + # 可空:`valid_until_ts` 对永久记忆必须能不写。 + schema.add_field( + field_name=name, datatype=DataType.INT64, nullable=name != "customer_id" + ) + for name in FLOAT_FIELDS: + schema.add_field(field_name=name, datatype=DataType.DOUBLE, nullable=False) + schema.add_field( + field_name=VECTOR_FIELD, datatype=DataType.FLOAT_VECTOR, dim=VECTOR_DIM + ) + return schema + + +def _build_index_params() -> Any: + from pymilvus import MilvusClient # type: ignore[import-untyped] + + index_params = MilvusClient.prepare_index_params() + index_params.add_index( + field_name=VECTOR_FIELD, + index_name="profile_vector_index", + index_type="AUTOINDEX", + metric_type="COSINE", + ) + return index_params + + +def describe_mismatch(described: dict[str, Any]) -> list[str]: + """比对已存在集合与期望结构,返回差异列表(一致时为空)。""" + problems: list[str] = [] + actual = {field["name"]: field for field in described.get("fields", [])} + expected_names = ( + [name for name, _ in VARCHAR_FIELDS] + list(INT64_FIELDS) + + list(FLOAT_FIELDS) + [VECTOR_FIELD] + ) + for name in expected_names: + if name not in actual: + problems.append(f"缺少字段 {name}") + for name, _ in VARCHAR_FIELDS: + if name in actual and not actual[name].get("is_primary") and name == PRIMARY_FIELD: + problems.append(f"{name} 不是主键") + vector = actual.get(VECTOR_FIELD) + if vector is not None: + params = vector.get("params") or {} + dim = params.get("dim") + if dim is not None and int(dim) != VECTOR_DIM: + problems.append(f"{VECTOR_FIELD} 维度是 {dim},期望 {VECTOR_DIM}") + return problems + + +async def ensure_collection(uri: str, token: str = "") -> str: + """返回 `created` / `exists` / `conflict:<原因>`。""" + from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] + + client = AsyncMilvusClient(uri=uri, token=token or None) + try: + if await client.has_collection(COLLECTION): + described = await client.describe_collection(COLLECTION) + problems = describe_mismatch(described) + if problems: + return "conflict:" + "; ".join(problems) + return "exists" + await client.create_collection( + collection_name=COLLECTION, + schema=_build_schema(), + index_params=_build_index_params(), + ) + return "created" + finally: + close = getattr(client, "close", None) + if close is not None: + await close() + + +async def main() -> int: + from app.core.config import get_settings + + settings = get_settings() + uri = settings.milvus_uri + if not uri: + print("未配置 milvus_uri,无法创建集合") + return 1 + outcome = await ensure_collection(uri, settings.milvus_token or "") + print(f"{COLLECTION}: {outcome}") + if outcome.startswith("conflict:"): + print("结构不一致,未做任何修改。请人工确认后再处理。") + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))