2026-09-12 14:27:37 +08:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
2026-09-12 16:33:07 +08:00
|
|
|
import app.api.advisor_script_templates as templates_api
|
|
|
|
|
from app.advisor_db import AgentSessionLocal
|
2026-09-12 14:27:37 +08:00
|
|
|
from app.main import app
|
2026-09-12 16:33:07 +08:00
|
|
|
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
|
2026-09-12 14:27:37 +08:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
]
|
|
|
|
|
)
|
2026-09-12 16:33:07 +08:00
|
|
|
service = ScriptTemplateService(repository=ScriptTemplateRepository(), vector_service=vector_service)
|
2026-09-12 14:27:37 +08:00
|
|
|
|
|
|
|
|
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",
|
2026-09-12 16:33:07 +08:00
|
|
|
ScriptTemplateService(repository=ScriptTemplateRepository(), vector_service=FailingVectorService()),
|
2026-09-12 14:27:37 +08:00
|
|
|
)
|
|
|
|
|
token = token_for("advisor_test", "advisor_test")
|
|
|
|
|
|
|
|
|
|
response = client.get(
|
2026-09-12 16:33:07 +08:00
|
|
|
"/api/advisor-agent/script-templates/search",
|
2026-09-12 14:27:37 +08:00
|
|
|
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"
|