- 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.
379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""Wave 5 测试:客户显式备注(save_note 意图 + LLM 抽取 + 写库 + 注入)。
|
||
|
||
策略(对齐 Wave 4 e2e):
|
||
- DB/Redis 直连真实环境;customer_notes 表真写真读
|
||
- LLM 调用 mock:customer_service._invoke 控制意图/闲聊/解读;
|
||
note_service._build_llm 返回 FakeLLM 控制备注抽取 JSON
|
||
- conftest autouse mock session/audit/advisor repo
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from app.config import database as db
|
||
from app.config.database import get_agent_engine
|
||
from app.gateway.jwt_service import issue_token
|
||
from app.repository.note_repository import CustomerNoteRepository
|
||
from app.service import customer_service, note_service
|
||
from app.service.customer_prompts import (
|
||
NOTE_SAVED_TEXT,
|
||
VALID_INTENTS,
|
||
keyword_route,
|
||
)
|
||
|
||
CUST = "CUST-9527"
|
||
SESSION_ID = "sess-note-001"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FakeLLM:invoke(messages) 返回 .content
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _Resp:
|
||
def __init__(self, content: str) -> None:
|
||
self.content = content
|
||
|
||
|
||
class FakeLLM:
|
||
"""按构造时传入的 content 返回;用于 mock note_service._build_llm。"""
|
||
|
||
def __init__(self, content: str) -> None:
|
||
self._content = content
|
||
|
||
def invoke(self, messages): # noqa: ANN001
|
||
return _Resp(self._content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixture:清理 Redis + customer_notes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean_env():
|
||
r = db.get_redis_client()
|
||
for pattern in (
|
||
f"customer:{SESSION_ID}:*",
|
||
f"customer:sess-test-001:*",
|
||
f"customer:{CUST}:*",
|
||
f"profile:l1:{CUST}",
|
||
):
|
||
for key in r.scan_iter(match=pattern):
|
||
r.delete(key)
|
||
engine = get_agent_engine()
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text("DELETE FROM customer_notes WHERE customer_id = :cid"),
|
||
{"cid": CUST},
|
||
)
|
||
yield
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text("DELETE FROM customer_notes WHERE customer_id = :cid"),
|
||
{"cid": CUST},
|
||
)
|
||
for pattern in (
|
||
f"customer:{SESSION_ID}:*",
|
||
f"customer:sess-test-001:*",
|
||
f"customer:{CUST}:*",
|
||
):
|
||
for key in r.scan_iter(match=pattern):
|
||
r.delete(key)
|
||
|
||
|
||
def _customer_token() -> str:
|
||
tok, _ = issue_token(CUST, "customer")
|
||
return tok
|
||
|
||
|
||
def _chat(client: TestClient, message: str) -> dict:
|
||
resp = client.post(
|
||
"/api/chat",
|
||
json={"message": message, "session_id": SESSION_ID},
|
||
headers={
|
||
"Authorization": f"Bearer {_customer_token()}",
|
||
"X-Agent-Type": "customer",
|
||
},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
raw = resp.json()
|
||
return raw.get("data", raw)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 1:keyword_route 命中 save_note 关键词
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_keyword_route_save_note():
|
||
assert "save_note" in VALID_INTENTS
|
||
cases = [
|
||
"你要记住我每天看净值",
|
||
"帮我记一下我不买私募",
|
||
"记住我喜欢稳健型产品",
|
||
"别忘了我不买股票基金",
|
||
"记着我每月定投5000",
|
||
]
|
||
for msg in cases:
|
||
hit = keyword_route(msg)
|
||
assert hit == ("save_note", ""), f"应命中 save_note:{msg} → {hit}"
|
||
|
||
|
||
def test_keyword_route_save_note_priority():
|
||
"""数据查询优先级高于 save_note:含数据查询关键词时优先路由到查询。"""
|
||
# "持有" 命中 holding_query,优先于 save_note 的"别忘了"
|
||
hit = keyword_route("别忘了我要长期持有")
|
||
assert hit == ("holding_query", "")
|
||
# "流水" 命中 transaction_query,优先于 save_note 的"记住"
|
||
hit2 = keyword_route("帮我记住最近的流水")
|
||
assert hit2 == ("transaction_query", "")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 2:e2e 写库(LLM 抽取成功 → DB 有 1 条记录)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_save_note_e2e_write_db(client: TestClient):
|
||
"""用户说"你要记住我每天看净值" → 抽取成功 → 写库 → 回复 NOTE_SAVED_TEXT。"""
|
||
extract_json = json.dumps(
|
||
{"content": "每天看基金净值", "category": "habit"}
|
||
)
|
||
|
||
with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)):
|
||
resp = _chat(client, "你要记住我每天看净值")
|
||
|
||
assert resp["reply"] == NOTE_SAVED_TEXT
|
||
assert resp["intent"] == "save_note"
|
||
|
||
repo = CustomerNoteRepository()
|
||
notes = repo.list_active_notes(CUST, limit=5)
|
||
assert len(notes) == 1
|
||
assert notes[0]["content"] == "每天看基金净值"
|
||
assert notes[0]["category"] == "habit"
|
||
assert notes[0]["source_text"] == "你要记住我每天看净值"
|
||
|
||
|
||
def test_save_note_llm_category_other(client: TestClient):
|
||
"""LLM 返回 category 不在 4 类枚举时写为 null。"""
|
||
extract_json = json.dumps(
|
||
{"content": "我喜欢稳健型产品", "category": "投资偏好"} # 非枚举值
|
||
)
|
||
with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)):
|
||
resp = _chat(client, "记住我喜欢稳健型产品")
|
||
|
||
assert resp["reply"] == NOTE_SAVED_TEXT
|
||
repo = CustomerNoteRepository()
|
||
notes = repo.list_active_notes(CUST, limit=5)
|
||
assert len(notes) == 1
|
||
assert notes[0]["category"] is None
|
||
assert notes[0]["content"] == "我喜欢稳健型产品"
|
||
|
||
|
||
def test_save_note_content_truncated(client: TestClient):
|
||
"""content 超过 500 字时被裁剪。"""
|
||
long_content = "我喜欢" + "稳健型" * 200 # >500 字
|
||
extract_json = json.dumps({"content": long_content, "category": "preference"})
|
||
with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)):
|
||
_chat(client, "记住" + long_content)
|
||
|
||
repo = CustomerNoteRepository()
|
||
notes = repo.list_active_notes(CUST, limit=5)
|
||
assert len(notes) == 1
|
||
assert len(notes[0]["content"]) == 500
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 3:抽取失败兜底(content=null / LLM 异常)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_save_note_empty_content_fallback(client: TestClient):
|
||
"""LLM 返回 content=null → 回复 NOTE_EMPTY_TEXT,DB 无新记录。"""
|
||
extract_json = json.dumps({"content": None, "category": None})
|
||
with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)):
|
||
resp = _chat(client, "记住")
|
||
|
||
from app.service.customer_prompts import NOTE_EMPTY_TEXT
|
||
assert resp["reply"] == NOTE_EMPTY_TEXT
|
||
repo = CustomerNoteRepository()
|
||
notes = repo.list_active_notes(CUST, limit=5)
|
||
assert len(notes) == 0
|
||
|
||
|
||
def test_save_note_llm_exception_fallback(client: TestClient):
|
||
"""LLM 异常 → 回复 NOTE_EMPTY_TEXT,DB 无新记录。"""
|
||
|
||
class ExplodingLLM:
|
||
def invoke(self, messages):
|
||
raise RuntimeError("LLM down")
|
||
|
||
with patch.object(note_service, "_build_llm", return_value=ExplodingLLM()):
|
||
resp = _chat(client, "你要记住我有定投习惯")
|
||
|
||
from app.service.customer_prompts import NOTE_EMPTY_TEXT
|
||
assert resp["reply"] == NOTE_EMPTY_TEXT
|
||
repo = CustomerNoteRepository()
|
||
assert len(repo.list_active_notes(CUST, limit=5)) == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 4:注入到 chitchat/interpret prompt
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_note_injection_chitchat(client: TestClient):
|
||
"""已有备注后,第 2 轮 chitchat 的 LLM prompt user 部分含备注内容。"""
|
||
# 预置备注
|
||
repo = CustomerNoteRepository()
|
||
repo.insert_note(
|
||
customer_id=CUST,
|
||
session_id=SESSION_ID,
|
||
trace_id="trace-preseed",
|
||
content="每天看基金净值",
|
||
category="habit",
|
||
source_text="你要记住我每天看净值",
|
||
)
|
||
|
||
captured: dict = {}
|
||
|
||
def _capture_invoke(system: str, user: str) -> str:
|
||
if "意图分类器" in system:
|
||
return "chit_chat"
|
||
if "轻松闲聊" in system:
|
||
captured["user"] = user
|
||
return "好的,明白了。"
|
||
return ""
|
||
|
||
with patch.object(customer_service, "_invoke", side_effect=_capture_invoke):
|
||
resp = _chat(client, "你好")
|
||
|
||
assert resp["intent"] == "chit_chat"
|
||
assert "每天看基金净值" in captured["user"], (
|
||
f"chitchat prompt 应注入备注内容,实际 user={captured.get('user')}"
|
||
)
|
||
assert "客户备注" in captured["user"]
|
||
|
||
|
||
def test_note_injection_interpret(client: TestClient):
|
||
"""已有备注后,interpret 的 LLM prompt 含备注内容。"""
|
||
repo = CustomerNoteRepository()
|
||
repo.insert_note(
|
||
customer_id=CUST,
|
||
session_id=SESSION_ID,
|
||
trace_id="trace-preseed",
|
||
content="不买私募",
|
||
category="preference",
|
||
source_text="记住我不买私募",
|
||
)
|
||
|
||
captured: dict = {}
|
||
|
||
def _capture_invoke(system: str, user: str) -> str:
|
||
if "意图分类器" in system:
|
||
return "holding_query"
|
||
if "参数抽取器" in system:
|
||
return "{}"
|
||
if "事实数据" in system or "系统查询结果" in user:
|
||
captured["user"] = user
|
||
return "您当前持有3只基金。"
|
||
return ""
|
||
|
||
with patch.object(customer_service, "_invoke", side_effect=_capture_invoke):
|
||
resp = _chat(client, "我的持仓")
|
||
|
||
assert resp["intent"] == "holding_query"
|
||
assert "不买私募" in captured["user"], (
|
||
f"interpret prompt 应注入备注,实际 user={captured.get('user')}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 5:软删除(deactivate_all)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_deactivate_all():
|
||
"""forget_all_notes 将所有 active 备注置为 is_active=0。"""
|
||
repo = CustomerNoteRepository()
|
||
for i in range(3):
|
||
repo.insert_note(
|
||
customer_id=CUST,
|
||
session_id=SESSION_ID,
|
||
trace_id=f"trace-{i}",
|
||
content=f"备注{i}",
|
||
category="other",
|
||
source_text=f"原话{i}",
|
||
)
|
||
assert len(repo.list_active_notes(CUST, limit=10)) == 3
|
||
|
||
n = note_service.forget_all_notes(CUST)
|
||
assert n == 3
|
||
assert len(repo.list_active_notes(CUST, limit=10)) == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 6:render_notes_context 格式化
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_render_notes_context_format():
|
||
"""备注按 [category] content 格式拼成一行,空备注返回空串。"""
|
||
# 空备注
|
||
assert note_service.render_notes_context(CUST) == ""
|
||
|
||
repo = CustomerNoteRepository()
|
||
repo.insert_note(
|
||
customer_id=CUST, session_id=SESSION_ID, trace_id="t1",
|
||
content="每天看净值", category="habit", source_text="x",
|
||
)
|
||
repo.insert_note(
|
||
customer_id=CUST, session_id=SESSION_ID, trace_id="t2",
|
||
content="不买私募", category="preference", source_text="y",
|
||
)
|
||
ctx = note_service.render_notes_context(CUST)
|
||
assert "每天看净值" in ctx
|
||
assert "不买私募" in ctx
|
||
assert "[habit]" in ctx
|
||
assert "[preference]" in ctx
|
||
assert ";" in ctx # 多条用;分隔
|
||
|
||
|
||
def test_render_notes_context_empty_customer():
|
||
"""customer_id 为空时返回空串(不查 DB)。"""
|
||
assert note_service.render_notes_context("") == ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用例 7:无备注时降级(prompt notes_context 字段为空)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_no_notes_no_injection(client: TestClient):
|
||
"""无备注时 chitchat prompt 的 notes_context 字段为空字符串。"""
|
||
captured: dict = {}
|
||
|
||
def _capture_invoke(system: str, user: str) -> str:
|
||
if "意图分类器" in system:
|
||
return "chit_chat"
|
||
if "轻松闲聊" in system:
|
||
captured["user"] = user
|
||
return "您好!"
|
||
return ""
|
||
|
||
with patch.object(customer_service, "_invoke", side_effect=_capture_invoke):
|
||
_chat(client, "你好")
|
||
|
||
# notes_context 字段应为空(注入后 prompt 含 "客户备注:\n\n")
|
||
assert "客户备注" in captured["user"]
|
||
# "客户备注:" 后紧跟空行(notes_context 为空)
|
||
after_label = captured["user"].split("客户备注:", 1)[1]
|
||
assert after_label.startswith("\n\n") or after_label.startswith("\n近期对话")
|