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

126
apps/api/app/db.py Normal file
View file

@ -0,0 +1,126 @@
"""asyncpg 연결 풀 + pgvector 등록.
DB = NAS PostgreSQL 16 단일 SoR (마스터플랜 §0). 스키마 4분할: app / kb / audit / ds.
RLS 이중강제: 커넥션 획득 SET LOCAL app.current_role / app.current_ai_view 주입
(deps.py RBAC 의존성과 ). 여기선 + 헬퍼만 제공한다.
"""
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional
import asyncpg
from .config import settings
# 전역 풀 핸들. main.py lifespan 에서 init/close.
_pool: Optional[asyncpg.Pool] = None
async def _init_connection(conn: asyncpg.Connection) -> None:
"""커넥션 단위 코덱 등록.
- jsonb: dict 직렬화 자동 (asyncpg 기본은 str 반환)
- vector(1024): pgvector. 런타임 인코딩은 RAG 경로에서 처리(여기선 텍스트 캐스트 허용).
TODO: pgvector 바이너리 코덱 등록(register_vector) RAG 라우터 구현 BGE-M3 1024d 연동.
"""
await conn.set_type_codec(
"jsonb",
encoder=lambda v: json.dumps(v, ensure_ascii=False),
decoder=json.loads,
schema="pg_catalog",
)
await conn.set_type_codec(
"json",
encoder=lambda v: json.dumps(v, ensure_ascii=False),
decoder=json.loads,
schema="pg_catalog",
)
async def init_pool() -> asyncpg.Pool:
"""풀 생성 (main lifespan startup)."""
global _pool
if _pool is not None:
return _pool
_pool = await asyncpg.create_pool(
dsn=settings.database_url,
min_size=settings.db_pool_min_size,
max_size=settings.db_pool_max_size,
command_timeout=settings.db_command_timeout,
init=_init_connection,
)
return _pool
async def close_pool() -> None:
"""풀 종료 (main lifespan shutdown)."""
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def get_pool() -> asyncpg.Pool:
"""초기화된 풀 반환. lifespan 밖 호출 시 RuntimeError."""
if _pool is None:
raise RuntimeError("DB pool not initialized — init_pool() must run in lifespan startup")
return _pool
@asynccontextmanager
async def acquire(
*,
role: Optional[str] = None,
ai_view: Optional[str] = None,
) -> AsyncIterator[asyncpg.Connection]:
"""커넥션 획득 + RLS 컨텍스트 주입.
RLS 이중강제 (설계서 §4.1, F-30):
레이어1 = AI 정보비대칭: app.current_ai_view (visible_to[] WHERE 강제)
레이어2 = 인간 RBAC×cohort: app.current_role (RLS 정책)
트랜잭션 SET LOCAL 주입해 커넥션 재사용 누수 방지.
NOTE: RLS 정책/세션변수는 Phase 0 마이그레이션에서 정의(설계서 §3.3 / §4).
여기선 변수 주입 계약만 확정.
"""
pool = get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
if role is not None:
await conn.execute("SELECT set_config('app.current_role', $1, true)", role)
if ai_view is not None:
await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view)
yield conn
async def healthcheck() -> bool:
"""SELECT 1 핑. /health 에서 사용."""
try:
pool = get_pool()
async with pool.acquire() as conn:
val = await conn.fetchval("SELECT 1")
return val == 1
except Exception:
return False
async def fetch(query: str, *args: Any, role: Optional[str] = None, ai_view: Optional[str] = None):
async with acquire(role=role, ai_view=ai_view) as conn:
return await conn.fetch(query, *args)
async def fetchrow(
query: str, *args: Any, role: Optional[str] = None, ai_view: Optional[str] = None
):
async with acquire(role=role, ai_view=ai_view) as conn:
return await conn.fetchrow(query, *args)
async def execute(
query: str, *args: Any, role: Optional[str] = None, ai_view: Optional[str] = None
) -> str:
async with acquire(role=role, ai_view=ai_view) as conn:
return await conn.execute(query, *args)