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:
@@ -1,11 +1,17 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from ..schemas.models import ProcessRequest_chat, ProcessResponse_chat, ProcessRequest_get, ProcessResponse_get, ProcessRequest_set, ProcessResponse_set, ProcessResponse_delete_session, ProcessRequest_delete_session
|
||||
from fastgpt_client import AsyncChatClient, aiter_stream_events
|
||||
from fastgpt_client.exceptions import (
|
||||
APIError, AuthenticationError, RateLimitError, ValidationError
|
||||
from fastgpt_client import AsyncChatClient
|
||||
from ..backends.chat import (
|
||||
ChatBackend,
|
||||
ChatBackendAPIError,
|
||||
ChatBackendAuthenticationError,
|
||||
ChatBackendRateLimitError,
|
||||
ChatInput,
|
||||
FormUpdate,
|
||||
TextDelta,
|
||||
)
|
||||
from ..core.fastgpt_client import get_fastgpt_client
|
||||
from ..core.fastgpt_client import get_chat_backend, get_fastgpt_client
|
||||
from ..core.config import Config
|
||||
from ..utils.text_chunker import SentenceTextChunker, SentenceTextChunkerConfig
|
||||
from loguru import logger
|
||||
@@ -14,7 +20,6 @@ import re
|
||||
import time
|
||||
|
||||
router = APIRouter()
|
||||
FORM_EXTRACT_MODULE_NAME = "文本内容提取事故信息"
|
||||
STATE_TAG_PATTERN = re.compile(r"<state>\s*(\d+)\s*</state>", flags=re.DOTALL)
|
||||
STATUS_CODE_MAP = {
|
||||
'0000': '结束通话',
|
||||
@@ -60,45 +65,6 @@ def extract_first_state_and_clean_content(text: str) -> tuple[str | None, str]:
|
||||
return match.group(1), STATE_TAG_PATTERN.sub("", text)
|
||||
|
||||
|
||||
def parse_json_value(value):
|
||||
"""Parse JSON string values when possible."""
|
||||
parsed = value
|
||||
for _ in range(3):
|
||||
if not isinstance(parsed, str):
|
||||
return parsed
|
||||
parsed = parsed.strip()
|
||||
if not parsed:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(parsed)
|
||||
except json.JSONDecodeError:
|
||||
return parsed
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_form_update_from_flow_nodes(nodes):
|
||||
"""Extract form update data from the configured FastGPT content-extract node."""
|
||||
if not isinstance(nodes, list):
|
||||
return {}
|
||||
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("moduleName") != FORM_EXTRACT_MODULE_NAME:
|
||||
continue
|
||||
|
||||
extract_result = node.get("extractResult", {})
|
||||
if not isinstance(extract_result, dict):
|
||||
return {}
|
||||
|
||||
form_update = extract_result.get("formUpdate", "")
|
||||
if not form_update:
|
||||
return {}
|
||||
return parse_json_value(form_update)
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def format_set_info_input(payload: dict, include_input_info: bool) -> str:
|
||||
"""Build optional setInfo input for FastGPT helper calls."""
|
||||
if not include_input_info:
|
||||
@@ -156,13 +122,17 @@ def create_sse_event(event: str, data: dict) -> str:
|
||||
async def chat(
|
||||
request: ProcessRequest_chat,
|
||||
stream: bool = False,
|
||||
client: AsyncChatClient = Depends(get_fastgpt_client)
|
||||
backend: ChatBackend = Depends(get_chat_backend)
|
||||
):
|
||||
"""Handle chat completion request."""
|
||||
json_data = request.model_dump()
|
||||
need_form_update = json_data.get('needFormUpdate', False)
|
||||
use_text_chunk = json_data.get('useTextChunk', False)
|
||||
chat_variables = {'needFormUpdate': need_form_update}
|
||||
chat_input = ChatInput(
|
||||
session_id=json_data['sessionId'],
|
||||
text=json_data['text'],
|
||||
need_form_update=need_form_update,
|
||||
)
|
||||
request_started_at = time.perf_counter()
|
||||
logger.info(
|
||||
"Chat request received "
|
||||
@@ -193,16 +163,8 @@ async def chat(
|
||||
else None
|
||||
)
|
||||
try:
|
||||
# Use SDK's create_chat_completion with stream=True
|
||||
response = await client.create_chat_completion(
|
||||
messages=[{"role": "user", "content": json_data['text']}],
|
||||
chatId=json_data['sessionId'],
|
||||
stream=True,
|
||||
detail=True,
|
||||
variables=chat_variables
|
||||
)
|
||||
logger.info(
|
||||
"FastGPT stream response opened "
|
||||
"Chat backend stream opened "
|
||||
f"sessionId={json_data['sessionId']} "
|
||||
f"open_latency_ms={(time.perf_counter() - stream_started_at) * 1000:.1f}"
|
||||
)
|
||||
@@ -279,41 +241,34 @@ async def chat(
|
||||
state_filter_buffer = ""
|
||||
return cleaned
|
||||
|
||||
async for event in aiter_stream_events(response):
|
||||
async for event in backend.stream(chat_input):
|
||||
try:
|
||||
if not first_event_logged:
|
||||
first_event_logged = True
|
||||
logger.info(
|
||||
"FastGPT stream first event "
|
||||
f"sessionId={json_data['sessionId']} kind={event.kind} "
|
||||
"Chat backend stream first event "
|
||||
f"sessionId={json_data['sessionId']} "
|
||||
f"kind={type(event).__name__} "
|
||||
f"ttfb_ms={(time.perf_counter() - stream_started_at) * 1000:.1f}"
|
||||
)
|
||||
|
||||
if event.kind == "flowResponses" and not module_form_sent:
|
||||
form_update = extract_form_update_from_flow_nodes(event.data)
|
||||
if form_update:
|
||||
form_update_payload = form_update
|
||||
if isinstance(event, FormUpdate) and not module_form_sent:
|
||||
if event.data:
|
||||
form_update_payload = event.data
|
||||
logger.info(
|
||||
"FastGPT stream formUpdate extracted "
|
||||
"Chat backend stream formUpdate received "
|
||||
f"sessionId={json_data['sessionId']} "
|
||||
f"type={type(form_update).__name__} "
|
||||
f"formUpdate={form_update!r}"
|
||||
f"type={type(event.data).__name__} "
|
||||
f"formUpdate={event.data!r}"
|
||||
)
|
||||
yield flush_form_update(form_update)
|
||||
yield flush_form_update(event.data)
|
||||
module_form_sent = True
|
||||
continue
|
||||
|
||||
if event.kind not in {"answer", "fastAnswer", "data"}:
|
||||
if not isinstance(event, TextDelta):
|
||||
continue
|
||||
|
||||
data = event.data
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
delta_content = data['choices'][0]['delta'].get('content', '')
|
||||
except (KeyError, IndexError):
|
||||
delta_content = ''
|
||||
delta_content = event.text
|
||||
if not delta_content:
|
||||
continue
|
||||
|
||||
@@ -400,23 +355,14 @@ async def chat(
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
try:
|
||||
# Use SDK's create_chat_completion
|
||||
response = await client.create_chat_completion(
|
||||
messages=[{"role": "user", "content": json_data['text']}],
|
||||
chatId=json_data['sessionId'],
|
||||
stream=False,
|
||||
detail=True,
|
||||
variables=chat_variables
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
result = await backend.complete(chat_input)
|
||||
logger.info(
|
||||
"FastGPT non-stream response received "
|
||||
"Chat backend non-stream response received "
|
||||
f"sessionId={json_data['sessionId']} "
|
||||
f"latency_ms={(time.perf_counter() - request_started_at) * 1000:.1f}"
|
||||
)
|
||||
|
||||
except AuthenticationError as e:
|
||||
except ChatBackendAuthenticationError as e:
|
||||
logger.error(f"Authentication error: {e}")
|
||||
return ProcessResponse_chat(
|
||||
sessionId=json_data['sessionId'],
|
||||
@@ -427,7 +373,7 @@ async def chat(
|
||||
code="401",
|
||||
msg="认证失败"
|
||||
)
|
||||
except RateLimitError as e:
|
||||
except ChatBackendRateLimitError as e:
|
||||
logger.error(f"Rate limit error: {e}")
|
||||
return ProcessResponse_chat(
|
||||
sessionId=json_data['sessionId'],
|
||||
@@ -438,7 +384,7 @@ async def chat(
|
||||
code="429",
|
||||
msg="请求过于频繁,请稍后重试"
|
||||
)
|
||||
except APIError as e:
|
||||
except ChatBackendAPIError as e:
|
||||
logger.error(f"API error: {e}")
|
||||
return ProcessResponse_chat(
|
||||
sessionId=json_data['sessionId'],
|
||||
@@ -462,39 +408,10 @@ async def chat(
|
||||
)
|
||||
|
||||
try:
|
||||
# Extract content from FastGPT response
|
||||
content = data['choices'][0]['message']['content']
|
||||
logger.info(f"FastGPT服务返回信息content: {content}")
|
||||
content = result.content
|
||||
logger.info(f"Chat backend returned content: {content}")
|
||||
|
||||
finish_reason = data['choices'][0]['finish_reason']
|
||||
|
||||
# Extract state variables
|
||||
state = data.get('newVariables', {}).get('state', {})
|
||||
if isinstance(state, str):
|
||||
state = json.loads(state)
|
||||
|
||||
transfer_to_human = state.get("transfer_to_human", False)
|
||||
ywrysw = state.get("ywrysw", False)
|
||||
ywfjdc = state.get("ywfjdc", False)
|
||||
ywmtc = state.get("ywmtc", False)
|
||||
jdcsl = state.get("jdcsl", 0)
|
||||
accident_info_complete = state.get("accident_info_complete", False)
|
||||
user_is_ready = state.get("user_is_ready", False)
|
||||
if isinstance(user_is_ready, str):
|
||||
user_is_ready = user_is_ready.lower() == 'true'
|
||||
driver_info_complete = state.get("driver_info_complete", False)
|
||||
drivers_info_complete = state.get("drivers_info_complete", False)
|
||||
driver_info_check = state.get("drivers_info_check", False)
|
||||
drivers_info_check = state.get("drivers_info_check", False)
|
||||
|
||||
logger.debug(f"State variables: {data.get('newVariables', {})}")
|
||||
|
||||
# Parse content - sometimes content is a string, sometimes it is a list
|
||||
content_stage_code = None
|
||||
if isinstance(content, list):
|
||||
logger.debug("content是一个list")
|
||||
content = content[0]['text']['content']
|
||||
|
||||
if isinstance(content, str):
|
||||
logger.debug("content是一个str")
|
||||
content_stage_code, content = extract_first_state_and_clean_content(content)
|
||||
@@ -509,10 +426,12 @@ async def chat(
|
||||
logger.error(f"content既不是list也不是str, type: {type(content)}")
|
||||
raise ValueError("大模型回复不是list也不是str")
|
||||
|
||||
nextStageCode = content_stage_code or data['newVariables']['status_code']
|
||||
nextStageCode = content_stage_code or result.status_code
|
||||
if not nextStageCode:
|
||||
raise ValueError("大模型回复中缺少state")
|
||||
nextStageCode = normalize_stage_code(nextStageCode)
|
||||
nextStage = STATUS_CODE_MAP.get(nextStageCode, '')
|
||||
form_update = extract_form_update_from_flow_nodes(data.get("responseData", []))
|
||||
form_update = result.form_update
|
||||
logger.info(
|
||||
"Chat non-stream completed "
|
||||
f"sessionId={json_data['sessionId']} "
|
||||
|
||||
27
src/backends/__init__.py
Normal file
27
src/backends/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Backend adapters used by the public API layer."""
|
||||
|
||||
from .chat import (
|
||||
ChatBackend,
|
||||
ChatBackendAPIError,
|
||||
ChatBackendAuthenticationError,
|
||||
ChatBackendError,
|
||||
ChatBackendRateLimitError,
|
||||
ChatInput,
|
||||
ChatResult,
|
||||
FormUpdate,
|
||||
TextDelta,
|
||||
)
|
||||
from .fastgpt import FastGPTBackend
|
||||
|
||||
__all__ = [
|
||||
"ChatBackend",
|
||||
"ChatBackendAPIError",
|
||||
"ChatBackendAuthenticationError",
|
||||
"ChatBackendError",
|
||||
"ChatBackendRateLimitError",
|
||||
"ChatInput",
|
||||
"ChatResult",
|
||||
"FastGPTBackend",
|
||||
"FormUpdate",
|
||||
"TextDelta",
|
||||
]
|
||||
72
src/backends/chat.py
Normal file
72
src/backends/chat.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Backend-neutral chat contract."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatInput:
|
||||
"""Input shared by all chat backend implementations."""
|
||||
|
||||
session_id: str
|
||||
text: str
|
||||
need_form_update: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextDelta:
|
||||
"""A piece of raw model text.
|
||||
|
||||
The text may contain a partial ``<state>...</state>`` prefix. Parsing that
|
||||
public protocol remains the responsibility of the FastAPI layer.
|
||||
"""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormUpdate:
|
||||
"""A structured form update produced alongside model text."""
|
||||
|
||||
data: Any
|
||||
|
||||
|
||||
ChatStreamEvent = TextDelta | FormUpdate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatResult:
|
||||
"""Backend-neutral result for a non-streaming chat request."""
|
||||
|
||||
content: str
|
||||
status_code: str | None = None
|
||||
form_update: Any = field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatBackend(Protocol):
|
||||
"""Contract implemented by FastGPT today and LangGraph later."""
|
||||
|
||||
def stream(self, chat_input: ChatInput) -> AsyncIterator[ChatStreamEvent]:
|
||||
"""Stream raw text and structured side-channel events."""
|
||||
...
|
||||
|
||||
async def complete(self, chat_input: ChatInput) -> ChatResult:
|
||||
"""Return one complete backend-neutral chat result."""
|
||||
...
|
||||
|
||||
|
||||
class ChatBackendError(Exception):
|
||||
"""Base error raised by a chat backend adapter."""
|
||||
|
||||
|
||||
class ChatBackendAuthenticationError(ChatBackendError):
|
||||
"""The backend rejected its configured credentials."""
|
||||
|
||||
|
||||
class ChatBackendRateLimitError(ChatBackendError):
|
||||
"""The backend rejected the request because of rate limiting."""
|
||||
|
||||
|
||||
class ChatBackendAPIError(ChatBackendError):
|
||||
"""The backend API failed or returned an invalid response."""
|
||||
148
src/backends/fastgpt.py
Normal file
148
src/backends/fastgpt.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""FastGPT implementation of the backend-neutral chat contract."""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from fastgpt_client import AsyncChatClient, aiter_stream_events
|
||||
from fastgpt_client.exceptions import APIError, AuthenticationError, RateLimitError
|
||||
|
||||
from .chat import (
|
||||
ChatBackendAPIError,
|
||||
ChatBackendAuthenticationError,
|
||||
ChatBackendRateLimitError,
|
||||
ChatInput,
|
||||
ChatResult,
|
||||
FormUpdate,
|
||||
TextDelta,
|
||||
)
|
||||
|
||||
|
||||
FORM_EXTRACT_MODULE_NAME = "文本内容提取事故信息"
|
||||
|
||||
|
||||
def _parse_json_value(value: Any) -> Any:
|
||||
parsed = value
|
||||
for _ in range(3):
|
||||
if not isinstance(parsed, str):
|
||||
return parsed
|
||||
parsed = parsed.strip()
|
||||
if not parsed:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(parsed)
|
||||
except json.JSONDecodeError:
|
||||
return parsed
|
||||
return parsed
|
||||
|
||||
|
||||
def _extract_form_update(nodes: Any) -> Any:
|
||||
if not isinstance(nodes, list):
|
||||
return {}
|
||||
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("moduleName") != FORM_EXTRACT_MODULE_NAME:
|
||||
continue
|
||||
|
||||
extract_result = node.get("extractResult", {})
|
||||
if not isinstance(extract_result, dict):
|
||||
return {}
|
||||
|
||||
form_update = extract_result.get("formUpdate", "")
|
||||
return _parse_json_value(form_update) if form_update else {}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_content(data: dict[str, Any]) -> str:
|
||||
try:
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise ChatBackendAPIError("FastGPT response is missing message content") from exc
|
||||
|
||||
if isinstance(content, list):
|
||||
try:
|
||||
content = content[0]["text"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise ChatBackendAPIError(
|
||||
"FastGPT response contains invalid list content"
|
||||
) from exc
|
||||
|
||||
if not isinstance(content, str):
|
||||
raise ChatBackendAPIError("FastGPT message content is not text")
|
||||
return content
|
||||
|
||||
|
||||
class FastGPTBackend:
|
||||
"""Translate FastGPT SDK calls and events into the neutral chat contract."""
|
||||
|
||||
def __init__(self, client: AsyncChatClient):
|
||||
self._client = client
|
||||
|
||||
async def stream(self, chat_input: ChatInput) -> AsyncIterator[TextDelta | FormUpdate]:
|
||||
try:
|
||||
response = await self._client.create_chat_completion(
|
||||
messages=[{"role": "user", "content": chat_input.text}],
|
||||
chatId=chat_input.session_id,
|
||||
stream=True,
|
||||
detail=True,
|
||||
variables={"needFormUpdate": chat_input.need_form_update},
|
||||
)
|
||||
|
||||
async for event in aiter_stream_events(response):
|
||||
if event.kind == "flowResponses":
|
||||
form_update = _extract_form_update(event.data)
|
||||
if form_update:
|
||||
yield FormUpdate(form_update)
|
||||
continue
|
||||
|
||||
if event.kind not in {"answer", "fastAnswer", "data"}:
|
||||
continue
|
||||
if not isinstance(event.data, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = event.data["choices"][0]["delta"].get("content", "")
|
||||
except (KeyError, IndexError, TypeError, AttributeError):
|
||||
content = ""
|
||||
if content:
|
||||
yield TextDelta(content)
|
||||
except AuthenticationError as exc:
|
||||
raise ChatBackendAuthenticationError(str(exc)) from exc
|
||||
except RateLimitError as exc:
|
||||
raise ChatBackendRateLimitError(str(exc)) from exc
|
||||
except APIError as exc:
|
||||
raise ChatBackendAPIError(str(exc)) from exc
|
||||
|
||||
async def complete(self, chat_input: ChatInput) -> ChatResult:
|
||||
try:
|
||||
response = await self._client.create_chat_completion(
|
||||
messages=[{"role": "user", "content": chat_input.text}],
|
||||
chatId=chat_input.session_id,
|
||||
stream=False,
|
||||
detail=True,
|
||||
variables={"needFormUpdate": chat_input.need_form_update},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except AuthenticationError as exc:
|
||||
raise ChatBackendAuthenticationError(str(exc)) from exc
|
||||
except RateLimitError as exc:
|
||||
raise ChatBackendRateLimitError(str(exc)) from exc
|
||||
except APIError as exc:
|
||||
raise ChatBackendAPIError(str(exc)) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ChatBackendAPIError("FastGPT response body is not an object")
|
||||
|
||||
status_code = data.get("newVariables", {}).get("status_code")
|
||||
if status_code is not None:
|
||||
status_code = str(status_code)
|
||||
|
||||
return ChatResult(
|
||||
content=_extract_content(data),
|
||||
status_code=status_code,
|
||||
form_update=_extract_form_update(data.get("responseData", [])),
|
||||
)
|
||||
@@ -1,17 +1,20 @@
|
||||
"""FastGPT client dependency injection."""
|
||||
"""FastGPT client and chat backend dependency injection."""
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastgpt_client import AsyncChatClient
|
||||
from ..backends.chat import ChatBackend
|
||||
from ..backends.fastgpt import FastGPTBackend
|
||||
from .config import Config
|
||||
|
||||
# Global client instance
|
||||
_fastgpt_client: AsyncChatClient | None = None
|
||||
_chat_backend: ChatBackend | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage FastGPT client lifecycle."""
|
||||
global _fastgpt_client
|
||||
global _chat_backend, _fastgpt_client
|
||||
Config.validate()
|
||||
|
||||
# Initialize client
|
||||
@@ -24,12 +27,15 @@ async def lifespan(app: FastAPI):
|
||||
enable_logging=Config.DEBUG,
|
||||
)
|
||||
await _fastgpt_client.__aenter__()
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
if _fastgpt_client:
|
||||
await _fastgpt_client.__aexit__(None, None, None)
|
||||
_chat_backend = FastGPTBackend(_fastgpt_client)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_chat_backend = None
|
||||
if _fastgpt_client:
|
||||
await _fastgpt_client.__aexit__(None, None, None)
|
||||
_fastgpt_client = None
|
||||
|
||||
|
||||
def get_fastgpt_client() -> AsyncChatClient:
|
||||
@@ -37,3 +43,10 @@ def get_fastgpt_client() -> AsyncChatClient:
|
||||
if _fastgpt_client is None:
|
||||
raise RuntimeError("FastGPT client not initialized")
|
||||
return _fastgpt_client
|
||||
|
||||
|
||||
def get_chat_backend() -> ChatBackend:
|
||||
"""Get the backend-neutral chat service."""
|
||||
if _chat_backend is None:
|
||||
raise RuntimeError("Chat backend not initialized")
|
||||
return _chat_backend
|
||||
|
||||
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