SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
201 lines
7.6 KiB
Python
201 lines
7.6 KiB
Python
"""엔진 게이트웨이 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
|
|
|
|
import asyncio
|
|
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]
|
|
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.engine_mode = settings.engine_mode
|
|
self.default_model: Optional[str] = None
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def startup(self) -> None:
|
|
async with self._lock:
|
|
if self._client is None:
|
|
self._client = self._new_client()
|
|
|
|
def _new_client(self) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(
|
|
base_url=self.base_url,
|
|
timeout=httpx.Timeout(
|
|
settings.engine_timeout,
|
|
connect=settings.engine_connect_timeout,
|
|
),
|
|
)
|
|
|
|
async def shutdown(self) -> None:
|
|
async with self._lock:
|
|
if self._client is not None:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
@staticmethod
|
|
def _model_override(model: Optional[str]) -> Optional[str]:
|
|
value = (model or "").strip()
|
|
if not value or value == "gateway-default":
|
|
return None
|
|
return value
|
|
|
|
async def configure(
|
|
self,
|
|
*,
|
|
base_url: str,
|
|
engine_mode: str,
|
|
default_model: Optional[str] = None,
|
|
) -> None:
|
|
next_url = base_url.rstrip("/")
|
|
next_model = self._model_override(default_model)
|
|
async with self._lock:
|
|
url_changed = next_url != self.base_url
|
|
self.base_url = next_url
|
|
self.engine_mode = engine_mode
|
|
self.default_model = next_model
|
|
if self._client is not None and url_changed:
|
|
old_client = self._client
|
|
self._client = self._new_client()
|
|
await old_client.aclose()
|
|
|
|
def _payload(self, req: GenerateRequest) -> dict[str, Any]:
|
|
payload = req.model_dump(exclude_none=True)
|
|
if self.default_model and "model" not in payload:
|
|
payload["model"] = self.default_model
|
|
return payload
|
|
|
|
@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:
|
|
return bool((await self.health_detail()).get("ok"))
|
|
|
|
async def health_detail(self) -> dict[str, Any]:
|
|
try:
|
|
r = await self.client.get("/ready")
|
|
if r.status_code == 404:
|
|
live = await self.client.get("/health")
|
|
return {
|
|
"ok": live.status_code == 200,
|
|
"detail": "gateway liveness only; readiness endpoint unavailable",
|
|
"status_code": live.status_code,
|
|
}
|
|
payload: dict[str, Any] = {}
|
|
try:
|
|
payload = r.json()
|
|
except ValueError:
|
|
payload = {}
|
|
return {
|
|
"ok": r.status_code == 200 and bool(payload.get("ok", False)),
|
|
"detail": str(payload.get("detail") or r.text or "engine readiness failed"),
|
|
"status_code": r.status_code,
|
|
"cached": bool(payload.get("cached", False)),
|
|
}
|
|
except httpx.HTTPError as exc:
|
|
return {
|
|
"ok": False,
|
|
"detail": f"engine readiness transport error: {exc}",
|
|
"status_code": None,
|
|
}
|
|
|
|
async def generate(self, req: GenerateRequest) -> GenerateResponse:
|
|
"""단발 생성. TODO: 게이트웨이 응답 스키마 확정 후 cost 텔레메트리 turns 적재."""
|
|
try:
|
|
r = await self.client.post("/v1/generate", json=self._payload(req))
|
|
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=self._payload(req)
|
|
) 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()
|