Refactor chat backend integration and introduce new backend-neutral architecture

- Replaced direct FastGPT client usage with a backend-neutral chat interface, allowing for improved flexibility and maintainability.
- Introduced a new `ChatBackend` protocol and implemented `FastGPTBackend` to handle chat operations.
- Updated the chat endpoint to utilize the new backend structure, enhancing the handling of chat requests and responses.
- Added comprehensive tests to ensure compatibility and functionality of the new backend integration.
This commit is contained in:
Eric Wang
2026-07-26 07:25:24 +08:00
parent 893eb2f2f7
commit 5c719ed2ea
8 changed files with 697 additions and 130 deletions

View File

@@ -0,0 +1,70 @@
import pytest
from src.api.endpoints import chat
from src.backends.chat import ChatInput, ChatResult, FormUpdate, TextDelta
from src.schemas.models import ProcessRequest_chat
class FakeBackend:
def __init__(self):
self.received = []
async def stream(self, chat_input: ChatInput):
self.received.append(chat_input)
yield TextDelta("<sta")
yield TextDelta("te>1002</state>你")
yield FormUpdate({"jdcsl": 2})
yield TextDelta("")
async def complete(self, chat_input: ChatInput):
self.received.append(chat_input)
return ChatResult(
content="<state>1002</state>你好",
status_code="1002",
form_update={"jdcsl": 2},
)
def make_request():
return ProcessRequest_chat(
sessionId="session-001",
timeStamp="20260725120000",
text="发生了交通事故",
needFormUpdate=True,
)
@pytest.mark.asyncio
async def test_non_stream_chat_uses_backend_neutral_result():
backend = FakeBackend()
response = await chat(make_request(), stream=False, backend=backend)
assert response.outputText == "你好"
assert response.nextStageCode == "1002"
assert response.formUpdate == {"jdcsl": 2}
assert backend.received == [
ChatInput(
session_id="session-001",
text="发生了交通事故",
need_form_update=True,
)
]
@pytest.mark.asyncio
async def test_stream_chat_keeps_state_prefix_buffering_in_fastapi_layer():
backend = FakeBackend()
response = await chat(make_request(), stream=True, backend=backend)
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
body = "".join(chunks)
assert body.index("event: stage_code") < body.index("event: text_delta")
assert '"nextStageCode": "1002"' in body
assert '"text": ""' in body
assert '"text": ""' in body
assert "event: formUpdate" in body
assert "event: done" in body

View File

@@ -0,0 +1,195 @@
"""Characterization tests for the public HTTP API schemas.
These tests freeze the current contract before the FastGPT backend is replaced.
They should change only when the teams integrating with this service agree to a
contract change.
"""
from collections.abc import Callable
from typing import Any
import pytest
from pydantic import BaseModel, ValidationError
from src.schemas.models import (
ProcessRequest_chat,
ProcessRequest_get,
ProcessRequest_set,
ProcessResponse_chat,
ProcessResponse_get,
ProcessResponse_set,
)
SESSION_ID = "session-001"
TIMESTAMP = "20260725120000"
def chat_request_payload() -> dict[str, Any]:
return {
"sessionId": SESSION_ID,
"timeStamp": TIMESTAMP,
"text": "发生了交通事故",
}
def chat_response_payload() -> dict[str, Any]:
return {
"sessionId": SESSION_ID,
"timeStamp": TIMESTAMP,
"outputText": "请描述事故经过。",
"nextStage": "通话中",
"nextStageCode": "1002",
"code": "200",
}
def get_request_payload() -> dict[str, Any]:
return {
"sessionId": SESSION_ID,
"timeStamp": TIMESTAMP,
"key": "acdinfo",
}
def get_response_payload() -> dict[str, Any]:
return {
"sessionId": SESSION_ID,
"timeStamp": TIMESTAMP,
"value": '{"jdcsl": "2"}',
"code": "200",
}
def set_request_payload() -> dict[str, Any]:
return {
"sessionId": SESSION_ID,
"timeStamp": TIMESTAMP,
"key": "hphm1",
"value": "沪A12345",
}
def set_response_payload() -> dict[str, Any]:
return {
"sessionId": SESSION_ID,
"timeStamp": TIMESTAMP,
"code": "200",
}
def test_chat_request_defaults_are_backward_compatible() -> None:
request = ProcessRequest_chat(**chat_request_payload())
assert request.model_dump() == {
**chat_request_payload(),
"needFormUpdate": False,
"useTextChunk": False,
}
def test_chat_response_shape_is_backward_compatible() -> None:
response = ProcessResponse_chat(**chat_response_payload())
assert response.model_dump() == {
**chat_response_payload(),
"formUpdate": {},
"msg": None,
}
def test_get_info_shapes_are_backward_compatible() -> None:
request = ProcessRequest_get(**get_request_payload())
response = ProcessResponse_get(**get_response_payload())
assert request.model_dump() == {
**get_request_payload(),
"includeInputInfo": False,
}
assert response.model_dump() == {
**get_response_payload(),
"msg": None,
}
def test_set_info_shapes_are_backward_compatible() -> None:
request = ProcessRequest_set(**set_request_payload())
response = ProcessResponse_set(**set_response_payload())
assert request.model_dump() == {
**set_request_payload(),
"includeInputInfo": False,
}
assert response.model_dump() == {
**set_response_payload(),
"msg": None,
}
@pytest.mark.parametrize(
("model", "payload_factory", "required_field"),
[
(ProcessRequest_chat, chat_request_payload, "sessionId"),
(ProcessRequest_chat, chat_request_payload, "timeStamp"),
(ProcessRequest_chat, chat_request_payload, "text"),
(ProcessRequest_get, get_request_payload, "key"),
(ProcessRequest_set, set_request_payload, "key"),
(ProcessRequest_set, set_request_payload, "value"),
(ProcessResponse_chat, chat_response_payload, "nextStageCode"),
(ProcessResponse_get, get_response_payload, "value"),
],
)
def test_required_fields_remain_required(
model: type[BaseModel],
payload_factory: Callable[[], dict[str, Any]],
required_field: str,
) -> None:
payload = payload_factory()
payload.pop(required_field)
with pytest.raises(ValidationError):
model(**payload)
@pytest.mark.parametrize(
("model", "payload_factory", "field_name", "invalid_value"),
[
(ProcessRequest_chat, chat_request_payload, "sessionId", "s" * 65),
(ProcessRequest_chat, chat_request_payload, "timeStamp", "t" * 33),
(ProcessRequest_get, get_request_payload, "sessionId", "s" * 65),
(ProcessRequest_set, set_request_payload, "timeStamp", "t" * 33),
(ProcessResponse_chat, chat_response_payload, "nextStage", "n" * 33),
(ProcessResponse_chat, chat_response_payload, "nextStageCode", "10020"),
(ProcessResponse_chat, chat_response_payload, "code", "10000"),
(ProcessResponse_get, get_response_payload, "code", "10000"),
(ProcessResponse_set, set_response_payload, "code", "10000"),
],
)
def test_public_length_limits_are_enforced(
model: type[BaseModel],
payload_factory: Callable[[], dict[str, Any]],
field_name: str,
invalid_value: str,
) -> None:
payload = payload_factory()
payload[field_name] = invalid_value
with pytest.raises(ValidationError):
model(**payload)
def test_get_info_value_remains_a_json_encoded_string() -> None:
response = ProcessResponse_get(**get_response_payload())
assert isinstance(response.value, str)
assert response.value == '{"jdcsl": "2"}'
def test_chat_form_update_remains_unstructured_for_compatibility() -> None:
form_update = {"jdcsl": 2, "ywrysw": False}
response = ProcessResponse_chat(
**chat_response_payload(),
formUpdate=form_update,
)
assert response.formUpdate == form_update

View File

@@ -0,0 +1,123 @@
from types import SimpleNamespace
import pytest
from src.backends.chat import ChatInput, FormUpdate, TextDelta
from src.backends.fastgpt import FastGPTBackend
class FakeResponse:
def __init__(self, data):
self._data = data
self.raise_for_status_called = False
def raise_for_status(self):
self.raise_for_status_called = True
def json(self):
return self._data
class FakeClient:
def __init__(self, response):
self.response = response
self.calls = []
async def create_chat_completion(self, **kwargs):
self.calls.append(kwargs)
return self.response
@pytest.mark.asyncio
async def test_complete_translates_fastgpt_response_to_neutral_result():
response = FakeResponse(
{
"choices": [
{
"message": {
"content": "<state>1002</state>请描述事故经过。"
}
}
],
"newVariables": {"status_code": "1002"},
"responseData": [
{
"moduleName": "文本内容提取事故信息",
"extractResult": {
"formUpdate": '{"jdcsl": 2, "ywrysw": false}'
},
}
],
}
)
client = FakeClient(response)
backend = FastGPTBackend(client)
result = await backend.complete(
ChatInput(
session_id="session-001",
text="发生了交通事故",
need_form_update=True,
)
)
assert response.raise_for_status_called is True
assert result.content == "<state>1002</state>请描述事故经过。"
assert result.status_code == "1002"
assert result.form_update == {"jdcsl": 2, "ywrysw": False}
assert client.calls == [
{
"messages": [{"role": "user", "content": "发生了交通事故"}],
"chatId": "session-001",
"stream": False,
"detail": True,
"variables": {"needFormUpdate": True},
}
]
@pytest.mark.asyncio
async def test_stream_translates_fastgpt_events_to_neutral_events(monkeypatch):
response = object()
client = FakeClient(response)
backend = FastGPTBackend(client)
async def fake_aiter_stream_events(actual_response):
assert actual_response is response
yield SimpleNamespace(
kind="answer",
data={"choices": [{"delta": {"content": "<state>1002"}}]},
)
yield SimpleNamespace(
kind="flowResponses",
data=[
{
"moduleName": "文本内容提取事故信息",
"extractResult": {"formUpdate": '{"jdcsl": 2}'},
}
],
)
yield SimpleNamespace(
kind="answer",
data={"choices": [{"delta": {"content": "</state>你好"}}]},
)
yield SimpleNamespace(kind="ignored", data={})
monkeypatch.setattr(
"src.backends.fastgpt.aiter_stream_events",
fake_aiter_stream_events,
)
events = [
event
async for event in backend.stream(
ChatInput(session_id="session-001", text="你好")
)
]
assert events == [
TextDelta("<state>1002"),
FormUpdate({"jdcsl": 2}),
TextDelta("</state>你好"),
]
assert client.calls[0]["stream"] is True