From 250896cdfa03db0976a05b30b71b0e5a44e5acae Mon Sep 17 00:00:00 2001 From: hyungi Date: Thu, 11 Jun 2026 14:51:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(eid):=20deep=20=EB=AA=A8=EB=93=9C=20=3D=20?= =?UTF-8?q?ReAct=20=EC=9E=90=EB=8F=99=EA=B2=80=EC=83=89=20+=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EC=B9=B4=EB=93=9C=20(ds-eid-ask-absorb=20P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deep 분기 _eid_chat_deep: 비생성 probe → phase:searching → agentic_ask_loop (tool_choice=auto 가 검색 여부 자율 판단, 검색 불요는 early-exit 대화) → final_answer + eid_sources envelope → DONE. heartbeat {phase:ping}(~10s, 프록시 idle timeout 차단) · mid-stream BackendUnavailable → in-stream error envelope · disconnect 시 task.cancel() + await(고아화·27B 점유 방지). - daily = call_stream 무변경(맥미니 대화). deep = 맥북 27B ReAct (tool calling 27B 전용, 맥미니 26B token-leak 미검증). 멀티턴 = 메시지 단독 처리(agentic_ask_loop query: str, history 2단계 백로그). - EidEvidenceCard.svelte 접이식 근거 카드(sources 순서번호·제목·점수) + 프론트 SSE 파서 확장(ping/searching/error/eid_sources) + 검색 중 표시 + 이력 보존. - 테스트: deep 4건(검색성/대화성/probe-503/mid-stream-error) + 기존 call_stream 회귀 daily 로 이전 = 29 passed. - 동반(이전 eid-chat 세션 미커밋): /api/eid/status endpoint + llm_gate.gate_status + test_eid_status (채팅 대기 UI 의 '대기 vs 고장' 구분용, 5 passed). Co-Authored-By: Claude Fable 5 --- app/api/eid_chat.py | 161 +++++++++++++- app/services/search/llm_gate.py | 8 + .../lib/components/eid/EidEvidenceCard.svelte | 31 +++ frontend/src/routes/chat/+page.svelte | 204 +++++++++++++++++- tests/eid/test_eid_chat_deep.py | 152 +++++++++++++ tests/eid/test_eid_chat_endpoint.py | 8 +- tests/eid/test_eid_status_endpoint.py | 112 ++++++++++ 7 files changed, 660 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/components/eid/EidEvidenceCard.svelte create mode 100644 tests/eid/test_eid_chat_deep.py create mode 100644 tests/eid/test_eid_status_endpoint.py diff --git a/app/api/eid_chat.py b/app/api/eid_chat.py index 1f0543f..4b4cf77 100644 --- a/app/api/eid_chat.py +++ b/app/api/eid_chat.py @@ -18,24 +18,58 @@ backend 실패는 /api/search/ask 와 동일 shape 의 503 + error_reason 매핑 from __future__ import annotations +import asyncio +import json +from collections.abc import AsyncIterator 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 sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user +from core.database import get_session 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 +from services.llm.backends import BackendUnavailable, _router_url, get_backend +from services.search import llm_gate +from services.search.react_loop import agentic_ask_loop logger = setup_logger("eid_chat") router = APIRouter() +# ── ds-eid-ask-absorb P1: deep 모드 = ReAct 자동검색 (qwen-macbook 27B) ── +# 비생성 reachability probe — router 도달만 확인(coarse). 27B(맥북) 자체 미가용은 +# 첫 generate_with_tools 호출의 BackendUnavailable → mid-stream error envelope 로 커버 +# (plan: probe 정밀도 불필요, TOCTOU 는 in-stream error 가 처리). ~2s 타임아웃·생성 슬롯 비점유. +_DEEP_PROBE_TIMEOUT = httpx.Timeout(connect=2.0, read=2.0, write=2.0, pool=2.0) +# heartbeat: ReAct 다회 tool call 시 수십초 무출력 → 프록시 idle timeout 차단. +# `{"phase":"ping"}` no-op 이벤트 (프론트 envelope 파서가 자연 스킵 — `: ping` comment 는 +# POST SSE fetch 파서가 처리 보장 안 됨). +_HEARTBEAT_INTERVAL_S = 10.0 + + +async def _probe_router_reachable() -> bool: + """router(:8890) /v1/models GET — 도달 확인(비생성). 실패/비200 = 미가용.""" + url = f"{_router_url().rstrip('/')}/v1/models" + try: + async with httpx.AsyncClient(timeout=_DEEP_PROBE_TIMEOUT) as client: + resp = await client.get(url) + return resp.status_code == 200 + except Exception: + return False + + +def _sse(obj: dict) -> bytes: + """SSE 이벤트 1건 — data: \\n\\n. final_answer 는 OpenAI 호환 choices.delta.content + 로, sources/phase 는 별 envelope 키로(프론트가 분기). model/usage 머신 메타 미포함.""" + return b"data: " + json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n\n" + class ChatMessage(BaseModel): """채팅 턴 1건. role=system 은 Literal 밖 → 422 (system 합본은 서버 compose 만 주입).""" @@ -71,16 +105,130 @@ class ChatRequest(BaseModel): return self +@router.get("/status") +async def eid_status( + user: Annotated[User, Depends(get_current_user)], +): + """이드 backend 점유 상태 스냅샷 — GET /api/eid/status (UI 의 "대기 vs 고장" 구분용). + + daily(맥미니 MLX) 의 DS 프로세스 내부 llm_gate 점유만 본다 — 외부 소비자 + (맥미니 자체 derived-worker·Hermes 등)의 endpoint 점유는 미포착. + 따라서 busy=true 는 확실(지금 줄이 있다), false 는 근사(외부 점유 가능성 잔존). + + 가벼움 보장: DB 0 / LLM 0 / 본문 로깅 0 — 폴링 대상으로 안전. + 자동 fallback 판단 근거로 쓰지 않는다 (모드 전환 = 명시 버튼만, 정책). + """ + snap = llm_gate.gate_status() + inflight = bool(snap["inflight"]) + waiters = int(snap["waiters"]) + return { + "daily": { + "busy": inflight or waiters > 0, + "inflight": inflight, + "waiters": waiters, + } + } + + +def _backend_unavailable_response(body: ChatRequest, reason: str, backend_name: str) -> JSONResponse: + """스트림 시작 전 27B 미가용 → ask 컨벤션과 동일 shape 503 (자동 fallback 0).""" + logger.warning( + "eid_chat backend_unavailable mode=%s turns=%d status=503 reason=%s", + body.mode, len(body.messages), reason, + ) + return JSONResponse( + status_code=503, + content={ + "error": "backend_unavailable", + "error_reason": reason, + "backend_requested": backend_name, + "detail": ( + "심층 엔진(검색)이 일시적으로 응답할 수 없습니다. " + "잠시 후 다시 시도하거나 일상 모드로 물어보세요." + ), + }, + ) + + +async def _eid_chat_deep(body: ChatRequest, session: AsyncSession) -> StreamingResponse | JSONResponse: + """deep 모드 = ReAct 자동검색. ReAct(`tool_choice=auto`)가 검색 여부를 LLM 자율 판단 — + 검색 불요 질문은 early-exit 으로 대화 답변. substrate(persona+rules+react_ask task)는 + agentic_ask_loop 내부 compose("react_ask") 가 주입(evidence-first 자동 상속). + + 멀티턴 = 1단계는 마지막 user 메시지 단독 처리(agentic_ask_loop 가 query: str — history + 미지원). 후속 질문 대명사 해소는 2단계 백로그. + """ + # ① 첫 SSE 바이트(=HTTP 200 확정) 전 비생성 probe — router 도달 실패 시 503 (재매핑 가능 구간) + if not await _probe_router_reachable(): + return _backend_unavailable_response(body, "macbook_unavailable", "qwen-macbook") + + query = body.messages[-1].content # 메시지 단독 처리 (마지막 user 턴) + backend = get_backend("qwen-macbook") + + async def _stream() -> AsyncIterator[bytes]: + # ② phase:searching 방출 = HTTP 200 확정. 이후 미가용은 503 불가 → in-stream error. + yield _sse({"phase": "searching"}) + task = asyncio.create_task(agentic_ask_loop(session, query, backend=backend)) + try: + # heartbeat: task 미완 동안 ~10s 마다 ping (shield 로 wait_for 취소가 task 안 죽임) + while not task.done(): + try: + await asyncio.wait_for(asyncio.shield(task), timeout=_HEARTBEAT_INTERVAL_S) + except asyncio.TimeoutError: + yield _sse({"phase": "ping"}) + result = task.result() # BackendUnavailable 은 여기서 raise (mid-stream) + # final_answer = OpenAI 호환 1청크(프론트 기존 content 누적 경로 재사용) + yield _sse({"choices": [{"delta": {"content": result.final_answer}}]}) + # 근거 = 별 envelope (citation 번호 없음 — 프론트가 순서 기반). partial = 근거 부족 표식 + yield _sse({"eid_sources": result.sources, "partial": result.partial}) + yield b"data: [DONE]\n\n" + logger.info( + "eid_chat deep ok turns=%d sources=%d partial=%s iters=%d", + len(body.messages), len(result.sources), result.partial, result.iterations, + ) + except BackendUnavailable as exc: + # mid-stream 미가용(검색 중 AC 분리·뚜껑 닫힘) — 200 이미 송신, in-stream error envelope. + # error 뒤 [DONE] = 프론트 sawDone 로 '중단' 오경보 방지(명시 error notice 유지). + logger.warning( + "eid_chat deep mid-stream unavailable turns=%d reason=%s", + len(body.messages), exc.reason, + ) + yield _sse({"phase": "error", "error_reason": exc.reason}) + yield b"data: [DONE]\n\n" + except asyncio.CancelledError: + raise # 클라 disconnect — finally 가 task 정리 + except Exception: + logger.exception("eid_chat deep stream failed turns=%d", len(body.messages)) + yield _sse({"phase": "error", "error_reason": "deep_failed"}) + yield b"data: [DONE]\n\n" + finally: + # 클라 disconnect 시 ReAct task 고아화 방지 — cancel + await(전파 완료 보장). + # 안 하면 27B 가 닫힌 연결 위해 수분 점유, router 동시성상 다음 검색 대기. + if not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + return StreamingResponse( + _stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + + @router.post("/chat") async def eid_chat( body: ChatRequest, user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], ): - """이드 채팅 — router SSE 스트리밍 pass-through. + """이드 채팅 — daily = router SSE pass-through(대화) / deep = ReAct 자동검색(근거). - 503 두 경로 (둘 다 자동 fallback 없음): + 503 경로 (모두 자동 fallback 없음): - substrate_degraded: rules.md 부재 (D-6 fail-closed, 채팅 진행 거부) - - backend_unavailable: 스트림 시작 전 backend 실패 (ask 컨벤션과 동일 shape) + - backend_unavailable: 스트림 시작 전 backend 실패 (daily/deep 공통, ask 컨벤션 shape) """ # D-6: rules 부재 = fail-closed. 채팅은 안전·정책 가드 없이 진행하지 않는다(배너 X). if not eid_compose.rules_present(): @@ -99,6 +247,11 @@ async def eid_chat( }, ) + # deep = ReAct 자동검색 (별 흐름 — probe + 동기 ReAct → SSE 변환) + if body.mode == "deep": + return await _eid_chat_deep(body, session) + + # daily = 순수 대화 SSE pass-through (기존) system = eid_compose.compose("eid_chat", task="") client = EidAIClient() stream = client.call_stream( diff --git a/app/services/search/llm_gate.py b/app/services/search/llm_gate.py index 561b14a..7e4add0 100644 --- a/app/services/search/llm_gate.py +++ b/app/services/search/llm_gate.py @@ -222,6 +222,14 @@ def get_mlx_gate(): return acquire_mlx_gate(DEFAULT_PRIORITY) +# ── Read-only status (UI 표시용) ───────────────────────────────────────────── + + +def gate_status() -> dict: + """현재 gate 점유 스냅샷 (read-only, lock-free 근사치 — UI 표시용).""" + return {"inflight": _inflight, "waiters": len(_waiters)} + + # ── Test helpers (conftest reset) ──────────────────────────────────────────── diff --git a/frontend/src/lib/components/eid/EidEvidenceCard.svelte b/frontend/src/lib/components/eid/EidEvidenceCard.svelte new file mode 100644 index 0000000..8474389 --- /dev/null +++ b/frontend/src/lib/components/eid/EidEvidenceCard.svelte @@ -0,0 +1,31 @@ + + + +{#if sources.length} +
+ + 근거 {sources.length}개{partial ? ' · 부분 답변 (확정 근거 부족)' : ''} + +
    + {#each sources as src, i (src.id ?? i)} +
  • + [{i + 1}] + {src.title || `문서 ${src.doc_id ?? '?'}`} + {#if typeof src.score === 'number'} + {src.score.toFixed(2)} + {/if} +
  • + {/each} +
+
+{/if} diff --git a/frontend/src/routes/chat/+page.svelte b/frontend/src/routes/chat/+page.svelte index 4b5a8b4..775d620 100644 --- a/frontend/src/routes/chat/+page.svelte +++ b/frontend/src/routes/chat/+page.svelte @@ -17,6 +17,12 @@ macbook_unavailable / substrate_degraded / 기타 detail). 자동 fallback 금지 — 다른 모드로 자동 전환하지 않는다. 스트림 도중 중단 = 받은 부분 유지 + 표시. + - 대기 표시(첫 바이트 전): 경과 타이머 1초 갱신 + 3초 후 GET /api/eid/status + 1회·이후 8초 간격 재조회(실패는 조용히 무시 — 기능 비차단)로 "대기"와 + "고장"을 정직하게 구분. daily.busy=true 면 줄 서는 중 안내. 15초 경과 + + daily 모드면 [심층으로 전환]/[취소] 버튼 노출 — 전환은 명시 클릭만 + (자동 fallback 금지 정책 위반 아님). 첫 바이트 도착/스트림 종료 시 + 타이머·폴링 즉시 정리. - 이력: localStorage `eid_chat:v1` (키 상수는 $lib/eidChat — logout 시 제거와 공유). 전송 payload 는 마지막 20턴(40 messages) cap. - 입력 한도: 메시지당 8,000자 클라 선차단(서버 422 검증과 동일 한도). @@ -25,15 +31,25 @@ --> @@ -473,25 +647,35 @@ {:else} -
+
{msg.content}
+ {#if msg.sources?.length} + + {/if}
{/if} {/each} - + {#if streaming}
{#if streamingText} {streamingText} {:else} - 응답 준비 중... + {waitPlaceholder} {/if}
+ + {#if showWaitActions} +
+ + +
+ {/if} {/if} diff --git a/tests/eid/test_eid_chat_deep.py b/tests/eid/test_eid_chat_deep.py new file mode 100644 index 0000000..7ca89be --- /dev/null +++ b/tests/eid/test_eid_chat_deep.py @@ -0,0 +1,152 @@ +"""POST /api/eid/chat mode=deep — ReAct 자동검색 SSE 변환 (ds-eid-ask-absorb P1). + +★ DB·LLM 0: get_session/get_current_user dependency override, probe·agentic_ask_loop· + get_backend monkeypatch. 실제 검색·27B 호출 없음. +★ 검증: 검색성→phase:searching+content+eid_sources+DONE / probe 실패→503 / + mid-stream BackendUnavailable→in-stream error envelope / 대화성→sources 빈. +""" + +from __future__ import annotations + +import json +import sys +import types +from pathlib import Path + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "app")) + +import api.eid_chat as eid_chat # noqa: E402 +from api.eid_chat import router as eid_chat_router # noqa: E402 +from core.auth import get_current_user # noqa: E402 +from core.database import get_session # noqa: E402 +from services.llm.backends import BackendUnavailable # noqa: E402 +from services.search.react_loop import ReactResult # noqa: E402 + +_DEEP = {"mode": "deep", "messages": [{"role": "user", "content": "콜드박스 위험성평가 찾아줘"}]} + + +async def _async_true() -> bool: + return True + + +async def _async_false() -> bool: + return False + + +def _build_app() -> FastAPI: + app = FastAPI() + app.include_router(eid_chat_router, prefix="/api/eid") + app.dependency_overrides[get_current_user] = lambda: types.SimpleNamespace( + id=1, username="test-user" + ) + + async def _fake_session(): + yield None # deep 경로는 session 을 agentic_ask_loop 에 넘기기만(여기선 monkeypatch) + + app.dependency_overrides[get_session] = _fake_session + return app + + +def _data_objs(raw: bytes) -> list[dict]: + out: list[dict] = [] + for line in raw.split(b"\n"): + if line.startswith(b"data: ") and line[len(b"data: "):].strip() != b"[DONE]": + try: + out.append(json.loads(line[len(b"data: "):])) + except Exception: + pass + return out + + +@pytest_asyncio.fixture +async def client(): + async with AsyncClient( + transport=ASGITransport(app=_build_app()), base_url="http://test" + ) as ac: + yield ac + + +@pytest.fixture(autouse=True) +def _rules_present(monkeypatch): + # D-6 fail-closed 가드 통과 (substrate degraded 아님) + monkeypatch.setattr(eid_chat.eid_compose, "rules_present", lambda: True) + + +@pytest.mark.asyncio +async def test_deep_search_sse_shape(client, monkeypatch): + """검색성 질문 → phase:searching + final content + eid_sources + DONE 순서.""" + monkeypatch.setattr(eid_chat, "_probe_router_reachable", _async_true) + monkeypatch.setattr(eid_chat, "get_backend", lambda name: object()) + + async def _fake_loop(session, query, *, backend, **kw): + return ReactResult( + final_answer="콜드박스 위험성평가는 TK-RA-2026-OT1-01 입니다.", + iterations=1, + partial=False, + sources=[{"id": 1, "doc_id": 10, "title": "OT1 콜드박스 위험성평가", "score": 0.91}], + ) + + monkeypatch.setattr(eid_chat, "agentic_ask_loop", _fake_loop) + + r = await client.post("/api/eid/chat", json=_DEEP) + assert r.status_code == 200 + objs = _data_objs(r.content) + assert "searching" in [o.get("phase") for o in objs if "phase" in o] + content = "".join( + o["choices"][0]["delta"]["content"] for o in objs if "choices" in o + ) + assert "OT1-01" in content + srcs = [o["eid_sources"] for o in objs if "eid_sources" in o] + assert srcs and srcs[0][0]["title"] == "OT1 콜드박스 위험성평가" + assert b"data: [DONE]" in r.content + + +@pytest.mark.asyncio +async def test_deep_conversational_no_sources(client, monkeypatch): + """대화성(검색 불요) → ReAct early-exit, sources 빈 배열.""" + monkeypatch.setattr(eid_chat, "_probe_router_reachable", _async_true) + monkeypatch.setattr(eid_chat, "get_backend", lambda name: object()) + + async def _chat_loop(session, query, *, backend, **kw): + return ReactResult(final_answer="안녕하세요, 이드입니다.", iterations=1, partial=False, sources=[]) + + monkeypatch.setattr(eid_chat, "agentic_ask_loop", _chat_loop) + + r = await client.post("/api/eid/chat", json=_DEEP) + assert r.status_code == 200 + objs = _data_objs(r.content) + srcs = [o["eid_sources"] for o in objs if "eid_sources" in o] + assert srcs and srcs[0] == [] # 검색 안 함 = 근거 카드 안 뜸 + + +@pytest.mark.asyncio +async def test_deep_probe_fail_503(client, monkeypatch): + """probe 실패(router 미도달) → 첫 바이트 전 503 macbook_unavailable.""" + monkeypatch.setattr(eid_chat, "_probe_router_reachable", _async_false) + r = await client.post("/api/eid/chat", json=_DEEP) + assert r.status_code == 503 + assert r.json()["error_reason"] == "macbook_unavailable" + + +@pytest.mark.asyncio +async def test_deep_midstream_error_envelope(client, monkeypatch): + """검색 중 BackendUnavailable(AC 분리 등) → in-stream error envelope + DONE.""" + monkeypatch.setattr(eid_chat, "_probe_router_reachable", _async_true) + monkeypatch.setattr(eid_chat, "get_backend", lambda name: object()) + + async def _fail_loop(session, query, *, backend, **kw): + raise BackendUnavailable("qwen-macbook", "macbook_unavailable") + + monkeypatch.setattr(eid_chat, "agentic_ask_loop", _fail_loop) + + r = await client.post("/api/eid/chat", json=_DEEP) + assert r.status_code == 200 # 스트림 이미 시작(probe 통과) → 200 + in-stream error + objs = _data_objs(r.content) + errs = [o for o in objs if o.get("phase") == "error"] + assert errs and errs[0]["error_reason"] == "macbook_unavailable" + assert b"data: [DONE]" in r.content diff --git a/tests/eid/test_eid_chat_endpoint.py b/tests/eid/test_eid_chat_endpoint.py index 0b27c98..90ebfc9 100644 --- a/tests/eid/test_eid_chat_endpoint.py +++ b/tests/eid/test_eid_chat_endpoint.py @@ -131,6 +131,8 @@ async def test_503_substrate_degraded(client, monkeypatch): @pytest.mark.asyncio async def test_503_backend_unavailable_prestream(client, monkeypatch): + # call_stream 회귀(prestream 503)는 daily 로 검증 — deep 은 이제 ReAct 별 경로 + # (probe·agentic_ask_loop), deep 의 503/midstream 은 test_eid_chat_deep.py 가 커버. async def fake_call_stream(self, mode, messages, system): raise BackendUnavailable("qwen-macbook", "macbook_unavailable") yield b"" # pragma: no cover — async generator 형태 유지용 @@ -138,7 +140,7 @@ async def test_503_backend_unavailable_prestream(client, monkeypatch): monkeypatch.setattr(EidAIClient, "call_stream", fake_call_stream) r = await client.post( "/api/eid/chat", - json={"mode": "deep", "messages": [{"role": "user", "content": "x"}]}, + json={"mode": "daily", "messages": [{"role": "user", "content": "x"}]}, ) assert r.status_code == 503 js = r.json() @@ -192,9 +194,11 @@ async def test_200_midstream_abort_quiet(client, monkeypatch): raise BackendUnavailable("qwen-macbook", "stream_deadline_exceeded") monkeypatch.setattr(EidAIClient, "call_stream", fake_call_stream) + # call_stream midstream 회귀는 daily 로 — deep midstream 은 in-stream error envelope + # 경로(test_eid_chat_deep.test_deep_midstream_error_envelope)로 분리됨. r = await client.post( "/api/eid/chat", - json={"mode": "deep", "messages": [{"role": "user", "content": "x"}]}, + json={"mode": "daily", "messages": [{"role": "user", "content": "x"}]}, ) assert r.status_code == 200 assert r.content == b'data: {"x": 1}\n\n' diff --git a/tests/eid/test_eid_status_endpoint.py b/tests/eid/test_eid_status_endpoint.py new file mode 100644 index 0000000..98de43f --- /dev/null +++ b/tests/eid/test_eid_status_endpoint.py @@ -0,0 +1,112 @@ +"""GET /api/eid/status endpoint 테스트 — inline ASGI app (DB 의존 0). + +★ 실행 환경: fastapi + httpx 필요 → test_eid_chat_endpoint.py 동일 idiom. +★ DB 0 / LLM 0: get_current_user 는 dependency_overrides 로 대체, gate 점유는 + llm_gate.gate_status monkeypatch (eid_chat 이 모듈 attribute 로 호출하므로 유효). +★ 무인증 케이스는 실제 auth 경로지만 decode 단계에서 거부돼 DB 접근 전 반환. +""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "app")) + +from api.eid_chat import router as eid_chat_router # noqa: E402 +from core.auth import get_current_user # noqa: E402 +from services.search import llm_gate # noqa: E402 + + +def _build_app(*, override_auth: bool = True) -> FastAPI: + """main.py 등록 방식과 동일 prefix(/api/eid)로 라우터만 올린 inline app.""" + app = FastAPI() + app.include_router(eid_chat_router, prefix="/api/eid") + if override_auth: + app.dependency_overrides[get_current_user] = lambda: types.SimpleNamespace( + id=1, username="test-user" + ) + return app + + +@pytest_asyncio.fixture +async def client(): + async with AsyncClient( + transport=ASGITransport(app=_build_app()), base_url="http://test" + ) as ac: + yield ac + + +# ── 401 무인증 ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_unauthenticated_rejected(): + async with AsyncClient( + transport=ASGITransport(app=_build_app(override_auth=False)), + base_url="http://test", + ) as ac: + # 헤더 자체 부재 — HTTPBearer 단계 거부 (fastapi 기본 403, 버전별 401 허용) + r = await ac.get("/api/eid/status") + assert r.status_code in (401, 403) + # 위조 토큰 — decode_token 실패 → 401 (DB 접근 전 거부) + r2 = await ac.get( + "/api/eid/status", headers={"Authorization": "Bearer bogus-token"} + ) + assert r2.status_code == 401 + + +# ── 200 shape ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_200_shape(client, monkeypatch): + """응답 shape — daily 키 아래 busy/inflight/waiters 3필드, 타입 고정.""" + monkeypatch.setattr( + llm_gate, "gate_status", lambda: {"inflight": False, "waiters": 0} + ) + r = await client.get("/api/eid/status") + assert r.status_code == 200, r.text + js = r.json() + assert set(js.keys()) == {"daily"} + assert set(js["daily"].keys()) == {"busy", "inflight", "waiters"} + assert isinstance(js["daily"]["busy"], bool) + assert isinstance(js["daily"]["inflight"], bool) + assert isinstance(js["daily"]["waiters"], int) + + +# ── busy 판정 — gate_status monkeypatch ────────────────────────────────────── + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "snap, expected", + [ + # 유휴 — busy=false (근사: 외부 소비자 점유는 미포착) + ( + {"inflight": False, "waiters": 0}, + {"busy": False, "inflight": False, "waiters": 0}, + ), + # inflight 만 — busy=true (확실) + ( + {"inflight": True, "waiters": 0}, + {"busy": True, "inflight": True, "waiters": 0}, + ), + # waiters 만 — busy=true (inflight or waiters>0 의 or 분기) + ( + {"inflight": False, "waiters": 3}, + {"busy": True, "inflight": False, "waiters": 3}, + ), + ], +) +async def test_busy_from_gate_status(client, monkeypatch, snap, expected): + monkeypatch.setattr(llm_gate, "gate_status", lambda: dict(snap)) + r = await client.get("/api/eid/status") + assert r.status_code == 200, r.text + assert r.json() == {"daily": expected} -- 2.52.0