Use decoupled way for backend client

This commit is contained in:
Xin Wang
2026-02-25 17:05:40 +08:00
parent 1cd2da1042
commit 08319a4cc7
15 changed files with 1203 additions and 228 deletions

View File

@@ -0,0 +1,150 @@
import aiohttp
import pytest
from app.backend_adapters import (
HistoryDisabledBackendAdapter,
HttpBackendAdapter,
NullBackendAdapter,
build_backend_adapter,
)
@pytest.mark.asyncio
async def test_build_backend_adapter_without_url_returns_null_adapter():
adapter = build_backend_adapter(
backend_url=None,
backend_mode="auto",
history_enabled=True,
timeout_sec=3,
)
assert isinstance(adapter, NullBackendAdapter)
assert await adapter.fetch_assistant_config("assistant_1") is None
assert (
await adapter.create_call_record(
user_id=1,
assistant_id="assistant_1",
source="debug",
)
is None
)
assert (
await adapter.add_transcript(
call_id="call_1",
turn_index=0,
speaker="human",
content="hi",
start_ms=0,
end_ms=100,
confidence=0.9,
duration_ms=100,
)
is False
)
assert (
await adapter.finalize_call_record(
call_id="call_1",
status="connected",
duration_seconds=2,
)
is False
)
assert await adapter.search_knowledge_context(kb_id="kb_1", query="hello", n_results=3) == []
assert await adapter.fetch_tool_resource("tool_1") is None
@pytest.mark.asyncio
async def test_http_backend_adapter_create_call_record_posts_expected_payload(monkeypatch):
captured = {}
class _FakeResponse:
def __init__(self, status=200, payload=None):
self.status = status
self._payload = payload if payload is not None else {}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def json(self):
return self._payload
def raise_for_status(self):
if self.status >= 400:
raise RuntimeError("http_error")
class _FakeClientSession:
def __init__(self, timeout=None):
captured["timeout"] = timeout
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
def post(self, url, json=None):
captured["url"] = url
captured["json"] = json
return _FakeResponse(status=200, payload={"id": "call_123"})
monkeypatch.setattr("app.backend_adapters.aiohttp.ClientSession", _FakeClientSession)
adapter = build_backend_adapter(
backend_url="http://localhost:8100",
backend_mode="auto",
history_enabled=True,
timeout_sec=7,
)
assert isinstance(adapter, HttpBackendAdapter)
call_id = await adapter.create_call_record(
user_id=99,
assistant_id="assistant_9",
source="debug",
)
assert call_id == "call_123"
assert captured["url"] == "http://localhost:8100/api/history"
assert captured["json"] == {
"user_id": 99,
"assistant_id": "assistant_9",
"source": "debug",
"status": "connected",
}
assert isinstance(captured["timeout"], aiohttp.ClientTimeout)
assert captured["timeout"].total == 7
@pytest.mark.asyncio
async def test_backend_mode_disabled_forces_null_even_with_url():
adapter = build_backend_adapter(
backend_url="http://localhost:8100",
backend_mode="disabled",
history_enabled=True,
timeout_sec=7,
)
assert isinstance(adapter, NullBackendAdapter)
@pytest.mark.asyncio
async def test_history_disabled_wraps_backend_adapter():
adapter = build_backend_adapter(
backend_url="http://localhost:8100",
backend_mode="auto",
history_enabled=False,
timeout_sec=7,
)
assert isinstance(adapter, HistoryDisabledBackendAdapter)
assert await adapter.create_call_record(user_id=1, assistant_id="a1", source="debug") is None
assert await adapter.add_transcript(
call_id="c1",
turn_index=0,
speaker="human",
content="hi",
start_ms=0,
end_ms=10,
duration_ms=10,
) is False

View File

@@ -0,0 +1,147 @@
import asyncio
import time
import pytest
from core.history_bridge import SessionHistoryBridge
class _FakeHistoryWriter:
def __init__(self, *, add_delay_s: float = 0.0, add_result: bool = True):
self.add_delay_s = add_delay_s
self.add_result = add_result
self.created_call_ids = []
self.transcripts = []
self.finalize_calls = 0
self.finalize_statuses = []
self.finalize_at = None
self.last_transcript_at = None
async def create_call_record(self, *, user_id: int, assistant_id: str | None, source: str = "debug"):
_ = (user_id, assistant_id, source)
call_id = "call_test_1"
self.created_call_ids.append(call_id)
return call_id
async def add_transcript(
self,
*,
call_id: str,
turn_index: int,
speaker: str,
content: str,
start_ms: int,
end_ms: int,
confidence: float | None = None,
duration_ms: int | None = None,
) -> bool:
_ = confidence
if self.add_delay_s > 0:
await asyncio.sleep(self.add_delay_s)
self.transcripts.append(
{
"call_id": call_id,
"turn_index": turn_index,
"speaker": speaker,
"content": content,
"start_ms": start_ms,
"end_ms": end_ms,
"duration_ms": duration_ms,
}
)
self.last_transcript_at = time.monotonic()
return self.add_result
async def finalize_call_record(self, *, call_id: str, status: str, duration_seconds: int) -> bool:
_ = (call_id, duration_seconds)
self.finalize_calls += 1
self.finalize_statuses.append(status)
self.finalize_at = time.monotonic()
return True
@pytest.mark.asyncio
async def test_slow_backend_does_not_block_enqueue():
writer = _FakeHistoryWriter(add_delay_s=0.15, add_result=True)
bridge = SessionHistoryBridge(
history_writer=writer,
enabled=True,
queue_max_size=32,
retry_max_attempts=0,
retry_backoff_sec=0.01,
finalize_drain_timeout_sec=1.0,
)
try:
call_id = await bridge.start_call(user_id=1, assistant_id="assistant_1", source="debug")
assert call_id == "call_test_1"
t0 = time.perf_counter()
queued = bridge.enqueue_turn(role="user", text="hello world")
elapsed_s = time.perf_counter() - t0
assert queued is True
assert elapsed_s < 0.02
await bridge.finalize(status="connected")
assert len(writer.transcripts) == 1
assert writer.finalize_calls == 1
finally:
await bridge.shutdown()
@pytest.mark.asyncio
async def test_failing_backend_retries_but_enqueue_remains_non_blocking():
writer = _FakeHistoryWriter(add_delay_s=0.01, add_result=False)
bridge = SessionHistoryBridge(
history_writer=writer,
enabled=True,
queue_max_size=32,
retry_max_attempts=2,
retry_backoff_sec=0.01,
finalize_drain_timeout_sec=0.5,
)
try:
await bridge.start_call(user_id=1, assistant_id="assistant_1", source="debug")
t0 = time.perf_counter()
assert bridge.enqueue_turn(role="assistant", text="retry me")
elapsed_s = time.perf_counter() - t0
assert elapsed_s < 0.02
await bridge.finalize(status="connected")
# Initial try + 2 retries
assert len(writer.transcripts) == 3
assert writer.finalize_calls == 1
finally:
await bridge.shutdown()
@pytest.mark.asyncio
async def test_finalize_is_idempotent_and_waits_for_queue_drain():
writer = _FakeHistoryWriter(add_delay_s=0.05, add_result=True)
bridge = SessionHistoryBridge(
history_writer=writer,
enabled=True,
queue_max_size=32,
retry_max_attempts=0,
retry_backoff_sec=0.01,
finalize_drain_timeout_sec=1.0,
)
try:
await bridge.start_call(user_id=1, assistant_id="assistant_1", source="debug")
assert bridge.enqueue_turn(role="user", text="first")
ok_1 = await bridge.finalize(status="connected")
ok_2 = await bridge.finalize(status="connected")
assert ok_1 is True
assert ok_2 is True
assert len(writer.transcripts) == 1
assert writer.finalize_calls == 1
assert writer.last_transcript_at is not None
assert writer.finalize_at is not None
assert writer.finalize_at >= writer.last_transcript_at
finally:
await bridge.shutdown()