Merge pull request 'Feat/eid chat' (#35) from feat/eid-chat into main
Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user