2026-09-09 18:32:00 +08:00
|
|
|
|
"""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
|
|
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
from app.config import database as db
|
|
|
|
|
|
from app.config.database import get_agent_engine
|
2026-09-09 18:32:00 +08:00
|
|
|
|
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():
|
2026-09-09 20:00:06 +08:00
|
|
|
|
r = db.get_redis_client()
|
2026-09-09 18:32:00 +08:00
|
|
|
|
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:
|
2026-09-09 20:00:06 +08:00
|
|
|
|
resp = client.post(
|
2026-09-09 18:32:00 +08:00
|
|
|
|
"/api/chat",
|
|
|
|
|
|
json={"message": message, "session_id": SESSION_ID},
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {_customer_token()}",
|
|
|
|
|
|
"X-Agent-Type": "customer",
|
|
|
|
|
|
},
|
2026-09-09 20:00:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
raw = resp.json()
|
2026-09-09 18:32:00 +08:00
|
|
|
|
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近期对话")
|