chore: 清理违反底座规则的死代码并修正接口文档编号

- 删除生产死代码 app/service/knowledge_tool_service.py 与
  app/infrastructure/milvus_knowledge_adapter.py:后者硬编码 Milvus 字段名,
  违反 AGENTS.md §E,且仅被前者引用;生产检索链路实际走
  knowledge_search_tool -> KnowledgeSearchService -> knowledge_schema 运行时探测。
- 删除上述两模块的单测,以及依赖 legacy 位置参数构造的
  tests/unit/service/test_knowledge_retrieval.py。
- app/service/knowledge_retrieval_service.py 整文件回退底座版本,
  移除 legacy 双构造与重复检索实现。
- docs/05-接口文档.md:客服画像候选改登记为 §8.5,恢复 §8.2 解析知识引用;
  既有 §8.1-§8.4 编号全部保持,修复此前出现两个 8.3 的问题。
- app/model/profile.py:current_customer_id 改为普通可空列映射,与
  alembic/baseline_generated.sql 及真实库一致;原 Computed 声明会让 ORM 把该列
  从 INSERT 中排除,与「必须显式写入」的实际 schema 不符。
- 新增 docs/客服Agent接入底座扩展说明_v1.md,供集成分支评审逐项确认。

验证:pytest tests/unit tests/contract -> 1275 passed, 2 skipped, 0 failed;
ruff check app tests tools alembic 通过;mypy app 通过(244 个源文件)。
This commit is contained in:
张胜宇
2026-09-12 11:15:24 +08:00
parent e85989b344
commit 9aaacc242f
9 changed files with 189 additions and 483 deletions
@@ -1,74 +0,0 @@
from typing import Any
from app.core.errors import ForbiddenAgentError, RecoverableAgentError
from app.core.knowledge_contracts import ALLOWED_KNOWLEDGE_COLLECTIONS
class MilvusKnowledgeClient:
def __init__(self, uri: str, token: str | None = None) -> None:
self._uri = uri
self._token = token
self._client: Any | None = None
async def _ensure_client(self) -> Any:
if self._client is None:
from pymilvus import AsyncMilvusClient # type: ignore[import-untyped]
self._client = AsyncMilvusClient(uri=self._uri, token=self._token)
return self._client
async def search(
self, collection: str, vector: list[float], top_k: int
) -> list[dict[str, Any]]:
if collection not in ALLOWED_KNOWLEDGE_COLLECTIONS:
raise ForbiddenAgentError("未授权的知识集合")
if len(vector) != 1024 or not 1 <= top_k <= 20:
raise RecoverableAgentError("知识检索参数无效")
try:
client = await self._ensure_client()
# Lite 重启后集合默认未加载;远程 Milvus 对重复加载保持幂等。
load_collection = getattr(client, "load_collection", None)
if load_collection is not None:
await load_collection(collection_name=collection)
batches = await client.search(
collection_name=collection,
data=[vector],
limit=top_k,
output_fields=["knowledge_id", "title", "snippet", "tags", "version"],
search_params={"metric_type": "COSINE"},
)
except Exception as exc:
raise RecoverableAgentError("知识检索不可用") from exc
return [
normalized
for batch in batches
for hit in batch
if (normalized := self._normalize_hit(hit)) is not None
]
@staticmethod
def _normalize_hit(hit: Any) -> dict[str, Any] | None:
"""统一 Milvus SDK 的平铺与 entity 包装命中格式。"""
raw = dict(hit)
entity = raw.get("entity")
fields = entity if isinstance(entity, dict) else raw
knowledge_id = fields.get("knowledge_id")
snippet = fields.get("snippet")
score = raw.get("score", raw.get("distance", fields.get("score")))
if (
not isinstance(knowledge_id, str)
or not isinstance(snippet, str)
or not isinstance(score, (int, float))
or isinstance(score, bool)
):
return None
normalized: dict[str, Any] = {
"knowledge_id": knowledge_id,
"snippet": snippet,
"score": float(score),
}
for field in ("title", "tags", "version"):
value = fields.get(field)
if value is not None:
normalized[field] = value
return normalized
+9 -8
View File
@@ -4,15 +4,18 @@
这里显式标注,避免后续有人按直觉写入而踩坑:
1. `user_facts.id` 在库里**没有 auto_increment**,插入时必须由应用显式提供主键;
2. `profile_snapshots.current_customer_id` 是**生成列**(`IF(is_current=1, customer_id, NULL)`),
与唯一键 `uk_profile_snapshot_current` 共同保证「每个客户最多一条当前快照」。生成列由数据库
维护,因此这里只映射为只读计算列,写入时不会提供该字段。
2. `profile_snapshots.current_customer_id` **不是生成列**,而是普通可空列 + 唯一键
`uk_profile_snapshot_current`:当前版本必须由写入方**显式写入**客户 ID(历史版本写 NULL),
才能保证「每个客户最多一条当前快照」。因此这里按普通可空列映射,**不能**声明 `Computed`——
声明成生成列会让 SQLAlchemy 把它从 INSERT 中排除,反而永远写不进去。
(`docs/00` 第 783 行把它描述为「生成列」,与实际 DDL 及真实库不一致;
以 `alembic/baseline_generated.sql`、`tools/seed_profile_demo.py` 和真实库为准。)
"""
from datetime import datetime
from typing import Any
from sqlalchemy import CHAR, JSON, BigInteger, Boolean, Computed, DateTime, Float, String
from sqlalchemy import CHAR, JSON, BigInteger, Boolean, DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.model.base import Base
@@ -59,7 +62,5 @@ class ProfileSnapshot(Base):
generated_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
# 生成列由数据库维护;映射为只读计算列,便于按当前快照查询,不参与 INSERT/UPDATE。
current_customer_id: Mapped[int | None] = mapped_column(
BigInteger, Computed("IF(is_current = 1, customer_id, NULL)")
)
# 普通可空列 + 唯一键,由写入方显式赋值(见模块 docstring 第 2 条),不是生成列。
current_customer_id: Mapped[int | None] = mapped_column(BigInteger)
+4 -61
View File
@@ -113,25 +113,16 @@ class KnowledgeRetrievalService:
def __init__(
self,
client: Any,
*legacy_args: Any,
*,
embedder: Any = None,
session_factory: Callable[[], Any] | None = None,
config: KnowledgeRuntimeConfig | None = None,
vector_dim: int = VECTOR_DIM,
) -> None:
# 兼容早期客服工具的 positional 构造:embedder, vector_store, config, authority。
self._legacy = len(legacy_args) == 3
if self._legacy:
self.embedder = client
self.client = legacy_args[0]
self.config = legacy_args[1]
self._authority = legacy_args[2]
else:
self.client = client
self.embedder = embedder
self.config = config or KnowledgeRuntimeConfig()
self._authority = None
self.client = client
self.embedder = embedder
self._session_factory: Callable[[], Any] = session_factory or SessionFactory
self.config = config or KnowledgeRuntimeConfig()
self.vector_dim = int(vector_dim)
# --- 入口 -----------------------------------------------------------------
@@ -139,14 +130,11 @@ class KnowledgeRetrievalService:
async def search(
self,
query: KnowledgeQuery,
legacy_context: Any = None,
*,
embedding_endpoints: Sequence[Any] | None = None,
embedder: Any = None,
) -> KnowledgeSearchResult:
"""执行检索。集合由意图映射,调用方无法指定集合名。"""
if self._legacy:
return await self._legacy_search(query)
targets = self._assert_collections_allowed(query.intents)
top_k = min(int(query.top_k), self.config.result_limit)
searched = tuple(sorted({collection for collection, _ in targets}))
@@ -162,49 +150,6 @@ class KnowledgeRetrievalService:
hits = self._to_hits(verified)
return KnowledgeSearchResult(hits=hits, degraded=False, searched_collections=searched)
async def _legacy_search(self, query: KnowledgeQuery) -> KnowledgeSearchResult:
"""兼容旧工具调用,仍复用同一意图路由和 MySQL 权威回查。"""
targets = self._assert_collections_allowed(query.intents)
collections = tuple(sorted({collection for collection, _ in targets}))
top_k = min(int(query.top_k), int(getattr(self.config, "result_limit", 20)))
try:
raw_vector = await self.embedder.embed(query.query)
vector = self._vector_of(raw_vector)
self.assert_vector_dim(vector)
rows: list[dict[str, Any]] = []
for collection, route_top_k in targets:
rows.extend(await self.client.search(
collection, vector, min(top_k, route_top_k)
))
if self._authority is not None:
verified = await self._authority.filter_published(tuple(
KnowledgeHit(
knowledge_id=str(row.get("knowledge_id")),
collection=collection,
title=row.get("title"),
snippet=str(row.get("snippet") or ""),
score=self._score(row),
)
for row in rows
for collection, _ in targets
if str(row.get("collection") or collection) == collection
))
return KnowledgeSearchResult(
hits=tuple(verified), searched_collections=collections
)
return KnowledgeSearchResult(hits=self._to_hits(rows), searched_collections=collections)
except Exception:
if self._authority is None:
return KnowledgeSearchResult(
hits=(), degraded=True, degradation_reason="milvus_unavailable",
searched_collections=collections,
)
fallback = await self._authority.search_keyword(query, collections, top_k)
return KnowledgeSearchResult(
hits=tuple(fallback), degraded=True,
degradation_reason="milvus_unavailable", searched_collections=collections,
)
# --- 路由与白名单 ---------------------------------------------------------
def _assert_collections_allowed(
@@ -257,8 +202,6 @@ class KnowledgeRetrievalService:
@staticmethod
def _vector_of(execution: Any) -> list[float]:
if isinstance(execution, list | tuple):
return [float(item) for item in execution]
raw = getattr(execution, "vector", None)
if raw is None and isinstance(execution, Mapping):
raw = execution.get("vector")
-55
View File
@@ -1,55 +0,0 @@
from typing import Protocol
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.knowledge_contracts import KnowledgeQuery, KnowledgeSearchResult
from app.infrastructure.db import SessionFactory
from app.infrastructure.milvus_knowledge_adapter import MilvusKnowledgeClient
from app.service.knowledge_authority import KnowledgeMysqlAuthority
from app.service.knowledge_config import KnowledgeRuntimeConfig
from app.service.knowledge_retrieval_service import KnowledgeRetrievalService
from app.service.model_gateway import DatabaseModelGateway
class EmbeddingGateway(Protocol):
async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]: ...
class DatabaseEmbeddingAdapter:
def __init__(
self, endpoint_code: str, timeout_ms: int, *, gateway: EmbeddingGateway
) -> None:
self._endpoint_code = endpoint_code
self._timeout_ms = timeout_ms
self._gateway = gateway
async def embed(self, text: str) -> list[float]:
return await self._gateway.embed(
endpoint_code=self._endpoint_code, text=text, timeout_ms=self._timeout_ms
)
async def query_knowledge_tool(
arguments: KnowledgeQuery, context: RequestContext
) -> KnowledgeSearchResult:
settings = get_settings()
if not settings.knowledge_embedding_endpoint_code:
return KnowledgeSearchResult(
degraded=True, degradation_reason="embedding_endpoint_unconfigured"
)
# 知识向量端点与默认聊天端点隔离,避免回答模型被误用于检索。
embedder = DatabaseEmbeddingAdapter(
settings.knowledge_embedding_endpoint_code,
settings.knowledge_embedding_timeout_ms,
gateway=DatabaseModelGateway(),
)
# 兼容旧测试替身;真实 Settings 会优先提供本地/远程统一解析后的地址。
milvus_uri = getattr(settings, "resolved_milvus_uri", settings.milvus_uri)
vector_store = MilvusKnowledgeClient(milvus_uri, token=settings.milvus_token or None)
# 权威元数据只读回查,确保对客答案始终来自已发布、有效的知识条目。
async with SessionFactory() as session:
authority = KnowledgeMysqlAuthority(session)
service = KnowledgeRetrievalService(
embedder, vector_store, KnowledgeRuntimeConfig(), authority
)
return await service.search(arguments, context)
+22 -20
View File
@@ -616,26 +616,7 @@ Authorization: Bearer <token>
记忆提取没有客户端写接口。`memory.extraction_requested` 由 `complete_run()` 与最终结果在同一事务写入 Outbox,再由 Worker 调用内部 `MemoryService`。更正、遗忘和监管删除属于独立隐私流程,本接口不临时复用 `memory_conflict`。
### 8.2 客服画像候选(Phase 2)
客服 Agent 不读取或直接修改正式画像。已登录用户明确陈述长期偏好、约束或目标时,系统
异步生成 `memory_unit.status='candidate'` 候选;访客不会生成候选。候选不进入客服召回,
必须经过用户确认和管理员审核后才能晋升为 `active`。
```text
GET /api/v1/users/me/memory-candidates
POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions
GET /api/v1/admin/customer-profile-candidates
POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews
```
用户确认请求体为 `{ "decision": "confirmed" | "rejected" }`,需要
`memory:candidate:confirm`;确认只将状态改为 `verified`。管理员审核请求体复用
`ReviewPayload`,需要管理员角色和 `memory:candidate:review`;`approved` 会在事务内
处理同键旧记忆冲突并将候选改为 `active`,`rejected` 将其改为 `rejected`。接口只返回
结构化候选值,不返回对话证据摘录、密码、验证码或其他原始敏感内容。
### 8.3 解析知识引用
### 8.2 解析知识引用
```http
GET /api/v1/knowledge-references/{reference_token}
@@ -772,6 +753,27 @@ DELETE /api/v1/knowledge/{knowledge_id}
**不返回 `real_name`、`birth_date`、`mobile_masked`、`trade_account` 等 PII**;
`assessment_expired` 按**当前时间**重算,不采信快照里的历史布尔值。
### 8.5 客服画像候选(Phase 2)
> 编号说明:本节为客服二期新增,**不占用 §8.1–§8.4 既有号段**,以避免破坏 `AGENTS.md`、`docs/09`、`docs/14` 对「§8.3 知识库管理三端点」「§8.4 公共只读工具索引」的既有引用。
客服 Agent 不读取或直接修改正式画像。已登录用户明确陈述长期偏好、约束或目标时,系统
异步生成 `memory_unit.status='candidate'` 候选;访客不会生成候选。候选不进入客服召回,
必须经过用户确认和管理员审核后才能晋升为 `active`。
```text
GET /api/v1/users/me/memory-candidates
POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions
GET /api/v1/admin/customer-profile-candidates
POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews
```
用户确认请求体为 `{ "decision": "confirmed" | "rejected" }`,需要
`memory:candidate:confirm`;确认只将状态改为 `verified`。管理员审核请求体复用
`ReviewPayload`,需要管理员角色和 `memory:candidate:review`;`approved` 会在事务内
处理同键旧记忆冲突并将候选改为 `active`,`rejected` 将其改为 `rejected`。接口只返回
结构化候选值,不返回对话证据摘录、密码、验证码或其他原始敏感内容。
## 9. 平台管理面接口
管理面只操作草稿、审核、激活、停用、回滚和归档流程,不提供绕过版本控制的通用 CRUD。所有更新和状态转换都需要 `If-Match`;创建、审核、激活、回滚和停用需要 `Idempotency-Key`。
@@ -0,0 +1,154 @@
# 客服 Agent 接入底座扩展说明(v1)
> 用途:提交 `qyqy_develop`(集成分支)评审时使用。
> 本文档逐项列出「客服 Agent + RAG」能力为接入底座而**触碰的既有文件**、改动性质、合规依据,
> 以及**明确未改动**的底座边界,便于架构师逐条确认。
>
> 依据:`AGENTS.md`、`docs/00`、`docs/01`、`docs/05`、`docs/09`、`docs/14`、`docs/20`。
---
## 一、总体口径
- 本次接入遵循 `AGENTS.md` 规则 7:客服 Agent 继承公共 `BaseAgent`、由 `AgentFactory` 创建,
**不绕过**公共鉴权、记忆、模型路由、工具、合规、审计与事件流程。
- 数据库侧**未做任何基线改动**:没有新增/重命名/删除表,没有改字段类型、可空性或业务含义,
没有新增迁移脚本。
- 底座中所有「既有 Agent 默认行为」均通过**带默认值的声明位**扩展,默认值等于改动前行为,
其他 Agent(`FundQueryDemoAgent`、`RiskAgent`、`PlatformProbeAgent`)路径不受影响。
---
## 二、A 类:纯新增,不影响既有代码
| 文件 | 说明 |
|---|---|
| `app/core/conversation_privacy.py` | 客服会话落库前的凭据最小化(密码/验证码/证件号/银行卡/手机号 → 占位符) |
| `app/api/controllers/visitor_tokens.py`、`app/api/schemas/visitor_tokens.py` | 访客短时令牌签发接口 |
| `app/service/agent/customer_service_routing.py` | 客服确定性意图路由(安全/账户/人工/合规/闲聊/公开知识) |
| `app/service/customer_service_session_memory_service.py` | 客服 Redis 短期会话记忆(30 分钟滑动 TTL、24 小时绝对上限、16 条/约 4096 Token 截断) |
| `app/service/customer_service_handover_context.py`、`customer_service_handover_admin_service.py` | 转人工上下文构造与管理员只读工单服务 |
| `app/service/customer_profile_candidate_service.py` | 画像候选(Phase 2,异步、需用户确认 + 管理员审核) |
| `app/service/knowledge_authority.py`、`knowledge_config.py`、`knowledge_publication_service.py` | 知识权威回查、运行期配置、受控发布工具所依赖的服务 |
| `app/infrastructure/milvus_profile_projection.py`、`neo4j_profile_projection.py` | 画像投影适配器(异步派生写入) |
| `app/worker/memory_sync_outbox_worker.py`、`customer_profile_candidate_worker.py` | 画像投影 Outbox 消费者、候选画像 Worker |
| `app/static/index.html` | 客服联调测试页(`/customer-service-test`) |
| `tools/publish_customer_service_knowledge.py`、`verify_customer_service_phase1.py`、`knowledge_import_preflight.py` | 受控发布、只读门禁、导入预检 |
以上均为新增文件,不改变任何既有模块行为。
---
## 三、B 类:带默认值、默认行为不变的扩展(对齐底座扩展点,建议直接确认)
| 文件 | 改动 | 默认值是否等于原行为 |
|---|---|---|
| `app/core/contracts.py` | `AgentDefinition` 新增 `requires_model_intent_classification=True`、`recalls_customer_memory=True`;`AgentRequestMetadata` 新增 `chitchat_streak`/`clarification_round`/`session_context`;`CoreResult` 新增 `clarification_required` | 是 |
| `app/service/agent/base.py` | `recall_memory()` 在声明关闭或访客时置空;`classify_intent()` 在声明关闭时返回 `None` | 是 |
| `app/service/agent/factory.py` | 仅在 `requires_model_intent_classification` 为真时绑定意图分类器 | 是 |
| `app/core/config.py` | 新增 `visitor_token_ttl_seconds`、`milvus_local_uri`、`knowledge_embedding_endpoint_code`、`knowledge_embedding_timeout_ms` 与 `resolved_milvus_uri` 属性 | 是(新增项均有默认值) |
| `app/core/knowledge_contracts.py` | 新增 `ALLOWED_KNOWLEDGE_COLLECTIONS` 别名(与 `ALLOWED_COLLECTIONS` 恒等);`KnowledgeHit.answer` 可选字段;`KnowledgeSearchResult.hits` 补默认值 | 是(三个 `fin_*_collection` 白名单内容未变) |
| `app/model/knowledge.py` | 新增 `FinKnowledgeMeta = KnowledgeMeta` 别名 | 是 |
| `app/model/memory.py` | `__all__` 与 `ProfileSnapshot` 兼容转出 | 是 |
| `app/service/agent/bootstrap.py` | 新增注册 `query_knowledge` 工具,`handler` 复用 `knowledge_search_tool`(与 `search_knowledge` 同一实现),`required_permission="knowledge:query"` | 是(不新增能力面,实际范围仍由 `config_release` 白名单收口) |
| `app/service/memory_service.py` | `upsert_memory()` 新增 `status` 参数(`active`/`candidate`),`candidate` 不覆盖现有有效记忆 | 是(默认 `active`) |
| `app/service/public_platform_service.py` | 转人工工单 `reason_detail` 落库前做凭据脱敏 | 是(安全收紧) |
| `app/main.py` | 注册 `visitor_tokens_router`、挂载 `/customer-service-test` 静态页 | 是(纯新增装配) |
| `pyproject.toml`、`requirements.txt` | 新增 `milvus-lite>=3.2,<4` | 是(可选本地开发依赖) |
| `.gitignore` | 新增 `.worktrees/`、`data/milvus/` | 是 |
---
## 四、C 类:需要架构师确认的底座语义扩展
### C1. 访客身份(`app/api/dependencies/auth.py` + `app/core/security.py`)
- `security.py`:`JwtAuthenticator.authenticate()` 新增分支——当令牌含 `visitor: true` 声明时,
返回 `roles=("visitor",)`、`permissions=("agent:run", "knowledge:query")`、`data_scope="public"`。
- `auth.py`:`build_request_context()` 对 `visitor` 角色跳过 `IdentityService().resolve()`
(访客不在 `sys_user` 中,无法解析身份)。
**合规说明:**
- `visitor` 角色只可能出自用项目 RS256 私钥签名、且显式带 `visitor: true` 的令牌,普通用户令牌
无法携带该角色;
- 访客权限固定为 `agent:run` + `knowledge:query`,`data_scope="public"`,
**不包含任何账户、持仓、订单、银行卡、投诉进度或画像权限**;
- 该分支只影响访客令牌,非访客令牌仍走完整 `IdentityService` 身份解析。
**请求确认:** 是否接受在底座统一鉴权链路中增加这一受控访客身份;如需要,我们可补一份
`docs/` 说明或在 `docs/05` 登记该令牌类型。
### C2. 客服 Agent 的角色与安全路由(`app/service/agent/implementations/customer_service.py`)
- `allowed_roles` 由 `("customer",)` 扩为 `("visitor", "customer")`;
- `handle()` 首部插入确定性安全路由 `route_message()`(安全提示、合规拒答、账户入口、人工转接优先于检索);
- 新增 `recalls_customer_memory=False`(客服不隐式召回长期/画像记忆);
- 新增 `chitchat_streak == 4` 的一次性业务引导;
- 业务口径:`COMPANY` 由 `南方科技` 改为 `奶龙基金责任有限公司`。
**请求确认:** 角色扩容与行为契约变更(安全路由前置、闲聊计数);品牌名属业务口径调整。
### C3. 画像快照 ORM 声明清理与 `current_customer_id` 映射更正
三处相关改动(**属于修既有缺陷,不是改设计**):
1. `app/model/risk_questionnaire.py`:原有内联 `ProfileSnapshot` 声明与 `app/model/profile.py`
的同表声明**重复**,两个 declarative 类映射同一张表会让 SQLAlchemy 直接拒绝导入。
现改为从 `app.model.profile` 转出规范类,保留对既有调用方的兼容导出。
2. `app/model/profile.py`:把 `current_customer_id` 映射为**普通可空列**。
3. 依据:该列在**真实库中是普通可空列**(`EXTRA=''`、`GENERATION_EXPRESSION=''`,已实测),
`alembic/baseline_generated.sql` 的建表语句也没有 `GENERATED` 子句,
`tools/seed_profile_demo.py` 明确写着「**不是生成列**,必须显式写入」。
因此**不能**声明 `Computed(...)`——那会让 SQLAlchemy 把它从 INSERT 中排除,反而永远写不进去。
4. **附带发现(建议底座侧修正):** `docs/00-新数据库基线设计.md` 第 783 行把该列描述为
「生成列」,与 `docs/02`/`alembic` 基线 DDL 及真实库不一致。本次未改动 `docs/00`,
仅在此登记,请底座侧决定以哪一侧为准。
**请求确认:** 上述三点是否按「修缺陷」接受。
### C4. Milvus 双地址与部署约束(`app/core/config.py` + `app/service/health_service.py`)
- 新增 `milvus_local_uri`(默认空串)与 `resolved_milvus_uri = milvus_local_uri or milvus_uri`;
- `health_service.py` 的健康检查改用 `resolved_milvus_uri`。
**约束(请一并确认并写入环境规范):**
- 团队/生产环境的 `.env` **必须留空 `MILVUS_LOCAL_URI`**,否则健康检查与部分检索链路会指向
本地 Milvus Lite 文件,出现「健康检查正常、实际查的是另一个库」的隐性偏差;
- `.env` 中默认值为空串,**默认行为与改动前完全一致**。
---
## 五、D 类:本次已从 ZSY 侧移除、不进入集成分支的内容
为避免把违反底座规则的内容带进集成分支,以下内容已在本分支删除:
| 内容 | 原因 |
|---|---|
| `app/service/knowledge_tool_service.py` | **生产死代码**(全仓唯一引用是它自己的单测)。生产路径是 `bootstrap.get_knowledge_search_service()` → `KnowledgeSearchService` |
| `app/infrastructure/milvus_knowledge_adapter.py` | **违反 `AGENTS.md` §E**:硬编码 `knowledge_id`/`snippet`/`tags` 等 Milvus 字段名,会打挂字段名不同的环境。且仅被上面那个死模块引用 |
| `tests/unit/service/test_knowledge_tool_service.py`、`tests/unit/infrastructure/test_milvus_knowledge_adapter.py` | 上述两模块的单测 |
| `tests/unit/service/test_knowledge_retrieval.py` | 依赖已移除的 legacy 位置参数构造 |
| `app/service/knowledge_retrieval_service.py` 的 legacy 双构造与 `_legacy_search` | 已整文件回退为底座版本,消除「同一服务两套构造语义 + 两套检索实现」的技术债 |
检索仍统一走底座既有链路:`ToolExecutor` → `knowledge_search_tool` → `KnowledgeSearchService`
→ `app/core/knowledge_schema.py` 的 `detect_schema()` **运行时字段探测**,无任何硬编码字段名。
---
## 六、E 类:明确未改动的底座边界
- `docs/00`、`docs/02` 的表结构与字段语义:**未改动**;
- `alembic/` 迁移脚本:**未改动**;
- Milvus 字段名:**无硬编码**,全部走运行时探测;
- `docs/05-接口文档.md` 的既有章节编号:**§8.1–§8.4 全部保持原编号**,客服二期新增的
「客服画像候选」登记为 **§8.5**(不占用既有号段),另有 §9.7 追加;原有交叉引用
「§8.3 知识库管理三端点」「§8.4 公共只读工具索引」仍然成立;
- 其他业务 Agent、场外/推广/风控域:**未改动**行为契约。
---
## 七、F 类:需要业务方在环境侧确认的既有约束(非本次引入)
1. `config_release` 是**环境数据、不随代码合并**:换环境需重新发布客服工具白名单。
2. Milvus 集合 schema 因环境而异,检索层已运行时探测,**任何新增代码都不得硬编码字段名**。
3. 本地 Milvus Lite 仅用于本地开发;团队环境应使用受管 Milvus 并留空 `MILVUS_LOCAL_URI`。
@@ -1,47 +0,0 @@
import pytest
from app.core.errors import ForbiddenAgentError
from app.infrastructure.milvus_knowledge_adapter import MilvusKnowledgeClient
class FakeMilvus:
def __init__(self) -> None:
self.kwargs = None
async def search(self, **kwargs):
self.kwargs = kwargs
return [[{
"distance": 0.91,
"entity": {
"knowledge_id": "101",
"snippet": "开户说明",
"title": "基金开户",
"tags": ["开户"],
"version": "v1",
},
}]]
@pytest.mark.asyncio
async def test_knowledge_adapter_uses_cosine_and_minimal_public_projection() -> None:
client = MilvusKnowledgeClient("http://unused")
fake = FakeMilvus()
client._client = fake
hits = await client.search("fin_faq_collection", [0.1] * 1024, 3)
assert hits[0]["knowledge_id"] == "101"
assert hits[0]["snippet"] == "开户说明"
assert hits[0]["score"] == 0.91
assert fake.kwargs["collection_name"] == "fin_faq_collection"
assert fake.kwargs["limit"] == 3
assert fake.kwargs["search_params"] == {"metric_type": "COSINE"}
assert fake.kwargs["output_fields"] == ["knowledge_id", "title", "snippet", "tags", "version"]
@pytest.mark.asyncio
async def test_knowledge_adapter_rejects_non_public_collection() -> None:
client = MilvusKnowledgeClient("http://unused")
with pytest.raises(ForbiddenAgentError):
await client.search("customer_vectors", [0.1] * 1024, 3)
@@ -1,87 +0,0 @@
import pytest
from app.core.contracts import RequestContext
from app.core.errors import RecoverableAgentError
from app.core.knowledge_contracts import KnowledgeHit, KnowledgeQuery
from app.service.knowledge_config import KnowledgeRuntimeConfig
from app.service.knowledge_retrieval_service import KnowledgeRetrievalService
# ruff: noqa: E501
class FakeEmbedder:
async def embed(self, text: str) -> list[float]:
assert text == "开户"
return [0.1] * 1024
class FakeVectorStore:
def __init__(self) -> None:
self.calls: list[tuple[str, int]] = []
async def search(self, collection: str, vector: list[float], top_k: int) -> list[dict[str, object]]:
assert len(vector) == 1024
self.calls.append((collection, top_k))
return []
class FakeAuthority:
async def filter_published(self, hits: tuple[object, ...]) -> list[object]:
return []
async def search_keyword(self, query: object, collections: tuple[str, ...], top_k: int) -> list[object]:
return []
class BrokenVectorStore:
async def search(self, collection: str, vector: list[float], top_k: int) -> list[dict[str, object]]:
raise RecoverableAgentError("知识检索不可用")
class FallbackAuthority:
def __init__(self) -> None:
self.calls: list[tuple[tuple[str, ...], int]] = []
async def filter_published(self, hits: tuple[object, ...]) -> list[object]:
return []
async def search_keyword(self, query: KnowledgeQuery, collections: tuple[str, ...], top_k: int) -> list[KnowledgeHit]:
self.calls.append((collections, top_k))
return [KnowledgeHit(
knowledge_id="101", collection="fin_policy_collection", snippet="确认规则",
answer="工作日确认", score=1.0,
)]
@pytest.mark.asyncio
async def test_search_uses_faq_collection_for_faq_only() -> None:
vector_store = FakeVectorStore()
service = KnowledgeRetrievalService(
FakeEmbedder(), vector_store, KnowledgeRuntimeConfig(), FakeAuthority()
)
result = await service.search(
KnowledgeQuery(query="开户", intents=("faq",)),
RequestContext(user_id="visitor-1", trace_id="trace", roles=("visitor",), data_scope="public"),
)
assert vector_store.calls == [("fin_faq_collection", 3)]
assert result.searched_collections == ("fin_faq_collection",)
@pytest.mark.asyncio
async def test_milvus_failure_falls_back_to_published_active_unexpired_knowledge() -> None:
authority = FallbackAuthority()
service = KnowledgeRetrievalService(
FakeEmbedder(), BrokenVectorStore(), KnowledgeRuntimeConfig(), authority
)
result = await service.search(
KnowledgeQuery(query="开户", intents=("policy_explain",)),
RequestContext(user_id="visitor-1", trace_id="trace", roles=("visitor",), data_scope="public"),
)
assert authority.calls == [(("fin_policy_collection",), 5)]
assert result.degraded is True
assert result.degradation_reason == "milvus_unavailable"
assert result.hits[0].answer == "工作日确认"
@@ -1,131 +0,0 @@
import pytest
from app.core.contracts import RequestContext
from app.core.knowledge_contracts import KnowledgeHit, KnowledgeQuery, KnowledgeSearchResult
from app.service import knowledge_tool_service
from app.service.knowledge_tool_service import DatabaseEmbeddingAdapter, query_knowledge_tool
class FakeGateway:
def __init__(self) -> None:
self.calls: list[tuple[str, str, int]] = []
async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]:
self.calls.append((endpoint_code, text, timeout_ms))
return [0.1] * 1024
@pytest.mark.asyncio
async def test_embedding_adapter_uses_single_text_gateway_contract() -> None:
gateway = FakeGateway()
adapter = DatabaseEmbeddingAdapter("knowledge-embedding", 15000, gateway=gateway)
vector = await adapter.embed("基金开户")
assert len(vector) == 1024
assert gateway.calls == [("knowledge-embedding", "基金开户", 15000)]
@pytest.mark.asyncio
async def test_query_tool_degrades_when_embedding_endpoint_is_unconfigured(monkeypatch) -> None:
class Settings:
knowledge_embedding_endpoint_code = ""
monkeypatch.setattr("app.service.knowledge_tool_service.get_settings", lambda: Settings())
result = await query_knowledge_tool(
KnowledgeQuery(query="基金开户", intents=("faq",)),
RequestContext(
user_id="visitor-1", trace_id="trace", roles=("visitor",), data_scope="public"
),
)
assert result.degraded is True
assert result.degradation_reason == "embedding_endpoint_unconfigured"
@pytest.mark.asyncio
async def test_query_tool_uses_configured_embedding_endpoint_and_read_only_dependencies(
monkeypatch,
) -> None:
class Settings:
knowledge_embedding_endpoint_code = "knowledge-embedding"
knowledge_embedding_timeout_ms = 15000
milvus_uri = "http://milvus:19530"
milvus_token = ""
class FakeGateway:
calls: list[tuple[str, str, int]] = []
async def embed(
self, *, endpoint_code: str, text: str, timeout_ms: int
) -> list[float]:
self.calls.append((endpoint_code, text, timeout_ms))
return [0.1] * 1024
class FakeMilvus:
def __init__(self, uri: str, token: str | None) -> None:
self.uri = uri
self.token = token
class FakeSession:
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, exc_type, exc, traceback) -> None:
return None
class FakeAuthority:
def __init__(self, session: object) -> None:
self.session = session
class FakeRetrievalService:
def __init__(self, embedder, vector_store, config, authority) -> None:
self.embedder = embedder
self.vector_store = vector_store
self.config = config
self.authority = authority
async def search(
self, query: KnowledgeQuery, context: RequestContext
) -> KnowledgeSearchResult:
vector = await self.embedder.embed(query.query)
assert len(vector) == 1024
assert isinstance(self.vector_store, FakeMilvus)
assert isinstance(self.authority, FakeAuthority)
assert self.config.routes["faq"] == ("fin_faq_collection", 3)
assert context.data_scope == "public"
return KnowledgeSearchResult(
hits=(
KnowledgeHit(
knowledge_id="1",
collection="fin_faq_collection",
snippet="snippet",
answer="answer",
),
),
searched_collections=("fin_faq_collection",),
)
gateway = FakeGateway()
monkeypatch.setattr(knowledge_tool_service, "get_settings", lambda: Settings())
monkeypatch.setattr(knowledge_tool_service, "DatabaseModelGateway", lambda: gateway)
monkeypatch.setattr(knowledge_tool_service, "MilvusKnowledgeClient", FakeMilvus)
monkeypatch.setattr(knowledge_tool_service, "KnowledgeMysqlAuthority", FakeAuthority)
monkeypatch.setattr(
knowledge_tool_service, "KnowledgeRetrievalService", FakeRetrievalService
)
monkeypatch.setattr(knowledge_tool_service, "SessionFactory", FakeSession)
result = await query_knowledge_tool(
KnowledgeQuery(query="基金开户", intents=("faq",)),
RequestContext(
user_id="visitor-1",
trace_id="trace",
roles=("visitor",),
data_scope="public",
),
)
assert result.hits[0].answer == "answer"
assert gateway.calls == [("knowledge-embedding", "基金开户", 15000)]