- Introduced new endpoints `/api/analyst/query/{trace_id}/sample` and `/api/analyst/escalate` for sampling query results and escalating issues to human analysts, respectively.
- Enhanced `AnalystAgent` to support sampling of SQL results based on trace ID and to handle escalation requests, improving user experience in error scenarios.
- Updated `analyst_schemas.py` to include `EscalateRequest` for structured escalation requests.
- Added corresponding frontend API calls and UI components to facilitate user interactions with the new features.
- Implemented unit tests to ensure the reliability of the new functionalities.
This update significantly enhances the analytical capabilities of the application, allowing users to retrieve detailed query samples and escalate issues effectively.
564 lines
20 KiB
Python
564 lines
20 KiB
Python
"""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
|