feat(engine): claude -p 상주 멀티턴 엔진 게이트웨이 + 실동작 검증

- engine_gateway/gateway.py: 회기당 claude -p 상주 프로세스(stream-json), 턴 직렬, budget 제한
- 세션 생성/턴/종료 HTTP API(FastAPI, :9099)
- 검증: 멀티턴 컨텍스트 유지 + prompt caching 재사용(턴2 +$0.07) 실동작 확인
This commit is contained in:
Yun Chan 2026-06-25 21:43:27 +09:00
parent d5b86c5f89
commit 84eb6e2173
20 changed files with 2038 additions and 1 deletions

View file

@ -0,0 +1,137 @@
"""엔진 게이트웨이 HTTP 클라이언트.
게이트웨이 자체(apps/api/engine_gateway/) 람다가 직접 만든다. 여기는 *호출부*.
게이트웨이가 provider 라우팅(claude_api/claude_cli/openai/solar)·캐싱·상주 claude -p 풀을
흡수한다(마스터플랜 §0, R1). 백엔드는 ENGINE_URL HTTP 호출만 한다.
계약 (람다와 합의할 게이트웨이 API):
POST {ENGINE_URL}/v1/generate 단발 생성 (평가 deep-loop )
POST {ENGINE_URL}/v1/stream SSE 토큰 스트림 (내담자 AI 응답)
GET {ENGINE_URL}/health
요청 바디는 3-AI 역할별 system 레이어(L0~L6, 설계서 §1.2) 게이트웨이에 넘기되,
text *PII 마스킹 (text_masked)* 보낸다 (R7/F-03, 마스킹은 호출 가드레일이 완료).
"""
from __future__ import annotations
from typing import Any, AsyncIterator, Literal, Optional
import httpx
from pydantic import BaseModel, Field
from .config import settings
AIRole = Literal["client", "counselor", "evaluator"]
# ── 요청/응답 계약 모델 ─────────────────────────────────
class EngineMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
# 프롬프트 캐싱 힌트 (설계서 §1.2 L0~L2 cache_control). 게이트웨이가 해석.
cache: bool = False
class GenerateRequest(BaseModel):
"""단발 생성 요청 (평가 AI deep-loop, 회기종료 압축 등)."""
ai_role: AIRole
messages: list[EngineMessage]
# tier 라우팅 힌트: client=Sonnet/Solar, evaluator=Opus, fast=Haiku (마스터플랜 §5)
tier: Literal["client", "feedback", "fast"] = "client"
model: Optional[str] = None # 명시 시 게이트웨이 override
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None # Structured Outputs (CCD 비노출 강제)
session_id: Optional[str] = None # 비용 텔레메트리 귀속
metadata: dict[str, Any] = Field(default_factory=dict)
class GenerateResponse(BaseModel):
text: str
model: str
provider: str
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
inference_geo: Optional[str] = None # 'kr'|'us' (데이터 주권 audit)
structured: Optional[dict[str, Any]] = None
class StreamRequest(GenerateRequest):
"""SSE 스트림 요청 (내담자 AI 실시간 응답)."""
class EngineError(RuntimeError):
"""게이트웨이 호출 실패. 라우트가 503/502 로 변환."""
class EngineClient:
"""ENGINE_URL 게이트웨이 비동기 클라이언트. 앱 수명주기 동안 1 인스턴스 재사용."""
def __init__(self, base_url: Optional[str] = None) -> None:
self.base_url = (base_url or settings.engine_url).rstrip("/")
self._client: Optional[httpx.AsyncClient] = None
async def startup(self) -> None:
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=httpx.Timeout(
settings.engine_timeout,
connect=settings.engine_connect_timeout,
),
)
async def shutdown(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
raise EngineError("EngineClient not started — call startup() in lifespan")
return self._client
async def health(self) -> bool:
try:
r = await self.client.get("/health")
return r.status_code == 200
except httpx.HTTPError:
return False
async def generate(self, req: GenerateRequest) -> GenerateResponse:
"""단발 생성. TODO: 게이트웨이 응답 스키마 확정 후 cost 텔레메트리 turns 적재."""
try:
r = await self.client.post("/v1/generate", json=req.model_dump(exclude_none=True))
r.raise_for_status()
except httpx.HTTPStatusError as e:
raise EngineError(f"engine generate {e.response.status_code}: {e.response.text}") from e
except httpx.HTTPError as e:
raise EngineError(f"engine generate transport error: {e}") from e
return GenerateResponse.model_validate(r.json())
async def stream(self, req: StreamRequest) -> AsyncIterator[str]:
"""SSE 토큰 스트림 프록시.
게이트웨이 SSE(`text/event-stream`) data: 청크를 그대로 yield.
라우트(sessions.py) 이걸 받아 자체 SSE 이벤트(heartbeat 포함) 재방출.
TODO: 게이트웨이 이벤트 프레이밍 확정(토큰/usage/done 이벤트 구분).
"""
try:
async with self.client.stream(
"POST", "/v1/stream", json=req.model_dump(exclude_none=True)
) as r:
r.raise_for_status()
async for line in r.aiter_lines():
if line:
yield line
except httpx.HTTPStatusError as e:
raise EngineError(f"engine stream {e.response.status_code}") from e
except httpx.HTTPError as e:
raise EngineError(f"engine stream transport error: {e}") from e
# 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown)
engine_client = EngineClient()