250896cdfa
- 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 <noreply@anthropic.com>
153 lines
5.6 KiB
Python
153 lines
5.6 KiB
Python
"""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
|