"""CS Wave 3:画像抽槽/合并/归档服务测试(mock LLM + FakeRedis + FakeRepo)。""" from __future__ import annotations import pytest from app.service import profile_service as ps # --------------------------------------------------------------------------- # 测试替身 # --------------------------------------------------------------------------- class FakeMsg: def __init__(self, content: str) -> None: self.content = content class FakeLLM: """按序返回脚本化响应的 LLM 替身。""" def __init__(self, responses: list[str]) -> None: self.responses = list(responses) self.calls: list[list[dict]] = [] def invoke(self, messages): self.calls.append(messages) return FakeMsg(self.responses.pop(0) if self.responses else "{}") class FakeRepo: """ProfileRepository 替身。""" def __init__(self) -> None: self.saved_tags: dict | None = None self.version = 3 self.fail_first = False self._tried = False self.behavior: dict | None = None self.closed: list[str] = [] self.archives: list[dict] = [] self.close_result = True self.idle: list[dict] = [] # L1 def read_l1(self, cid): return self.saved_tags def read_version(self, cid): return self.version def ensure_l1(self, cid): pass def update_style_tags(self, cid, tags, expected_version): if self.fail_first and not self._tried: self._tried = True return False self.saved_tags = tags self.version = expected_version + 1 return True def update_behavior_tags(self, cid, tags): self.behavior = tags # 行为统计 def count_customer_sessions(self, cid): return 2 def count_customer_messages(self, cid): return 7 # 会话消息 def fetch_recent_messages(self, sid, limit=30): return [ {"role": "user", "content": "你好"}, {"role": "assistant", "content": "您好,请问有什么可以帮您?"}, ] def count_session_messages(self, sid): return 4 # 归档 def close_session(self, sid): if not self.close_result: return False self.closed.append(sid) return True def insert_archive(self, **kw): self.archives.append(kw) def list_idle_customer_sessions(self, idle_minutes, limit, exclude_sid=""): return [s for s in self.idle if s["session_id"] != exclude_sid][:limit] class FakeHotCache: def __init__(self, repo=None, tags: dict | None = None) -> None: self._repo = repo self._tags = tags or {} self.invalidated: list[str] = [] def get_style_tags(self, cid): return self._tags def get_style_tags_lazy(self, cid): return self._tags def warm_l1(self, cid, tags): self._tags = tags def invalidate(self, cid): self.invalidated.append(cid) @pytest.fixture def no_sleep(monkeypatch): monkeypatch.setattr(ps.time, "sleep", lambda s: None) # --------------------------------------------------------------------------- # parse_llm_json # --------------------------------------------------------------------------- def test_parse_llm_json_plain(): assert ps.parse_llm_json('{"updates": []}') == {"updates": []} def test_parse_llm_json_fenced(): assert ps.parse_llm_json('```json\n{"a": 1}\n```') == {"a": 1} def test_parse_llm_json_with_prefix(): assert ps.parse_llm_json('结果如下 {"a": {"b": 2}} 完毕') == {"a": {"b": 2}} def test_parse_llm_json_invalid(): assert ps.parse_llm_json("not json at all") is None assert ps.parse_llm_json("") is None assert ps.parse_llm_json("[1,2,3]") is None # 非 dict # --------------------------------------------------------------------------- # CustomerMemoryService # --------------------------------------------------------------------------- def test_memory_append_and_trim(fake_redis): mem = ps.CustomerMemoryService(redis_client=fake_redis) for i in range(20): mem.append("s1", "chitchat", "user", f"消息{i}") mem.append("s1", "chitchat", "assistant", f"回复{i}") # 20 轮 > 15 轮上限 → 保留最后 30 条 msgs = mem.recall("s1", "chitchat") assert len(msgs) == 30 assert msgs[0]["content"] == "消息5" assert msgs[-1]["content"] == "回复19" def test_memory_lines_are_separate(fake_redis): mem = ps.CustomerMemoryService(redis_client=fake_redis) mem.append("s1", "chitchat", "user", "你好") mem.append("s1", "consult", "user", "我的持仓") assert mem.recall("s1", "chitchat")[0]["content"] == "你好" assert mem.recall("s1", "consult")[0]["content"] == "我的持仓" def test_memory_recall_window_merges_and_sorts(fake_redis): import json # 手工写入带 ts 的消息:consult 两条 + chitchat 一条,乱序 fake_redis.rpush("customer:s1:consult", json.dumps({"role": "user", "content": "查持仓", "ts": 100}, ensure_ascii=False)) fake_redis.rpush("customer:s1:chitchat", json.dumps({"role": "user", "content": "你好", "ts": 50}, ensure_ascii=False)) fake_redis.rpush("customer:s1:consult", json.dumps({"role": "assistant", "content": "已查询", "ts": 200}, ensure_ascii=False)) mem = ps.CustomerMemoryService(redis_client=fake_redis) window = mem.recall_window("s1") lines = window.splitlines() assert lines == ["用户: 你好", "用户: 查持仓", "客服: 已查询"] def test_memory_kind_for(): assert ps.memory_kind_for("holding_query") == "consult" assert ps.memory_kind_for("suitability_check") == "consult" assert ps.memory_kind_for("product_consult") == "consult" assert ps.memory_kind_for("chit_chat") == "chitchat" assert ps.memory_kind_for("reject") == "chitchat" assert ps.memory_kind_for("fallback") == "chitchat" # --------------------------------------------------------------------------- # 热缓存降维渲染 # --------------------------------------------------------------------------- def test_render_profile_context_metadata_to_pure_values(): tags = { "basic": {"age_band": {"value": "25-30", "source": "user_declared", "confidence": 0.9}}, "investment": { "product_preferences": {"value": ["指数基金", "货币基金"], "source": "user_declared", "confidence": 0.95} }, "threshold_pref_summary": {"value": "亏损10%提醒", "source": "user_declared", "confidence": 0.97}, } text = ps.render_profile_context(tags) assert text == "年龄段:25-30;偏好产品类型:指数基金、货币基金;阈值提醒偏好:亏损10%提醒" def test_render_profile_context_empty(): assert ps.render_profile_context({}) == "" assert ps.render_profile_context({"basic": {"age_band": {"source": "user_declared"}}}) == "" def test_merge_preferences_records_items_meta(): new, diff = ps.merge_candidates( {}, [{"path": "investment.product_preferences", "value": "我喜欢货币基金", "source": "user_declared", "confidence": 0.85}], ) assert diff entry = new["investment"]["product_preferences"] assert entry["value"] == ["货币基金"] meta = entry["items_meta"]["货币基金"] assert meta["mention_count"] == 1 assert meta["confidence"] == 0.85 assert meta["updated_at"] newer, _ = ps.merge_candidates( new, [{"path": "investment.product_preferences", "value": "货币基金和债券基金", "source": "user_declared", "confidence": 0.9}], ) assert set(newer["investment"]["product_preferences"]["value"]) == {"货币基金", "债券基金"} assert newer["investment"]["product_preferences"]["items_meta"]["货币基金"]["mention_count"] == 2 assert newer["investment"]["product_preferences"]["items_meta"]["债券基金"]["mention_count"] == 1 def test_render_profile_context_top_k_and_stale_excluded(monkeypatch): monkeypatch.setattr(ps.settings, "profile_preference_top_k", 2) monkeypatch.setattr(ps.settings, "profile_preference_inject_ttl_days", 90) tags = { "investment": { "product_preferences": { "value": ["货币基金", "债券基金", "指数基金", "混合基金"], "source": "user_declared", "confidence": 0.9, "items_meta": { "货币基金": {"updated_at": "2026-09-10T10:00:00", "mention_count": 3, "confidence": 0.95}, "债券基金": {"updated_at": "2026-09-09T10:00:00", "mention_count": 2, "confidence": 0.9}, "指数基金": {"updated_at": "2020-01-01T10:00:00", "mention_count": 5, "confidence": 0.99}, "混合基金": {"updated_at": "2026-09-08T10:00:00", "mention_count": 1, "confidence": 0.7}, }, } } } text = ps.render_profile_context(tags, now=ps.datetime.fromisoformat("2026-09-10T12:00:00")) assert "指数基金" not in text assert "货币基金" in text assert "债券基金" in text assert "混合基金" not in text # --------------------------------------------------------------------------- # merge_candidates 规则合并 # --------------------------------------------------------------------------- def _old_tags() -> dict: return { "basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}}, "investment": { "product_preferences": {"value": ["货币基金"], "source": "user_declared", "confidence": 0.9} }, } def test_merge_latest_overwrite(): new, diff = ps.merge_candidates( _old_tags(), [{"path": "basic.city", "value": "我搬去杭州了", "source": "user_declared", "confidence": 0.95}], ) assert diff and "杭州" in diff[0] assert new["basic"]["city"]["value"] == "杭州" assert new["basic"]["city"]["source"] == "user_declared" def test_merge_d7_user_declared_not_overridden_by_inferred(): old = _old_tags() new, diff = ps.merge_candidates( old, [{"path": "basic.city", "value": "杭州", "source": "inferred", "confidence": 0.99}], ) assert diff == [] assert new["basic"]["city"]["value"] == "上海" def test_merge_inferred_can_fill_empty_slot(): new, diff = ps.merge_candidates( {}, [{"path": "lifecycle.near_term_goal", "value": "购房首付", "source": "inferred", "confidence": 0.85}], ) assert diff assert new["lifecycle"]["near_term_goal"]["value"] == "购房首付" def test_merge_high_sensitivity_inferred_dropped(): new, diff = ps.merge_candidates( {}, [{"path": "financial.income_band", "value": "月薪五千", "source": "inferred", "confidence": 0.99}], ) assert diff == [] assert new == {} def test_merge_confidence_threshold(): # 高敏 0.9:0.85 丢弃 / 0.9 通过 new, _ = ps.merge_candidates( {}, [{"path": "financial.income_band", "value": "年薪30万", "source": "user_declared", "confidence": 0.85}], ) assert new == {} new, diff = ps.merge_candidates( {}, [{"path": "financial.income_band", "value": "年薪30万", "source": "user_declared", "confidence": 0.9}], ) assert diff and new["financial"]["income_band"]["value"] == "20-50万" # 默认 0.7:0.65 丢弃 / 0.7 通过 new, _ = ps.merge_candidates( {}, [{"path": "basic.city", "value": "深圳", "source": "user_declared", "confidence": 0.65}], ) assert new == {} new, diff = ps.merge_candidates( {}, [{"path": "basic.city", "value": "深圳", "source": "user_declared", "confidence": 0.7}], ) assert diff and new["basic"]["city"]["value"] == "深圳" def test_merge_invalid_path_and_source(): new, diff = ps.merge_candidates( {}, [ {"path": "l0.risk_level", "value": "C5", "source": "user_declared", "confidence": 0.99}, {"path": "basic.city", "value": "杭州", "source": "model_guess", "confidence": 0.99}, ], ) assert diff == [] assert new == {} def test_merge_normalize_failure_dropped(): new, diff = ps.merge_candidates( {}, [{"path": "financial.income_band", "value": "很多钱", "source": "user_declared", "confidence": 0.99}], ) assert diff == [] assert new == {} def test_merge_age_band_normalized(): new, diff = ps.merge_candidates( {}, [{"path": "basic.age_band", "value": "我今年28", "source": "user_declared", "confidence": 0.95}], ) assert diff and new["basic"]["age_band"]["value"] == "25-30" def test_merge_set_union_dedup(): new, diff = ps.merge_candidates( _old_tags(), [{"path": "investment.product_preferences", "value": ["货币基金", "指数基金"], "source": "user_declared", "confidence": 0.95}], ) assert diff assert new["investment"]["product_preferences"]["value"] == ["货币基金", "指数基金"] def test_merge_set_union_alias_mapping(): new, diff = ps.merge_candidates( {}, [{"path": "investment.excluded_products", "value": ["私募"], "source": "user_declared", "confidence": 0.95}], ) assert diff assert new["investment"]["excluded_products"]["value"] == ["私募基金"] def test_merge_text_slot(): new, diff = ps.merge_candidates( {}, [{"path": "threshold_pref_summary", "value": "亏10%就提醒我", "source": "user_declared", "confidence": 0.95}], ) assert diff assert new["threshold_pref_summary"]["value"] == "亏10%就提醒我" def test_merge_non_list_value_for_list_slot_dropped(): new, diff = ps.merge_candidates( {}, [{"path": "investment.product_preferences", "value": 123, "source": "user_declared", "confidence": 0.95}], ) assert diff == [] assert new == {} # --------------------------------------------------------------------------- # extract_profile(抽槽 → 合并 → 落库三连) # --------------------------------------------------------------------------- def _patch_extract(monkeypatch, repo: FakeRepo, hot: FakeHotCache, llm: FakeLLM): monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) monkeypatch.setattr(ps, "ProfileHotCache", lambda repo=None: hot) monkeypatch.setattr(ps, "_build_llm", lambda: llm) def test_extract_profile_success(monkeypatch): repo, hot = FakeRepo(), FakeHotCache() llm = FakeLLM(['{"updates": [{"path": "basic.age_band", "value": "我今年28", "source": "user_declared", "confidence": 0.95, "evidence": "我今年28"}]}']) _patch_extract(monkeypatch, repo, hot, llm) diff = ps.extract_profile("CUST-1", "用户: 我今年28", trace_id="t1") assert diff and "25-30" in diff[0] assert repo.saved_tags["basic"]["age_band"]["value"] == "25-30" assert hot.invalidated == ["CUST-1"] # prompt 输入窗口进入 user 消息 assert "我今年28" in llm.calls[0][1]["content"] def test_extract_profile_no_updates(monkeypatch): repo, hot = FakeRepo(), FakeHotCache() llm = FakeLLM(['{"updates": []}']) _patch_extract(monkeypatch, repo, hot, llm) assert ps.extract_profile("CUST-1", "用户: 你好") == [] assert repo.saved_tags is None def test_extract_profile_llm_broken(monkeypatch): repo, hot = FakeRepo(), FakeHotCache() _patch_extract(monkeypatch, repo, hot, FakeLLM([])) monkeypatch.setattr(ps, "_build_llm", lambda: (_ for _ in ()).throw(RuntimeError("llm down"))) assert ps.extract_profile("CUST-1", "用户: 我今年28") == [] def test_extract_profile_empty_window(monkeypatch): repo, hot = FakeRepo(), FakeHotCache() _patch_extract(monkeypatch, repo, hot, FakeLLM([])) assert ps.extract_profile("CUST-1", " ") == [] def test_extract_profile_optimistic_lock_retry(monkeypatch): repo, hot = FakeRepo(), FakeHotCache() repo.fail_first = True llm = FakeLLM(['{"updates": [{"path": "basic.city", "value": "我在深圳", "source": "user_declared", "confidence": 0.95, "evidence": "深圳上班"}]}']) _patch_extract(monkeypatch, repo, hot, llm) diff = ps.extract_profile("CUST-1", "用户: 我在深圳上班") assert diff and repo.saved_tags["basic"]["city"]["value"] == "深圳" assert repo.version == 4 # 3 → 失败重试 → 4 # --------------------------------------------------------------------------- # 行为标签 # --------------------------------------------------------------------------- def test_refresh_behavior_tags(monkeypatch, fake_redis): repo = FakeRepo() monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis) fake_redis.hincrby("customer:CUST-1:behavior:intents", "holding_query", 2) fake_redis.hincrby("customer:CUST-1:behavior:intents", "reject", 1) tags = ps.refresh_behavior_tags("CUST-1") assert tags["total_sessions"] == 2 assert tags["total_msgs"] == 7 assert tags["intent_counts"] == {"holding_query": 2, "reject": 1} assert repo.behavior == tags def test_record_intent(monkeypatch, fake_redis): monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis) ps.record_intent("CUST-1", "holding_query") ps.record_intent("CUST-1", "holding_query") assert fake_redis.hgetall("customer:CUST-1:behavior:intents")["holding_query"] == 2 # --------------------------------------------------------------------------- # 会话归档 # --------------------------------------------------------------------------- def test_archive_session_explicit(monkeypatch, no_sleep): repo = FakeRepo() monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) monkeypatch.setattr(ps, "_build_llm", lambda: FakeLLM(["客户咨询了持仓查询,情绪平稳,无需人工跟进。"])) extracted: list[tuple] = [] monkeypatch.setattr(ps, "extract_profile", lambda cid, window, trace_id="": extracted.append((cid, window)) or []) behavior: list[str] = [] monkeypatch.setattr(ps, "refresh_behavior_tags", lambda cid: behavior.append(cid) or {}) ok = ps.archive_session("s1", "CUST-1", "t1", "explicit") assert ok assert repo.closed == ["s1"] assert len(repo.archives) == 1 arch = repo.archives[0] assert arch["session_id"] == "s1" assert arch["trace_id"] == "t1" assert arch["actor_id"] == "CUST-1" assert arch["summary"] == "客户咨询了持仓查询,情绪平稳,无需人工跟进。" assert arch["msg_count"] == 4 assert arch["archive_reason"] == "explicit" # 归档触发画像全量抽槽(窗口含会话消息) assert extracted and extracted[0][0] == "CUST-1" and "你好" in extracted[0][1] assert behavior == ["CUST-1"] def test_archive_session_summary_fallback_on_llm_error(monkeypatch, no_sleep): repo = FakeRepo() monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) def _boom(): raise RuntimeError("llm down") monkeypatch.setattr(ps, "_build_llm", _boom) monkeypatch.setattr(ps, "extract_profile", lambda cid, window, trace_id="": []) monkeypatch.setattr(ps, "refresh_behavior_tags", lambda cid: {}) ok = ps.archive_session("s1", "CUST-1", "t1", "timeout") assert ok assert repo.archives[0]["summary"].startswith("会话已结束") def test_archive_session_already_closed(monkeypatch, no_sleep): repo = FakeRepo() repo.close_result = False monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) monkeypatch.setattr(ps, "_build_llm", lambda: FakeLLM([])) assert ps.archive_session("s1", "CUST-1", "t1", "timeout") is False assert repo.archives == [] def test_archive_idle_sessions(monkeypatch, no_sleep): repo = FakeRepo() repo.idle = [{"session_id": "s1", "customer_id": "CUST-1"}, {"session_id": "s2", "customer_id": "CUST-2"}] monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) archived: list[tuple] = [] monkeypatch.setattr(ps, "archive_session", lambda sid, cid, tid, reason: archived.append((sid, cid, reason)) or True) done = ps.archive_idle_sessions("t1", exclude_sid="s2") assert done == ["s1"] assert archived == [("s1", "CUST-1", "timeout")] def test_archive_idle_sessions_respects_limit(monkeypatch, no_sleep): repo = FakeRepo() repo.idle = [{"session_id": f"s{i}", "customer_id": f"CUST-{i}"} for i in range(5)] monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) archived: list[tuple] = [] monkeypatch.setattr(ps, "archive_session", lambda sid, cid, tid, reason: archived.append(sid) or True) done = ps.archive_idle_sessions("t1", exclude_sid="", limit=3) assert len(done) == 3