flexigo-support-bot / tests /test_orchestrator.py
victor34593993's picture
reliable tools (gpt-oss-120b) + resilient orchestrator
ddefbcd verified
Raw
History Blame
4.77 kB
from __future__ import annotations
import pytest
from app.llm.base import ChatResult, ProviderError, ToolCall
from app.models import ChatMessage, ChatSession
from app.orchestrator import run_turn
from app.tools.registry import ToolContext
class FakeRouter:
def __init__(self, results: list[ChatResult]):
self._results = list(results)
self.calls = 0
async def chat(self, messages, tools, tier="large", temperature=None):
self.calls += 1
return self._results.pop(0)
class FakeShopify:
async def execute(self, query, variables=None):
return {"orders": {"edges": []}}
@pytest.fixture(autouse=True)
def no_real_search(monkeypatch):
async def fake_search(session, query, k=4, *, tenant_id=None):
return []
from app.rag import index
monkeypatch.setattr(index, "search", fake_search)
async def _session(db):
s = ChatSession(shop="x")
db.add(s)
await db.flush()
return s
async def test_info_turn_uses_knowledge_tool_then_answers(db_session):
router = FakeRouter(
[
ChatResult(
content=None,
tool_calls=[ToolCall(id="t1", name="search_knowledge", arguments={"query": "envíos"})],
finish_reason="tool_calls",
),
ChatResult(content="Hacemos envíos gratis desde 199€.", tool_calls=[], finish_reason="stop"),
]
)
ctx = ToolContext(db=db_session, session=await _session(db_session))
resp = await run_turn(router, ctx, "¿hacéis envíos gratis?", brand_name="Tienda")
assert resp.reply == "Hacemos envíos gratis desde 199€."
assert resp.used_tools == ["search_knowledge"]
assert router.calls == 2
# persisted user + assistant
from sqlalchemy import select
msgs = (await db_session.execute(select(ChatMessage))).scalars().all()
roles = [m.role for m in msgs]
assert "user" in roles and "assistant" in roles
async def test_order_turn_unverified_asks_for_credentials(db_session):
router = FakeRouter(
[
ChatResult(
content=None,
tool_calls=[ToolCall(id="o1", name="lookup_order", arguments={})],
finish_reason="tool_calls",
),
ChatResult(
content="Para localizar tu pedido necesito tu email y número de pedido.",
tool_calls=[],
finish_reason="stop",
),
]
)
ctx = ToolContext(db=db_session, session=await _session(db_session), shopify=FakeShopify())
resp = await run_turn(router, ctx, "¿dónde está mi pedido?")
assert "email" in resp.reply.lower()
assert resp.used_tools == ["lookup_order"]
class _ToolFailRouter:
"""Fails (ProviderError) whenever tools are sent; answers when no tools."""
def __init__(self, reply="Respuesta sin herramientas."):
self.reply = reply
self.calls_with_tools = 0
async def chat(self, messages, tools, tier="large", temperature=None):
if tools:
self.calls_with_tools += 1
raise ProviderError("tool_use_failed")
return ChatResult(content=self.reply, tool_calls=[], finish_reason="stop")
class _AlwaysFailRouter:
async def chat(self, messages, tools, tier="large", temperature=None):
raise ProviderError("down")
async def test_degrades_to_no_tools_when_tool_calling_fails(db_session):
router = _ToolFailRouter()
ctx = ToolContext(db=db_session, session=await _session(db_session))
resp = await run_turn(router, ctx, "¿qué vendéis?")
assert resp.reply == "Respuesta sin herramientas."
assert router.calls_with_tools >= 1 # it tried tools first, then degraded
async def test_total_provider_failure_returns_fallback_not_500(db_session):
from app.orchestrator import FALLBACK_REPLY
ctx = ToolContext(db=db_session, session=await _session(db_session))
resp = await run_turn(_AlwaysFailRouter(), ctx, "hola")
assert resp.reply == FALLBACK_REPLY
async def test_tool_budget_exhausted_forces_final_answer(db_session):
# Always returns tool_calls; orchestrator must stop and force a final answer.
loop_result = ChatResult(
content=None,
tool_calls=[ToolCall(id="t", name="search_knowledge", arguments={"query": "x"})],
finish_reason="tool_calls",
)
router = FakeRouter(
[loop_result, loop_result, loop_result, loop_result,
ChatResult(content="Respuesta final.", tool_calls=[], finish_reason="stop")]
)
ctx = ToolContext(db=db_session, session=await _session(db_session))
resp = await run_turn(router, ctx, "bucle")
assert resp.reply == "Respuesta final."
assert router.calls == 5 # 4 tool iters + 1 forced final