- Added new configuration for knowledge base root directory in `.env.example` and `settings.py`. - Implemented `find_products` method in `CoreReadOnlyRepository` for fuzzy product search based on user queries. - Introduced `search_cs_knowledge` function in `rag_service.py` to facilitate semantic search across new `fin_*` collections. - Updated document parsing to support Markdown and YAML front-matter for knowledge base entries. - Created multiple new FAQ and policy documents in the `data/kb_collections` directory to enrich the knowledge base. This update significantly improves the knowledge retrieval capabilities for customer service interactions, ensuring more relevant and accurate responses.
492 lines
19 KiB
Python
492 lines
19 KiB
Python
"""Wave 4 端到端测试:12 用例覆盖数据查询/RAG/拒绝/转人工/画像抽槽/脱敏/越权/降级。
|
||
|
||
策略:
|
||
- DB/Redis 直连真实环境(不 mock)
|
||
- LLM 调用 mock(fake_invoke 按 system prompt 关键词路由固定返回值)
|
||
- RAG 检索 mock(VisitorRagService.retrieve 返回固定文档)
|
||
- chat.py 的 session/audit repo 用 conftest autouse mock
|
||
- customer_id 全程取 JWT 解析值(issue_token 签发真实 JWT,走完整鉴权链路)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.config import database as db
|
||
from app.config.database import get_agent_engine, get_core_engine
|
||
from app.gateway.jwt_service import issue_token
|
||
from app.main import app
|
||
from app.service import customer_service
|
||
from app.service.rag_service import VisitorRagService
|
||
from app.service.profile_service import PROFILE_EXTRACT_SYSTEM, ARCHIVE_SUMMARY_SYSTEM
|
||
from sqlalchemy import text
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 常量
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CUST = "CUST-9527"
|
||
CUST_B = "CUST-1001"
|
||
CUST_EXPIRED = "CUST-1006" # 风评过期,测 FM-03
|
||
|
||
SESSION_ID = "sess-e2e-001"
|
||
|
||
# fake_invoke 的默认返回值映射(按 system prompt 关键词路由)
|
||
_INTENT_MAP = {
|
||
"意图分类器": "holding_query",
|
||
"参数抽取器": "{}",
|
||
"事实数据": "根据查询结果,您的持仓情况如下。",
|
||
"知识库检索结果": "根据知识库信息,该产品属于低风险货币基金。",
|
||
"轻松闲聊": "您好!有什么可以帮您的吗?",
|
||
"画像": '{"updates": []}',
|
||
"摘要生成器": "客户咨询了持仓和风险等级。",
|
||
}
|
||
|
||
|
||
def _make_fake_invoke(overrides: dict | None = None):
|
||
"""构造 fake_invoke:按 system prompt 关键词匹配返回固定值。
|
||
|
||
overrides 可覆盖默认映射,如 {"意图分类器": "transaction_query"}。
|
||
"""
|
||
mapping = dict(_INTENT_MAP)
|
||
if overrides:
|
||
mapping.update(overrides)
|
||
|
||
def _fake(system: str, user: str) -> str:
|
||
for keyword, ret in mapping.items():
|
||
if keyword in system:
|
||
return ret
|
||
return ""
|
||
|
||
return _fake
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixture
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean_redis():
|
||
"""每个用例前清理 Redis 中的测试 session 和客户画像缓存。
|
||
|
||
conftest mock 的 session_repo.ensure_session 返回 'sess-test-001',
|
||
所以实际 Redis key 用的是 'sess-test-001' 而非 body.session_id。
|
||
"""
|
||
r = db.get_redis_client()
|
||
patterns = [
|
||
"customer:sess-e2e-001:*",
|
||
"customer:sess-test-001:*",
|
||
f"customer:{CUST}:*",
|
||
f"profile:l1:{CUST}",
|
||
]
|
||
for pattern in patterns:
|
||
for key in r.scan_iter(match=pattern):
|
||
r.delete(key)
|
||
yield
|
||
for pattern in patterns:
|
||
for key in r.scan_iter(match=pattern):
|
||
r.delete(key)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean_profile_l1():
|
||
"""清理 customer_profile_l1 中测试客户的画像(防止用例间污染)。"""
|
||
engine = get_agent_engine()
|
||
with engine.begin() as conn:
|
||
conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST})
|
||
yield
|
||
with engine.begin() as conn:
|
||
conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST})
|
||
|
||
|
||
def _customer_token(customer_id: str = CUST) -> str:
|
||
"""签发 customer 类型 JWT。"""
|
||
token, _ = issue_token(customer_id, "customer")
|
||
return token
|
||
|
||
|
||
def _chat(client: TestClient, message: str, *, token: str | None = None,
|
||
session_id: str = SESSION_ID, end_session: bool = False,
|
||
customer_id: str | None = None) -> dict:
|
||
"""发送 /api/chat 请求并返回 JSON 响应。
|
||
|
||
用法:test_xxx(client, ...) → client 来自 conftest fixture。
|
||
"""
|
||
tok = token or _customer_token()
|
||
body: dict = {"message": message, "session_id": session_id}
|
||
if end_session:
|
||
body["end_session"] = True
|
||
if customer_id:
|
||
body["customer_id"] = customer_id
|
||
resp = client.post(
|
||
"/api/chat",
|
||
json=body,
|
||
headers={
|
||
"Authorization": f"Bearer {tok}",
|
||
"X-Agent-Type": "customer",
|
||
},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
raw = resp.json()
|
||
return raw.get("data", raw)
|
||
|
||
|
||
def _core_scalar(sql: str, params: dict | None = None):
|
||
"""从 core 库查单值。"""
|
||
engine = get_core_engine()
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(sql), params or {}).scalar()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 1:持仓查询
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_01_holding_query(client):
|
||
"""登录 CUST-9527 → 问「我的持仓有哪些」→ 返回本人持仓事实,数值与 core_holding 一致。"""
|
||
fake = _make_fake_invoke({"意图分类器": "holding_query",
|
||
"事实数据": "您的持仓包括以下产品。"})
|
||
with patch.object(customer_service, "_invoke", side_effect=fake):
|
||
data = _chat(client, "我的持仓有哪些", token=_customer_token())
|
||
|
||
assert data["intent"] == "holding_query"
|
||
assert data["reply"]
|
||
assert data["agent_type"] == "customer"
|
||
# 交叉验证:DB 中确实有持仓
|
||
count = _core_scalar("SELECT COUNT(*) FROM core_holding WHERE customer_id = :cid", {"cid": CUST})
|
||
assert count > 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 2:交易流水查询
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_02_transaction_query(client):
|
||
"""问「最近 3 个月交易记录」→ 流水按时间范围正确过滤。"""
|
||
fake = _make_fake_invoke({
|
||
"意图分类器": "transaction_query",
|
||
"参数抽取器": json.dumps({"months": 3}),
|
||
"事实数据": "近3个月交易记录如下。",
|
||
})
|
||
with patch.object(customer_service, "_invoke", side_effect=fake):
|
||
data = _chat(client, "最近3个月交易记录", token=_customer_token())
|
||
|
||
assert data["intent"] == "transaction_query"
|
||
assert data["reply"]
|
||
# 交叉验证:近 3 月 confirmed 交易数
|
||
db_count = _core_scalar(
|
||
"SELECT COUNT(*) FROM core_trade "
|
||
"WHERE customer_id = :cid AND trade_status = 'confirmed' "
|
||
"AND traded_at >= DATE_SUB(NOW(), INTERVAL 3 MONTH)",
|
||
{"cid": CUST},
|
||
)
|
||
# 不论 DB 有无数据,tool 都应正常返回(可能提示"暂无记录")
|
||
assert db_count is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 3:风评查询
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_03_risk_assessment_query(client):
|
||
"""问「我的风险等级」→ 返回 C 档 + 有效期。"""
|
||
fake = _make_fake_invoke({
|
||
"意图分类器": "risk_assessment_query",
|
||
"事实数据": "您的风险评级为C3,有效期至...",
|
||
})
|
||
with patch.object(customer_service, "_invoke", side_effect=fake):
|
||
data = _chat(client, "我的风险等级是什么", token=_customer_token())
|
||
|
||
assert data["intent"] == "risk_assessment_query"
|
||
# 交叉验证:DB 中的风评等级
|
||
risk_code = _core_scalar(
|
||
"SELECT risk_code FROM core_customer_risk "
|
||
"WHERE customer_id = :cid AND is_authoritative = 1 "
|
||
"AND expires_at > NOW() LIMIT 1",
|
||
{"cid": CUST},
|
||
)
|
||
assert risk_code is not None
|
||
assert f"C{risk_code[-1]}" in data["reply"] or risk_code is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 4:适当性匹配(只读,不写 risk_suitability_log)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_04_suitability_check_readonly(client):
|
||
"""问「我能买 R3 产品吗」→ 只读匹配说明,risk_suitability_log 无新记录。"""
|
||
# risk_suitability_log 在 jinrong_agent 库
|
||
agent_engine = get_agent_engine()
|
||
with agent_engine.connect() as conn:
|
||
before = conn.execute(text(
|
||
"SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid"
|
||
), {"cid": CUST}).scalar()
|
||
|
||
fake = _make_fake_invoke({
|
||
"意图分类器": "suitability_check",
|
||
"参数抽取器": json.dumps({"risk_level": "R3"}),
|
||
"事实数据": "根据适当性匹配,您的C3评级可购买R3及以下产品。",
|
||
})
|
||
with patch.object(customer_service, "_invoke", side_effect=fake):
|
||
data = _chat(client, "我能买R3产品吗", token=_customer_token())
|
||
|
||
assert data["intent"] == "suitability_check"
|
||
# 验证 risk_suitability_log 无新记录
|
||
with agent_engine.connect() as conn:
|
||
after = conn.execute(text(
|
||
"SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid"
|
||
), {"cid": CUST}).scalar()
|
||
assert after == before, "适当性查询不应写入 risk_suitability_log"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 5:RAG 类问题
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_05_rag_product_consult(client):
|
||
"""问 RAG 类问题 → 与游客同质量回答 + 风险提示。"""
|
||
fake_rag = ("货币基金A是一款低风险产品,风险等级R1,起购金额1元。", [{"source": "fin_product/doc001"}])
|
||
fake = _make_fake_invoke({
|
||
"意图分类器": "product_consult",
|
||
"知识库检索结果": "根据产品信息,货币基金A风险等级为R1,适合稳健型投资者。",
|
||
})
|
||
with patch.object(customer_service, "_invoke", side_effect=fake), \
|
||
patch.object(VisitorRagService, "retrieve", return_value=fake_rag):
|
||
data = _chat(client, "货币基金A是什么产品", token=_customer_token())
|
||
|
||
assert data["intent"] == "product_consult"
|
||
assert data["reply"]
|
||
assert data.get("has_disclaimer") is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 6:拒绝(推荐稳赚基金)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_06_reject_investment_advice(client):
|
||
"""说「推荐稳赚基金」→ reject 拒绝话术,不转人工。"""
|
||
# 不 mock LLM(keyword_route 直接命中)
|
||
data = _chat(client, "推荐稳赚的基金", token=_customer_token())
|
||
|
||
assert data["intent"] == "reject"
|
||
assert data["transfer_to_human"] is False
|
||
assert data["reply"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 7:转人工(投诉)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_07_transfer_human_complaint(client):
|
||
"""说「我要投诉」→ transfer_human。"""
|
||
data = _chat(client, "我要投诉你们的服务", token=_customer_token())
|
||
|
||
assert data["intent"] == "transfer_human"
|
||
assert data["transfer_to_human"] is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 8:画像抽槽结晶(5 轮)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_08_profile_extraction_crystallize(client):
|
||
"""5 轮内说「只买指数基金」「亏10%提醒我」→ L1 结晶落库,version+1。"""
|
||
extract_json = json.dumps({"updates": [
|
||
{"path": "investment.product_preferences", "value": "指数基金",
|
||
"source": "user_declared", "confidence": 0.9, "evidence": "我只买指数基金"},
|
||
{"path": "threshold_pref_summary", "value": "亏10%就提醒我",
|
||
"source": "user_declared", "confidence": 0.9, "evidence": "亏10%提醒我"},
|
||
]})
|
||
|
||
class FakeLLM:
|
||
def invoke(self, messages):
|
||
class Resp:
|
||
content = extract_json
|
||
return Resp()
|
||
|
||
fake = _make_fake_invoke({
|
||
"意图分类器": "chit_chat",
|
||
"轻松闲聊": "好的,了解了您的偏好。",
|
||
})
|
||
|
||
from app.service import profile_service as ps
|
||
messages = [
|
||
"你好",
|
||
"我只买指数基金",
|
||
"亏10%就提醒我",
|
||
"还有什么产品推荐",
|
||
"谢谢,先这样",
|
||
]
|
||
|
||
with patch.object(customer_service, "_invoke", side_effect=fake), \
|
||
patch.object(ps, "_build_llm", return_value=FakeLLM()):
|
||
for msg in messages:
|
||
_chat(client, msg, token=_customer_token())
|
||
|
||
# 第 5 轮后应触发抽槽(后台线程,稍等)
|
||
import time
|
||
time.sleep(2)
|
||
|
||
# 验证 L1 落库
|
||
engine = get_agent_engine()
|
||
with engine.connect() as conn:
|
||
row = conn.execute(text(
|
||
"SELECT style_tags, version FROM customer_profile_l1 WHERE customer_id = :cid"
|
||
), {"cid": CUST}).fetchone()
|
||
|
||
assert row is not None, "画像应已落库"
|
||
tags = json.loads(row[0])
|
||
assert row[1] >= 1, "version 应 >= 1"
|
||
# 验证偏好结晶
|
||
prefs = tags.get("investment", {}).get("product_preferences", [])
|
||
assert "指数基金" in prefs or len(prefs) > 0, f"product_preferences 应含指数基金: {prefs}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 9:画像注入(第 2 天)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_09_profile_injection_next_day(client):
|
||
"""预置 L1 画像 → 再问时 LLM prompt 注入画像上下文。"""
|
||
# 预置画像
|
||
engine = get_agent_engine()
|
||
with engine.begin() as conn:
|
||
conn.execute(text(
|
||
"INSERT INTO customer_profile_l1 (customer_id, style_tags, version, updated_by) "
|
||
"VALUES (:cid, :tags, 1, 'customer_agent')"
|
||
), {"cid": CUST, "tags": json.dumps({
|
||
"investment": {"product_preferences": {"value": ["指数基金"], "source": "user_declared", "confidence": 0.9}},
|
||
"basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}},
|
||
})})
|
||
|
||
# 刷新 Redis 热缓存(手动写入空 dict 触发 DB 回源)
|
||
r = db.get_redis_client()
|
||
r.delete(f"profile:l1:{CUST}")
|
||
|
||
captured = {}
|
||
|
||
def capturing_invoke(system: str, user: str) -> str:
|
||
# 意图分类器返回 chit_chat
|
||
if "意图分类器" in system:
|
||
return "chit_chat"
|
||
# 闲聊节点:捕获 prompt 内容
|
||
if "轻松闲聊" in system:
|
||
captured["last_user"] = user
|
||
return "您好,了解您的偏好。"
|
||
# 其他节点兜底
|
||
return "好的"
|
||
|
||
with patch.object(customer_service, "_invoke", side_effect=capturing_invoke):
|
||
data = _chat(client, "今天天气怎么样", token=_customer_token())
|
||
|
||
# 验证画像上下文注入了 LLM prompt
|
||
user_prompt = captured.get("last_user", "")
|
||
assert "指数基金" in user_prompt or "上海" in user_prompt, \
|
||
f"画像应注入 prompt: {user_prompt}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 10:脱敏
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_10_data_masking():
|
||
"""查询结果中的敏感信息(手机号/姓名)必须经过脱敏。"""
|
||
# tool_call 返回的 fact_text 已经过 mask_dict_fields 脱敏
|
||
# 这里验证 fact_text 中不含原始敏感信息
|
||
from app.tool.core_ro_tool import query_holdings
|
||
|
||
result = query_holdings(CUST)
|
||
assert result["ok"]
|
||
fact_text = result.get("fact_text", "")
|
||
|
||
# fact_text 不应含完整手机号模式
|
||
import re
|
||
phone_pattern = re.compile(r"1[3-9]\d{9}")
|
||
phones = phone_pattern.findall(fact_text)
|
||
assert len(phones) == 0, f"fact_text 含完整手机号: {phones}"
|
||
|
||
# 不应含完整身份证号
|
||
id_pattern = re.compile(r"\d{17}[\dXx]")
|
||
ids = id_pattern.findall(fact_text)
|
||
assert len(ids) == 0, f"fact_text 含完整身份证号: {ids}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 11:越权 403
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_11_cross_customer_forbidden(client):
|
||
"""用 CUST-9527 的 token 问 CUST-1001 的数据 → 403 或数据隔离。"""
|
||
# resolve_effective_customer_id 对 customer token 直接返回 ctx.sub
|
||
# 所以 body.customer_id 被忽略,不会 403(设计保护)
|
||
# 但直接调 assert_customer_access 应拦截
|
||
from app.gateway.ownership import assert_customer_access
|
||
from app.model.schemas import AuthContext
|
||
from app.utils.exceptions import ForbiddenError
|
||
|
||
ctx = AuthContext(
|
||
sub=CUST,
|
||
token_type="customer",
|
||
roles=["customer"],
|
||
permissions=["agent:customer:chat"],
|
||
tenant_id="default",
|
||
trace_id="test-trace",
|
||
agent_type="customer",
|
||
jti="test-jti",
|
||
customer_id=CUST,
|
||
)
|
||
|
||
# 直接传别人的 customer_id → 应抛 403
|
||
with pytest.raises(ForbiddenError) as exc_info:
|
||
assert_customer_access(ctx, CUST_B)
|
||
assert "403" in str(exc_info.value.error_code) or "NOT_OWNER" in exc_info.value.error_code
|
||
|
||
# 端到端:customer token 传他人 customer_id → merger chat 层 403
|
||
tok = _customer_token()
|
||
resp = client.post(
|
||
"/api/chat",
|
||
json={"message": "我的持仓", "session_id": SESSION_ID, "customer_id": CUST_B},
|
||
headers={
|
||
"Authorization": f"Bearer {tok}",
|
||
"X-Agent-Type": "customer",
|
||
},
|
||
)
|
||
assert resp.status_code == 403
|
||
err = resp.json()
|
||
assert err.get("error_code") == "AUTH_403_NOT_OWNER"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 12:Redis 断降级
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_12_redis_down_degrade(client):
|
||
"""Redis 无数据时对话 → 降级无记忆模式,功能不崩。
|
||
|
||
测法:mock CustomerMemoryService.recall 返回空列表(模拟 Redis 无数据),
|
||
mock ProfileHotCache 返回空 dict(模拟无画像缓存),验证对话正常返回。
|
||
"""
|
||
fake = _make_fake_invoke({
|
||
"意图分类器": "chit_chat",
|
||
"轻松闲聊": "您好!有什么可以帮您的?",
|
||
})
|
||
|
||
from app.service.profile_service import CustomerMemoryService
|
||
|
||
with patch.object(customer_service, "_invoke", side_effect=fake), \
|
||
patch.object(CustomerMemoryService, "recall", return_value=[]), \
|
||
patch.object(CustomerMemoryService, "as_prompt_text", return_value=""), \
|
||
patch.object(CustomerMemoryService, "append"), \
|
||
patch("app.service.customer_service.ProfileHotCache") as mock_hc:
|
||
mock_hc_instance = mock_hc.return_value
|
||
mock_hc_instance.get_style_tags.return_value = {}
|
||
data = _chat(client, "你好", token=_customer_token())
|
||
|
||
assert data["reply"]
|
||
|
||
|