2026-02-24 05:19:38 +00:00
|
|
|
"""
|
|
|
|
|
Chat endpoint for AI Service.
|
2026-02-24 05:31:42 +00:00
|
|
|
[AC-AISVC-01, AC-AISVC-02, AC-AISVC-06, AC-AISVC-08, AC-AISVC-09] Main chat endpoint with streaming/non-streaming modes.
|
2026-02-24 05:19:38 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Annotated, Any
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Header, Request
|
|
|
|
|
from fastapi.responses import JSONResponse
|
2026-02-25 15:10:12 +00:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-02-28 04:52:50 +00:00
|
|
|
from sse_starlette.sse import EventSourceResponse
|
2026-02-24 05:19:38 +00:00
|
|
|
|
2026-02-25 15:10:12 +00:00
|
|
|
from app.core.database import get_session
|
2026-02-24 05:19:38 +00:00
|
|
|
from app.core.middleware import get_response_mode, is_sse_request
|
2026-02-24 05:31:42 +00:00
|
|
|
from app.core.sse import SSEStateMachine, create_error_event
|
2026-02-24 05:19:38 +00:00
|
|
|
from app.core.tenant import get_tenant_id
|
2026-02-28 04:52:50 +00:00
|
|
|
from app.models import ChatRequest, ErrorResponse
|
2026-02-25 15:10:12 +00:00
|
|
|
from app.services.memory import MemoryService
|
|
|
|
|
from app.services.orchestrator import OrchestratorService
|
2026-02-24 05:19:38 +00:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(tags=["AI Chat"])
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 15:10:12 +00:00
|
|
|
async def get_orchestrator_service_with_memory(
|
|
|
|
|
session: Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
|
) -> OrchestratorService:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-13] Create orchestrator service with memory service and LLM client.
|
|
|
|
|
Ensures each request has a fresh MemoryService with database session.
|
|
|
|
|
"""
|
|
|
|
|
from app.services.llm.factory import get_llm_config_manager
|
2026-02-25 15:45:34 +00:00
|
|
|
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-25 15:10:12 +00:00
|
|
|
memory_service = MemoryService(session)
|
|
|
|
|
llm_config_manager = get_llm_config_manager()
|
|
|
|
|
llm_client = llm_config_manager.get_client()
|
2026-02-25 15:45:34 +00:00
|
|
|
retriever = await get_optimized_retriever()
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-25 15:10:12 +00:00
|
|
|
return OrchestratorService(
|
|
|
|
|
llm_client=llm_client,
|
|
|
|
|
memory_service=memory_service,
|
|
|
|
|
retriever=retriever,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-24 05:19:38 +00:00
|
|
|
@router.post(
|
|
|
|
|
"/ai/chat",
|
|
|
|
|
operation_id="generateReply",
|
|
|
|
|
summary="Generate AI reply",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-AISVC-01, AC-AISVC-02, AC-AISVC-06] Generate AI reply based on user message.
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-24 05:19:38 +00:00
|
|
|
Response mode is determined by Accept header:
|
|
|
|
|
- Accept: text/event-stream -> SSE streaming response
|
|
|
|
|
- Other -> JSON response
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {
|
|
|
|
|
"description": "Success - JSON or SSE stream",
|
|
|
|
|
"content": {
|
|
|
|
|
"application/json": {"schema": {"$ref": "#/components/schemas/ChatResponse"}},
|
|
|
|
|
"text/event-stream": {"schema": {"type": "string"}},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
400: {"description": "Invalid request", "model": ErrorResponse},
|
|
|
|
|
500: {"description": "Internal error", "model": ErrorResponse},
|
|
|
|
|
503: {"description": "Service unavailable", "model": ErrorResponse},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def generate_reply(
|
|
|
|
|
request: Request,
|
|
|
|
|
chat_request: ChatRequest,
|
|
|
|
|
accept: Annotated[str | None, Header()] = None,
|
2026-02-25 15:10:12 +00:00
|
|
|
orchestrator: OrchestratorService = Depends(get_orchestrator_service_with_memory),
|
2026-02-24 05:19:38 +00:00
|
|
|
) -> Any:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-06] Generate AI reply with automatic response mode switching.
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-24 05:19:38 +00:00
|
|
|
Based on Accept header:
|
|
|
|
|
- text/event-stream: Returns SSE stream with message/final/error events
|
|
|
|
|
- Other: Returns JSON ChatResponse
|
|
|
|
|
"""
|
|
|
|
|
tenant_id = get_tenant_id()
|
|
|
|
|
if not tenant_id:
|
|
|
|
|
from app.core.exceptions import MissingTenantIdException
|
|
|
|
|
raise MissingTenantIdException()
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"[AC-AISVC-06] Processing chat request: tenant={tenant_id}, "
|
|
|
|
|
f"session={chat_request.session_id}, mode={get_response_mode(request)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if is_sse_request(request):
|
|
|
|
|
return await _handle_streaming_request(tenant_id, chat_request, orchestrator)
|
|
|
|
|
else:
|
|
|
|
|
return await _handle_json_request(tenant_id, chat_request, orchestrator)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_json_request(
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
chat_request: ChatRequest,
|
|
|
|
|
orchestrator: OrchestratorService,
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-02] Handle non-streaming JSON request.
|
|
|
|
|
Returns ChatResponse with reply, confidence, shouldTransfer.
|
|
|
|
|
"""
|
|
|
|
|
logger.info(f"[AC-AISVC-02] Processing JSON request for tenant={tenant_id}")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response = await orchestrator.generate(tenant_id, chat_request)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=response.model_dump(exclude_none=True, by_alias=True),
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"[AC-AISVC-04] Error generating response: {e}")
|
|
|
|
|
from app.core.exceptions import AIServiceException, ErrorCode
|
|
|
|
|
if isinstance(e, AIServiceException):
|
|
|
|
|
raise e
|
|
|
|
|
from app.core.exceptions import AIServiceException
|
|
|
|
|
raise AIServiceException(
|
|
|
|
|
code=ErrorCode.INTERNAL_ERROR,
|
|
|
|
|
message=str(e),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_streaming_request(
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
chat_request: ChatRequest,
|
|
|
|
|
orchestrator: OrchestratorService,
|
|
|
|
|
) -> EventSourceResponse:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-06, AC-AISVC-07, AC-AISVC-08, AC-AISVC-09] Handle SSE streaming request.
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-24 05:31:42 +00:00
|
|
|
SSE Event Sequence (per design.md Section 6.2):
|
|
|
|
|
- message* (0 or more) -> final (exactly 1) -> close
|
|
|
|
|
- OR message* (0 or more) -> error (exactly 1) -> close
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-24 05:31:42 +00:00
|
|
|
State machine ensures:
|
|
|
|
|
- No events after final/error
|
|
|
|
|
- Only one final OR one error event
|
|
|
|
|
- Proper connection close
|
2026-02-24 05:19:38 +00:00
|
|
|
"""
|
|
|
|
|
logger.info(f"[AC-AISVC-06] Processing SSE request for tenant={tenant_id}")
|
|
|
|
|
|
2026-02-24 05:31:42 +00:00
|
|
|
state_machine = SSEStateMachine()
|
|
|
|
|
|
2026-02-24 05:19:38 +00:00
|
|
|
async def event_generator():
|
2026-02-24 05:31:42 +00:00
|
|
|
"""
|
|
|
|
|
[AC-AISVC-08, AC-AISVC-09] Event generator with state machine enforcement.
|
|
|
|
|
Ensures proper event sequence and error handling.
|
|
|
|
|
"""
|
|
|
|
|
await state_machine.transition_to_streaming()
|
|
|
|
|
|
2026-02-24 05:19:38 +00:00
|
|
|
try:
|
|
|
|
|
async for event in orchestrator.generate_stream(tenant_id, chat_request):
|
2026-02-24 05:31:42 +00:00
|
|
|
if not state_machine.can_send_message():
|
|
|
|
|
logger.warning("[AC-AISVC-08] Received event after state machine closed, ignoring")
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if event.event == "final":
|
|
|
|
|
if await state_machine.transition_to_final():
|
|
|
|
|
logger.info("[AC-AISVC-08] Sending final event and closing stream")
|
|
|
|
|
yield event
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
elif event.event == "error":
|
|
|
|
|
if await state_machine.transition_to_error():
|
|
|
|
|
logger.info("[AC-AISVC-09] Sending error event and closing stream")
|
|
|
|
|
yield event
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
elif event.event == "message":
|
|
|
|
|
yield event
|
|
|
|
|
|
2026-02-24 05:19:38 +00:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"[AC-AISVC-09] Streaming error: {e}")
|
2026-02-24 05:31:42 +00:00
|
|
|
if await state_machine.transition_to_error():
|
|
|
|
|
yield create_error_event(
|
|
|
|
|
code="STREAMING_ERROR",
|
|
|
|
|
message=str(e),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
await state_machine.close()
|
|
|
|
|
logger.debug("[AC-AISVC-08] SSE connection closed")
|
2026-02-24 05:19:38 +00:00
|
|
|
|
|
|
|
|
return EventSourceResponse(event_generator(), ping=15)
|