Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user