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,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)]