Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop

This commit is contained in:
zhangshy
2026-09-13 21:09:09 +08:00
70 changed files with 9746 additions and 271 deletions
@@ -8,6 +8,6 @@ def test_customer_service_test_page_exposes_visitor_agent_flow() -> None:
response = client.get("/customer-service-test/")
assert response.status_code == 200
assert "奶龙基金智能助手" in response.text
assert "南方财富智能助手" in response.text
assert "/api/v1/visitor-tokens" in response.text
assert "/api/v1/agent-runs" in response.text
+27 -1
View File
@@ -25,7 +25,8 @@ from app.api.schemas.trading import (
HoldingListResponse,
PortfolioSummary,
)
from app.core.errors import FundQuoteUnavailableError
from app.core.contracts import RequestContext
from app.core.errors import FundQuoteUnavailableError, SuitabilityMismatchError
from app.service.trade_service import TradeService, _FeeRule
# ---------------------------------------------------------------------------
@@ -206,3 +207,28 @@ async def test_quote_freshness_only_blocks_trade_path() -> None:
assert snapshot.price == Decimal("4.5000")
with pytest.raises(FundQuoteUnavailableError, match="行情已过期"):
await service._fetch_quote(product, enforce_freshness=True) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_trade_suitability_uses_request_context_and_denies_mismatch() -> None:
evaluator = SimpleNamespace(
evaluate=AsyncMock(
return_value=SimpleNamespace(allowed=False, reason_code="RISK_LEVEL_MISMATCH")
)
)
service = TradeService(session=None, suitability_evaluator=evaluator) # type: ignore[arg-type]
product = SimpleNamespace(
product_code="510500",
risk_level="R4",
risk_disclosure_required=1,
second_confirmation_required=0,
)
context = RequestContext(
user_id="9001", trace_id="trade-test", roles=("customer",), customer_ids=("9001",)
)
with pytest.raises(SuitabilityMismatchError, match="RISK_LEVEL_MISMATCH"):
await service._check_suitability(9001, product, context) # type: ignore[arg-type]
evaluator.evaluate.assert_awaited_once()
assert evaluator.evaluate.await_args.kwargs["context"] is context
+100 -17
View File
@@ -250,8 +250,8 @@ async def test_re_sync_after_content_update_carries_new_text() -> None:
{"knowledge_id": "11"}
)
assert writer.upserts[0]["fields"]["snippet"] != updated
assert writer.upserts[1]["fields"]["snippet"] == updated
assert writer.upserts[0]["fields"]["content"] != updated
assert writer.upserts[1]["fields"]["content"] == updated
async def test_intent_keeps_contract_none_for_ordinary_knowledge() -> None:
@@ -320,11 +320,34 @@ async def test_dispatch_publishes_exactly_one_event_per_call() -> None:
assert session.commits == 0
#: 两套**都真实存在**的集合 schema(2026-09-13 实测 describe_collection):
#: 本机现库是主键 `doc_id` + 正文 `content`,另一套环境是 `knowledge_id` + `snippet`。
#: 写侧靠 `describe_collection` 把**逻辑**字段名映射成这些**物理**名 ——
#: 桩不提供它,探测就会失败、整条写入被拒(这正是修复前真机上的表现:
#: 22 块知识全部 `RecoverableAgentError`,事件进死信,而检索侧看不出异常)。
SCHEMA_DOC_ID = ("doc_id", "title", "content", "chapter", "section", "tags",
"doc_no", "version", "visibility", "embedding")
SCHEMA_KNOWLEDGE_ID = ("knowledge_id", "title", "snippet", "tags", "version", "embedding")
class _FakeMilvusClient:
def __init__(self) -> None:
def __init__(self, fields: tuple[str, ...] = SCHEMA_DOC_ID) -> None:
self.upserts: list[dict[str, Any]] = []
self.deletes: list[dict[str, Any]] = []
self.closed = False
self._fields = fields
async def describe_collection(self, *, collection_name: str) -> dict[str, Any]:
# 除向量字段外都带 `max_length`,与真实 VarChar 字段一致 —— 写侧靠它判断
# 「哪些是必填标量字段、这一行没给就要补空串」。
return {
"collection_name": collection_name,
"fields": [
{"name": name} if name == "embedding"
else {"name": name, "params": {"max_length": 1024}}
for name in self._fields
],
}
async def upsert(self, *, collection_name: str, data: list[dict[str, Any]]) -> None:
self.upserts.append({"collection_name": collection_name, "data": data})
@@ -336,8 +359,22 @@ class _FakeMilvusClient:
self.closed = True
async def test_writer_upserts_one_row_keyed_by_knowledge_id() -> None:
client = _FakeMilvusClient()
@pytest.mark.parametrize(
("present_fields", "id_key", "text_key"),
[
(SCHEMA_DOC_ID, "doc_id", "content"),
(SCHEMA_KNOWLEDGE_ID, "knowledge_id", "snippet"),
],
)
async def test_writer_maps_logical_fields_to_collection_schema(
present_fields: tuple[str, ...], id_key: str, text_key: str
) -> None:
"""逻辑字段名 → 该集合的物理名:**两套 schema 都必须能写**。
写侧此前硬编码 `knowledge_id`/`snippet`,遇到另一套 schema 时整条 upsert 直接失败;
而且这条路径只在真机上暴露(桩没有 describe_collection),所以长期没被测出来。
"""
client = _FakeMilvusClient(present_fields)
writer = MilvusKnowledgeWriter("http://milvus:19530")
writer._client = client
@@ -345,20 +382,60 @@ async def test_writer_upserts_one_row_keyed_by_knowledge_id() -> None:
collection="fin_faq_collection",
knowledge_id="11",
vector=[0.5] * 1024,
fields={"title": "标题", "snippet": "正文"},
fields={"title": "标题", "content": "正文"},
)
assert client.upserts == [{
"collection_name": "fin_faq_collection",
"data": [{
"knowledge_id": "11",
# 向量字段名必须与集合 schema 一致(`embedding`,不是 `vector`):
# 集合 `enable_dynamic_field=False`,写错键会让 upsert 直接失败。
"embedding": [0.5] * 1024,
"title": "标题",
"snippet": "正文",
}],
}]
assert client.upserts[0]["collection_name"] == "fin_faq_collection"
row = client.upserts[0]["data"][0]
assert row[id_key] == "11"
# 向量字段名必须与集合 schema 一致(`embedding`,不是 `vector`):
# 集合 `enable_dynamic_field=False`,写错键会让 upsert 直接失败。
assert row["embedding"] == [0.5] * 1024
assert row["title"] == "标题"
assert row[text_key] == "正文"
# 集合有、这一行没给的 VARCHAR 字段补值:Milvus 对非 nullable 且无默认值的字段要求
# 必须提供,缺一个整条 upsert 就失败(实测缺 `chapter` 报 `Insert missed an field`)。
# 两套 schema 只有一套带这些字段,所以只断言"集合里有的那些"。
for optional_field in ("chapter", "section"):
if optional_field in present_fields:
assert row[optional_field] == ""
# `visibility` 例外:留空会被检索侧 `visibility == "public"` 的过滤把整行排除,
# 表现为"入库成功却一条都检索不到"(实测 22 块知识全部如此),所以按 FIELD_DEFAULTS 填。
if "visibility" in present_fields:
assert row["visibility"] == "public"
async def test_writer_skips_fields_the_collection_does_not_have() -> None:
"""集合没有的逻辑字段**跳过而不是报错**(另一套 schema 里没有 `intent` 字段)。"""
client = _FakeMilvusClient(SCHEMA_KNOWLEDGE_ID)
writer = MilvusKnowledgeWriter("http://milvus:19530")
writer._client = client
await writer.upsert(
collection="fin_faq_collection",
knowledge_id="11",
vector=[0.1] * 1024,
fields={"content": "正文", "intent": "chitchat"},
)
row = client.upserts[0]["data"][0]
assert "intent" not in row
assert row["snippet"] == "正文"
async def test_writer_fails_closed_when_schema_is_unusable() -> None:
"""探测不出必要字段时必须**报错**,不能静默写入检索不到的数据。"""
client = _FakeMilvusClient(("title", "embedding")) # 既无主键、也无正文
writer = MilvusKnowledgeWriter("http://milvus:19530")
writer._client = client
with pytest.raises(RecoverableAgentError):
await writer.upsert(
collection="fin_faq_collection",
knowledge_id="11",
vector=[0.1] * 1024,
fields={"content": "正文"},
)
async def test_writer_delete_targets_single_id() -> None:
@@ -387,6 +464,12 @@ async def test_writer_fails_closed_on_empty_input(kwargs: Mapping[str, Any]) ->
async def test_writer_translates_backend_failure_to_recoverable() -> None:
class Broken:
async def describe_collection(self, *, collection_name: str) -> dict[str, Any]:
return {
"collection_name": collection_name,
"fields": [{"name": name} for name in SCHEMA_DOC_ID],
}
async def upsert(self, **_kwargs: Any) -> None:
raise RuntimeError("milvus down")
@@ -62,6 +62,20 @@ class FakeMilvusClient:
self.deletes: list[dict[str, Any]] = []
self._log = log
async def describe_collection(self, *, collection_name: str) -> dict[str, Any]:
"""写侧靠它把**逻辑**字段名映射成物理名;桩不提供会走"探测失败"分支。
这里给本机现库的 schema:主键 `doc_id`、正文 `content`
(另一套环境是 `knowledge_id`/`snippet`,两套都由 `knowledge_schema` 统一映射)。
"""
return {
"collection_name": collection_name,
"fields": [{"name": name} for name in (
"doc_id", "title", "content", "chapter", "section",
"tags", "doc_no", "version", "visibility", "embedding",
)],
}
async def upsert(self, *, collection_name: str, data: list[dict[str, Any]]) -> None:
self._log.append("upsert")
self.upserts.append({"collection_name": collection_name, "data": data})
@@ -180,11 +194,11 @@ async def test_dispatch_one_consumes_knowledge_sync_event(
write = client.upserts[0]
assert write["collection_name"] == "fin_faq_collection"
row = write["data"][0]
assert row["knowledge_id"] == "11"
assert row["doc_id"] == "11"
# 字段名必须是集合 schema 的 `embedding`:写成 `vector` 会让 upsert 失败、检索永远命中不到。
assert VECTOR_FIELD == "embedding"
assert len(row[VECTOR_FIELD]) == 1024
assert row["snippet"] == "交易日 15:00 前提交,T+1 确认份额。"
assert row["content"] == "交易日 15:00 前提交,T+1 确认份额。"
# 嵌入的是知识行正文(不是事件 payload),端点走 customer_service/embedding。
assert embedder.texts == ["交易日 15:00 前提交,T+1 确认份额。"]
# 事件本身被标记为已投递,不再 pending。