"""이드 채팅 표면 — POST /api/eid/chat (eid-chat 트랙). 확정 결정: - D-1 경로 = /api/eid/chat (main.py prefix=/api/eid + 본 라우터 POST /chat) - D-2 mode 닫힌 어휘: daily(mac-mini-default) / deep(qwen-macbook). 클라는 mode 만 보냄 — claude-cloud / auto 금지 (Literal 로 422 차단). 심층(deep) 모드 무게이트. - D-3 독립 /chat 라우트 (frontend) — 본 모듈은 백엔드 API 만. - D-5 LLM 호출 = EidAIClient.call_stream 한 곳 (이드 egress 봉쇄 불변식 #5, RouterBackend 직접 호출 금지). - D-6 rules.md 부재 = 503 substrate_degraded fail-closed — 다른 표면의 degraded 배너 컨벤션(compose._rules)과 달리 채팅은 진행 자체를 거부. 응답 = router SSE 라인 단위 중계 (text/event-stream — call_stream 이 model 필드를 mode 어휘로 치환·usage 제거, 프레이밍 보존. 본 모듈은 무변형 relay). 스트림 시작 전 backend 실패는 /api/search/ask 와 동일 shape 의 503 + error_reason 매핑(자동 fallback 0). 로그는 메타 1줄(mode·턴수·status)만 — 대화 본문 로깅 0. """ from __future__ import annotations from typing import Annotated, Literal import httpx from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field, field_validator, model_validator from core.auth import get_current_user from core.utils import setup_logger from eid import compose as eid_compose from eid.ai import EidAIClient from models.user import User from services.llm.backends import BackendUnavailable logger = setup_logger("eid_chat") router = APIRouter() class ChatMessage(BaseModel): """채팅 턴 1건. role=system 은 Literal 밖 → 422 (system 합본은 서버 compose 만 주입).""" role: Literal["user", "assistant"] content: str = Field(min_length=1, max_length=8000) # 대화 총량 cap (전 메시지 content 합) — per-message 8000·40턴 제한과 별도의 총량 상한 _TOTAL_CONTENT_CAP = 32000 class ChatRequest(BaseModel): """POST /api/eid/chat body. mode 는 닫힌 어휘(D-2), messages 는 1~40턴 + 총량 32000자.""" mode: Literal["daily", "deep"] messages: list[ChatMessage] = Field(min_length=1, max_length=40) @field_validator("messages") @classmethod def _last_turn_is_user(cls, v: list[ChatMessage]) -> list[ChatMessage]: if v and v[-1].role != "user": raise ValueError("마지막 메시지는 role=user 여야 합니다") return v @model_validator(mode="after") def _total_content_cap(self) -> "ChatRequest": if sum(len(m.content) for m in self.messages) > _TOTAL_CONTENT_CAP: raise ValueError( "대화 총량 초과 — 새 대화로 시작하거나 입력을 줄여주세요 " f"(전체 메시지 합 {_TOTAL_CONTENT_CAP}자 제한)" ) return self @router.post("/chat") async def eid_chat( body: ChatRequest, user: Annotated[User, Depends(get_current_user)], ): """이드 채팅 — router SSE 스트리밍 pass-through. 503 두 경로 (둘 다 자동 fallback 없음): - substrate_degraded: rules.md 부재 (D-6 fail-closed, 채팅 진행 거부) - backend_unavailable: 스트림 시작 전 backend 실패 (ask 컨벤션과 동일 shape) """ # D-6: rules 부재 = fail-closed. 채팅은 안전·정책 가드 없이 진행하지 않는다(배너 X). if not eid_compose.rules_present(): logger.error( "eid_chat substrate_degraded mode=%s turns=%d status=503 — rules.md 부재, 채팅 거부", body.mode, len(body.messages), ) return JSONResponse( status_code=503, content={ "detail": ( "이드 substrate 가 degraded 상태입니다 (운영 규칙 rules.md 부재). " "복구 전까지 채팅을 진행하지 않습니다." ), "error_reason": "substrate_degraded", }, ) system = eid_compose.compose("eid_chat", task="") client = EidAIClient() stream = client.call_stream( body.mode, [m.model_dump() for m in body.messages], system, ) # async generator 는 첫 __anext__ 에서야 실제 요청 전송 — 스트림 시작 전 실패(연결/4xx/5xx) # 를 503 으로 매핑하기 위해 첫 chunk 를 여기서 먼저 당긴다. try: first = await anext(stream, None) except BackendUnavailable as exc: logger.warning( "eid_chat backend_unavailable mode=%s turns=%d status=503 reason=%s", body.mode, len(body.messages), exc.reason, ) await client.close() return JSONResponse( status_code=503, content={ "error": "backend_unavailable", "error_reason": exc.reason, "backend_requested": exc.backend_name, "detail": ( "선택한 모드의 backend 가 일시적으로 응답할 수 없습니다. " "잠시 후 다시 시도하거나 mode 를 바꿔 호출하세요." ), }, ) except BaseException: await client.close() raise # 메타 로그 1줄 — 본문 로깅 0 (대화 내용은 어디에도 남기지 않는다) logger.info( "eid_chat stream mode=%s turns=%d status=200", body.mode, len(body.messages) ) async def _passthrough(): # call_stream 방출분 무변형 relay (정화는 call_stream 라인 단위 한 곳). 취소· # disconnect 포함 finally 에서 generator aclose → AsyncExitStack 이 upstream 정리. try: try: if first is not None: yield first async for chunk in stream: yield chunk except (BackendUnavailable, httpx.HTTPError) as exc: # 스트림 시작 후 절단 — status 200 은 이미 송신돼 재매핑 불가. 메타 로그 # 1줄만 남기고 조용히 종료(traceback 전파 0) — 프론트는 [DONE] 부재로 처리. logger.warning( "eid_chat stream aborted mode=%s turns=%d reason=%s", body.mode, len(body.messages), getattr(exc, "reason", type(exc).__name__), ) return finally: # stream.aclose() 가 예외여도 client.close() 는 보장 (중첩 finally) try: await stream.aclose() finally: await client.close() return StreamingResponse( _passthrough(), media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, )