添加 Copilot 聊天流式响应接口及测试
This commit is contained in:
@@ -8,9 +8,8 @@ from fastapi import APIRouter, Depends, Request, status
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.auth.dependencies import get_current_active_user
|
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.domain.schemas.user import UserInDB
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ def _sse_event(event: str, data: dict) -> str:
|
|||||||
async def copilot_chat_stream(
|
async def copilot_chat_stream(
|
||||||
payload: CopilotChatStreamRequest,
|
payload: CopilotChatStreamRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: UserInDB = Depends(get_current_active_user),
|
username: str = Depends(get_current_keycloak_username),
|
||||||
):
|
):
|
||||||
timeout = httpx.Timeout(
|
timeout = httpx.Timeout(
|
||||||
connect=10.0,
|
connect=10.0,
|
||||||
@@ -55,7 +54,7 @@ async def copilot_chat_stream(
|
|||||||
body = {
|
body = {
|
||||||
"message": payload.message,
|
"message": payload.message,
|
||||||
"conversationId": payload.conversation_id,
|
"conversationId": payload.conversation_id,
|
||||||
"userId": current_user.username,
|
"userId": username,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -312,23 +312,23 @@ async def valve_isolation_endpoint(
|
|||||||
- affected_nodes: 受影响的节点列表
|
- affected_nodes: 受影响的节点列表
|
||||||
- isolatable: 是否可以有效隔离
|
- isolatable: 是否可以有效隔离
|
||||||
"""
|
"""
|
||||||
result = {
|
# result = {
|
||||||
"accident_element": "P461309",
|
# "accident_element": "P461309",
|
||||||
"accident_elements": ["P461309"],
|
# "accident_elements": ["P461309"],
|
||||||
"affected_nodes": [
|
# "affected_nodes": [
|
||||||
"J316629_A",
|
# "J316629_A",
|
||||||
"J317037_B",
|
# "J317037_B",
|
||||||
"J317060_B",
|
# "J317060_B",
|
||||||
"J408189_B",
|
# "J408189_B",
|
||||||
"J499996",
|
# "J499996",
|
||||||
"J524940",
|
# "J524940",
|
||||||
"J535933",
|
# "J535933",
|
||||||
"J58841",
|
# "J58841",
|
||||||
],
|
# ],
|
||||||
"isolatable": True,
|
# "isolatable": True,
|
||||||
"must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
||||||
"optional_valves": [],
|
# "optional_valves": [],
|
||||||
}
|
# }
|
||||||
result = analyze_valve_isolation(network, accident_element, disabled_valves)
|
result = analyze_valve_isolation(network, accident_element, disabled_valves)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1.endpoints import (
|
from app.api.v1.endpoints import (
|
||||||
auth,
|
auth,
|
||||||
|
copilot,
|
||||||
project,
|
project,
|
||||||
simulation,
|
simulation,
|
||||||
scada,
|
scada,
|
||||||
@@ -18,7 +19,6 @@ from app.api.v1.endpoints import (
|
|||||||
user_management, # 新增:用户管理
|
user_management, # 新增:用户管理
|
||||||
audit, # 新增:审计日志
|
audit, # 新增:审计日志
|
||||||
meta,
|
meta,
|
||||||
copilot_chat,
|
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.network import (
|
from app.api.v1.endpoints.network import (
|
||||||
general,
|
general,
|
||||||
@@ -113,4 +113,4 @@ api_router.include_router(project_data.router, tags=["Project Data"])
|
|||||||
api_router.include_router(extension.router, tags=["Extension"])
|
api_router.include_router(extension.router, tags=["Extension"])
|
||||||
|
|
||||||
# Copilot Chat
|
# Copilot Chat
|
||||||
api_router.include_router(copilot_chat.router, prefix="/copilot", tags=["Copilot"])
|
api_router.include_router(copilot.router, prefix="/copilot", tags=["Copilot"])
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
"/meta/projects",
|
"/meta/projects",
|
||||||
"/api/v1/openproject/",
|
"/api/v1/openproject/",
|
||||||
"/openproject/",
|
"/openproject/",
|
||||||
|
"/api/v1/copilot/chat/",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ app = FastAPI(title="TJWater Copilot Python Sidecar")
|
|||||||
client: Optional[CopilotClient] = None
|
client: Optional[CopilotClient] = None
|
||||||
sessions: dict[str, SessionHolder] = {}
|
sessions: dict[str, SessionHolder] = {}
|
||||||
session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800"))
|
session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800"))
|
||||||
model = os.getenv("COPILOT_MODEL", "gpt-5.1-codex")
|
model = os.getenv("COPILOT_MODEL", "gpt-5.3-codex")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1.endpoints import copilot as copilot_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStreamResponse:
|
||||||
|
def __init__(self, status_code: int, lines: list[str] | None = None, body: bytes = b""):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._lines = lines or []
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def aread(self) -> bytes:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
async def aiter_lines(self):
|
||||||
|
for line in self._lines:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncClient:
|
||||||
|
response: _FakeStreamResponse
|
||||||
|
captured: dict
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._kwargs = kwargs
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stream(self, method: str, url: str, json: dict, headers: dict):
|
||||||
|
_FakeAsyncClient.captured = {
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"json": json,
|
||||||
|
"headers": headers,
|
||||||
|
"client_kwargs": self._kwargs,
|
||||||
|
}
|
||||||
|
return _FakeAsyncClient.response
|
||||||
|
|
||||||
|
|
||||||
|
def _build_client(monkeypatch) -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(copilot_endpoint.router, prefix="/api/v1/copilot")
|
||||||
|
app.dependency_overrides[copilot_endpoint.get_current_keycloak_username] = (
|
||||||
|
lambda: "tester"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(copilot_endpoint.httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_forwards_auth_and_payload(monkeypatch):
|
||||||
|
_FakeAsyncClient.response = _FakeStreamResponse(
|
||||||
|
status_code=200,
|
||||||
|
lines=[
|
||||||
|
'event: token',
|
||||||
|
'data: {"conversationId":"c1","content":"hello"}',
|
||||||
|
"",
|
||||||
|
'event: done',
|
||||||
|
'data: {"conversationId":"c1"}',
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
client = _build_client(monkeypatch)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/copilot/chat/stream",
|
||||||
|
json={"message": "hi", "conversation_id": "conv-1"},
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer keycloak-token",
|
||||||
|
"X-Project-Id": "project-a",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/event-stream" in response.headers["content-type"]
|
||||||
|
assert "event: token" in response.text
|
||||||
|
assert "event: done" in response.text
|
||||||
|
|
||||||
|
captured = _FakeAsyncClient.captured
|
||||||
|
assert captured["method"] == "POST"
|
||||||
|
assert captured["url"].endswith("/chat/stream")
|
||||||
|
assert captured["headers"]["authorization"] == "Bearer keycloak-token"
|
||||||
|
assert captured["headers"]["x-project-id"] == "project-a"
|
||||||
|
assert captured["json"] == {
|
||||||
|
"message": "hi",
|
||||||
|
"conversationId": "conv-1",
|
||||||
|
"userId": "tester",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_emits_error_event_when_upstream_fails(monkeypatch):
|
||||||
|
_FakeAsyncClient.response = _FakeStreamResponse(
|
||||||
|
status_code=401,
|
||||||
|
body=b"upstream unauthorized",
|
||||||
|
)
|
||||||
|
client = _build_client(monkeypatch)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/copilot/chat/stream",
|
||||||
|
json={"message": "hi"},
|
||||||
|
headers={"Authorization": "Bearer keycloak-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "event: error" in response.text
|
||||||
|
assert "Copilot sidecar request failed" in response.text
|
||||||
|
assert '"status": 401' in response.text
|
||||||
Reference in New Issue
Block a user