from types import SimpleNamespace from uuid import uuid4 from fastapi.testclient import TestClient import app.api.advisor_script_templates as templates_api from app.advisor_db import AgentSessionLocal from app.main import app from app.model.entities_advisor import ScriptTemplate from app.model.advisor_schemas import AuthContext from app.repository.script_template_repository import ScriptTemplateRepository from app.service.script_template_service import ScriptTemplateService from app.service.script_template_vector_service import TemplateVectorError client = TestClient(app) def advisor_auth() -> AuthContext: return AuthContext( user_id="advisor_test", display_name="Advisor Test", roles=["advisor"], permissions=["template:read"], trace_id=f"trace-hybrid-{uuid4().hex}", advisor_id="ADV-TEST-001", ) def token_for(username: str, password: str) -> str: response = client.post( "/api/v1/auth/login", json={"username": username, "password": password}, ) assert response.status_code == 200 return response.json()["data"]["access_token"] def create_approved_template( *, title: str, content: str, usage_count: int = 0, scene: str = "loss_comfort", created_by: str | None = None, ) -> ScriptTemplate: actor = created_by or f"test:hybrid_search:{uuid4().hex}" with AgentSessionLocal() as session: template = ScriptTemplate( scene=scene, customer_type="C3", title=title, content=content, tags=["安抚", "市场波动"], embedding_id=f"tpl_test_{uuid4().hex[:8]}", is_approved=True, approved_by="compliance_test", is_active=True, version=1, usage_count=usage_count, created_by=actor, updated_by=actor, ) session.add(template) session.commit() session.refresh(template) session.expunge(template) return template class FakeHybridVectorService: def __init__(self, hits: list[SimpleNamespace]) -> None: self.hits = hits self.calls: list[dict] = [] def search_templates(self, *, query: str, scene: str | None, top_k: int): self.calls.append({"query": query, "scene": scene, "top_k": top_k}) return self.hits class FailingVectorService: def search_templates(self, *, query: str, scene: str | None, top_k: int): raise TemplateVectorError("milvus unavailable") def test_template_search_merges_keyword_and_vector_hits_with_score_order(): keyword = create_approved_template( title="客户情绪安抚话术", content="您好,短期波动不代表长期趋势,请先看风险揭示材料。", usage_count=1, ) semantic = create_approved_template( title="市场波动沟通话术", content="您好,近期净值回撤来自市场波动,可以先复盘持仓结构。", usage_count=0, ) vector_service = FakeHybridVectorService( [ SimpleNamespace(template_id=semantic.id, score=0.95), SimpleNamespace(template_id=keyword.id, score=0.80), ] ) service = ScriptTemplateService(repository=ScriptTemplateRepository(), vector_service=vector_service) result = service.search_templates(auth=advisor_auth(), q="客户情绪安抚", top_k=3) ids = [item.id for item in result.items] assert ids[0] == semantic.id assert keyword.id in ids assert result.items[0].match_type == "semantic" assert all(item.score <= 1.0 for item in result.items) assert vector_service.calls == [{"query": "客户情绪安抚", "scene": None, "top_k": 3}] def test_template_search_api_falls_back_to_keyword_when_vector_unavailable(monkeypatch): suffix = uuid4().hex[:8] query = f"跌幅沟通关键词{suffix}" template = create_approved_template( title=f"{query}模板", content="您好,产品净值短期有波动,请结合自身风险承受能力理性看待。", ) monkeypatch.setattr( templates_api, "template_service", ScriptTemplateService(repository=ScriptTemplateRepository(), vector_service=FailingVectorService()), ) token = token_for("advisor_test", "advisor_test") response = client.get( "/api/advisor-agent/script-templates/search", params={"q": query}, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 items = response.json()["data"]["items"] assert items[0]["id"] == template.id assert items[0]["match_type"] == "keyword"