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