Files
group_xinghuo_jinrong/tests/test_wave5_notes.py
T

379 lines
13 KiB
Python
Raw Normal View History

"""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近期对话")