"""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 router_unreachable.""" 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"] == "router_unreachable" @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