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:
70
test/api/test_chat_backend_boundary.py
Normal file
70
test/api/test_chat_backend_boundary.py
Normal 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
|
||||
195
test/api/test_public_schema_contract.py
Normal file
195
test/api/test_public_schema_contract.py
Normal 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
|
||||
123
test/backends/test_fastgpt_backend.py
Normal file
123
test/backends/test_fastgpt_backend.py
Normal 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
|
||||
Reference in New Issue
Block a user