런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -13,6 +13,7 @@ import json
import os
import time
import uuid
from dataclasses import dataclass
from typing import Any, Optional
from fastapi import FastAPI, HTTPException
@ -30,6 +31,7 @@ from app.contracts.engine_gateway import (
StreamDoneEvent,
StreamErrorEvent,
StreamTokenEvent,
normalize_engine_gateway_model,
sse_frame,
)
@ -40,6 +42,15 @@ DEFAULT_BUDGET = float(os.environ.get("SESSION_BUDGET_USD", "5.0"))
READY_TTL_SECONDS = float(os.environ.get("ENGINE_READY_TTL_SECONDS", "30"))
READY_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_READY_TIMEOUT_SECONDS", "20"))
READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5"))
GATEWAY_PROVIDER = "claude_cli"
GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
@dataclass(frozen=True, slots=True)
class GatewayPromptParts:
system_prompt: str
user_payload: str
BASE_ARGS = [
"-p",
@ -53,14 +64,6 @@ BASE_ARGS = [
"--exclude-dynamic-system-prompt-sections",
]
def _model_override(model: Optional[str]) -> Optional[str]:
value = (model or "").strip()
if not value or value == "gateway-default":
return None
return value
class EngineSession:
"""claude -p 상주 프로세스 1개 = 상담 회기 1개."""
@ -73,7 +76,7 @@ class EngineSession:
self.id = uuid.uuid4().hex
self.system_prompt = system_prompt
self.budget = budget
self.model = _model_override(model)
self.model = normalize_engine_gateway_model(model)
self.proc: asyncio.subprocess.Process | None = None
self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복)
self.cost_usd = 0.0
@ -335,28 +338,22 @@ async def close_session(sid: str):
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
# ════════════════════════════════════════════════════════════════════════════
def _split_messages(messages: list[GwMessage]) -> tuple[str, str]:
"""EngineMessage[] → (system_prompt, user_payload).
def _split_messages(messages: list[GwMessage]) -> GatewayPromptParts:
"""EngineMessage[] → named prompt parts for the current gateway turn.
- system 들은 합쳐서 --append-system-prompt 주입할 텍스트로.
- system 들은 합쳐서 --system-prompt 주입할 텍스트로.
- 마지막 user 발화를 이번 stdin content .
- 직전 assistant/user 히스토리는 (단발 모드라) system 뒤에 맥락으로 직렬화.
(상주 세션 재사용 시에는 풀이 이미 컨텍스트를 들고 있으므로 마지막 user 보냄.)
- 상주 세션 재사용 시에는 풀이 이미 컨텍스트를 들고 있으므로 마지막 user 보냄.
"""
system_parts: list[str] = []
history_parts: list[str] = []
last_user = ""
for m in messages:
if m.role == "system":
system_parts.append(m.content)
elif m.role == "assistant":
history_parts.append(f"[이전 상담자 발화]\n{m.content}")
elif m.role == "user":
if last_user:
history_parts.append(f"[이전 내담자 발화]\n{last_user}")
last_user = m.content
system_prompt = "\n\n".join(p for p in system_parts if p.strip())
return system_prompt, last_user
return GatewayPromptParts(system_prompt=system_prompt, user_payload=last_user)
def _inject_schema(system_prompt: str, schema: Optional[dict[str, Any]]) -> str:
@ -375,12 +372,16 @@ def _inject_schema(system_prompt: str, schema: Optional[dict[str, Any]]) -> str:
return (system_prompt + directive) if system_prompt else directive.lstrip()
def _response_model_name(session: EngineSession) -> str:
return session.model or DEFAULT_MODEL or GATEWAY_FALLBACK_MODEL_NAME
async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[EngineSession, bool]:
"""session_id 가 있고 살아있으면 재사용, 아니면 단발용 임시 세션 생성.
반환: (session, ephemeral). ephemeral=True 호출부가 응답 close 한다.
"""
requested_model = _model_override(req.model)
requested_model = normalize_engine_gateway_model(req.model)
if req.session_id and req.session_id in SESSIONS:
s = SESSIONS[req.session_id]
if s.proc is not None and s.proc.returncode is None:
@ -399,14 +400,14 @@ async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[Engi
@app.post("/v1/generate")
async def v1_generate(req: GwGenerateReq):
"""단발 생성 (평가 deep-loop, 회기종료 압축 등). GenerateResponse 호환 dict 반환."""
system_prompt, user_payload = _split_messages(req.messages)
system_prompt = _inject_schema(system_prompt, req.structured_schema)
if not user_payload:
prompt_parts = _split_messages(req.messages)
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
if not prompt_parts.user_payload:
raise HTTPException(400, "no user message in payload")
s, ephemeral = await _resolve_session(req, system_prompt)
try:
result = await s.turn(user_payload, timeout=120.0)
result = await s.turn(prompt_parts.user_payload, timeout=120.0)
finally:
if ephemeral:
await s.close()
@ -422,8 +423,8 @@ async def v1_generate(req: GwGenerateReq):
structured = None # 파싱 실패는 호출부가 text 로 폴백
return GenerateResponse(
text=text,
model=s.model or DEFAULT_MODEL or "claude-opus-4-8",
provider="claude_cli",
model=_response_model_name(s),
provider=GATEWAY_PROVIDER,
tokens_in=0,
tokens_out=0,
cost_usd=result.get("cost_usd", 0.0),
@ -435,16 +436,16 @@ async def v1_generate(req: GwGenerateReq):
@app.post("/v1/stream")
async def v1_stream(req: GwGenerateReq):
"""SSE 토큰 스트림. data: 라인으로 텍스트 델타를 흘리고 done/error 프레이밍."""
system_prompt, user_payload = _split_messages(req.messages)
system_prompt = _inject_schema(system_prompt, req.structured_schema)
if not user_payload:
prompt_parts = _split_messages(req.messages)
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
if not prompt_parts.user_payload:
raise HTTPException(400, "no user message in payload")
s, ephemeral = await _resolve_session(req, system_prompt)
async def _sse():
try:
async for evt in s.turn_stream(user_payload, timeout=600.0):
async for evt in s.turn_stream(prompt_parts.user_payload, timeout=600.0):
if evt.get("type") == "delta":
yield sse_frame(
ENGINE_GATEWAY_SSE_TOKEN,
@ -460,8 +461,8 @@ async def v1_stream(req: GwGenerateReq):
yield sse_frame(
ENGINE_GATEWAY_SSE_DONE,
StreamDoneEvent(
provider="claude_cli",
model=s.model or DEFAULT_MODEL or "claude-opus-4-8",
provider=GATEWAY_PROVIDER,
model=_response_model_name(s),
tokens_in=0,
tokens_out=0,
cost_usd=evt.get("cost_usd", 0.0),