feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -25,15 +25,24 @@ from app.contracts.engine_gateway import (
ENGINE_GATEWAY_SSE_DONE,
ENGINE_GATEWAY_SSE_ERROR,
ENGINE_GATEWAY_SSE_TOKEN,
EngineCapabilitiesResponse,
EngineMessage as GwMessage,
EngineProvider,
GenerateResponse,
GenerateRequest as GwGenerateReq,
ReasoningEffort,
StreamDoneEvent,
StreamErrorEvent,
StreamTokenEvent,
normalize_engine_gateway_model,
sse_frame,
)
from engine_gateway.provider_registry import (
ProviderError,
discover_capabilities,
generate_with_provider,
stream_with_provider,
)
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
@ -45,6 +54,8 @@ READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5"))
# 단발 생성(/v1/generate) 턴 타임아웃 — 페르소나 초안 생성 같은 대형 구조화 출력은
# 120초를 넘길 수 있어 설정 가능하게 한다(2026-07-15). 호출부(app ENGINE_TIMEOUT)와 정합 필요.
GENERATE_TURN_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_GENERATE_TIMEOUT_SECONDS", "300"))
SESSION_IDLE_TTL_SECONDS = float(os.environ.get("ENGINE_SESSION_IDLE_TTL_SECONDS", "3600"))
MAX_RESIDENT_SESSIONS = max(1, int(os.environ.get("ENGINE_MAX_RESIDENT_SESSIONS", "24")))
GATEWAY_PROVIDER = "claude_cli"
GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
@ -53,13 +64,18 @@ GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
class GatewayPromptParts:
system_prompt: str
user_payload: str
current_user_payload: str
BASE_ARGS = [
"-p",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--include-partial-messages",
"--verbose",
# 상담 축어록은 게이트웨이 프로세스 수명 안에서만 유지한다. Claude CLI의 로컬
# 세션 파일로 이중 저장하지 않아 개인정보 노출과 매 턴 디스크 I/O를 줄인다.
"--no-session-persistence",
"--dangerously-skip-permissions",
# 페르소나 격리: cwd/env/git status/메모리(CLAUDE.md) 등 per-machine 섹션을 시스템프롬프트에서
# 제거 → 내담자 AI가 자신이 개발 환경(Claude Code/Vignette repo) 안에 있음을 알아채 캐릭터를
@ -75,21 +91,26 @@ class EngineSession:
system_prompt: str | None = None,
budget: float = DEFAULT_BUDGET,
model: str | None = None,
reasoning_effort: ReasoningEffort | None = None,
):
self.id = uuid.uuid4().hex
self.system_prompt = system_prompt
self.budget = budget
self.model = normalize_engine_gateway_model(model)
self.reasoning_effort = reasoning_effort
self.proc: asyncio.subprocess.Process | None = None
self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복)
self.cost_usd = 0.0
self.turns = 0
self.last_used_at = time.monotonic()
async def start(self) -> None:
args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)]
model = self.model or DEFAULT_MODEL
if model:
args += ["--model", model]
if self.reasoning_effort:
args += ["--effort", self.reasoning_effort]
if FALLBACK_MODEL:
args += ["--fallback-model", FALLBACK_MODEL]
if self.system_prompt:
@ -135,6 +156,7 @@ class EngineSession:
return obj
result = await asyncio.wait_for(_read_until_result(), timeout=timeout)
self.last_used_at = time.monotonic()
self.cost_usd = result.get("total_cost_usd", self.cost_usd)
self.turns += 1
error_detail = (
@ -186,7 +208,18 @@ class EngineSession:
except json.JSONDecodeError:
continue
t = obj.get("type")
if t == "assistant":
if t == "stream_event":
stream_event = obj.get("event") or {}
delta_payload = stream_event.get("delta") or {}
if (
stream_event.get("type") == "content_block_delta"
and delta_payload.get("type") == "text_delta"
):
delta = str(delta_payload.get("text") or "")
if delta:
emitted += delta
yield {"type": "delta", "text": delta}
elif t == "assistant":
# 이번 메시지의 텍스트 전체를 재구성
full = "".join(
c.get("text", "")
@ -202,6 +235,7 @@ class EngineSession:
emitted += delta if full.startswith(emitted) else full
yield {"type": "delta", "text": delta}
elif t == "result":
self.last_used_at = time.monotonic()
self.cost_usd = obj.get("total_cost_usd", self.cost_usd)
self.turns += 1
error_detail = (
@ -234,7 +268,8 @@ class EngineSession:
SESSIONS: dict[str, EngineSession] = {}
_READY_CACHE: dict[str, Any] = {"checked_at": 0.0, "ok": False, "detail": "not checked"}
_SESSION_RESOLVE_LOCK = asyncio.Lock()
_READY_CACHE: dict[tuple[str, str, str], dict[str, Any]] = {}
_READY_LOCK = asyncio.Lock()
app = FastAPI(title="Vignette Engine Gateway")
@ -250,18 +285,36 @@ class TurnReq(BaseModel):
@app.get("/health")
async def health():
return {"ok": True, "engine": "claude_p", "model": DEFAULT_MODEL or "default(opus-4-8)", "sessions": len(SESSIONS)}
return {
"ok": True,
"engine": "claude_cli",
"model": DEFAULT_MODEL or "gateway-default",
"sessions": len(SESSIONS),
}
def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse:
ok = bool(_READY_CACHE.get("ok"))
def _ready_response(
entry: dict[str, Any],
*,
provider: EngineProvider,
model: str | None,
reasoning_effort: ReasoningEffort | None,
cached: bool,
age_seconds: float = 0.0,
) -> JSONResponse:
ok = bool(entry.get("ok"))
return JSONResponse(
{
"ok": ok,
"engine": "claude_p",
"model": DEFAULT_MODEL or "default(opus-4-8)",
"engine": provider,
"model": model or (
DEFAULT_MODEL or "default(opus-4-8)"
if provider == "claude_cli"
else "provider-default"
),
"reasoning_effort": reasoning_effort,
"sessions": len(SESSIONS),
"detail": _READY_CACHE.get("detail"),
"detail": entry.get("detail"),
"age_seconds": round(max(0.0, age_seconds), 3),
"cached": cached,
},
@ -270,43 +323,105 @@ def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse:
@app.get("/ready")
async def ready(force: bool = False):
async def ready(
force: bool = False,
provider: EngineProvider = "claude_cli",
model: str | None = None,
reasoning_effort: ReasoningEffort | None = None,
):
"""Prove that claude -p can complete a real generation.
/health is shallow process liveness. This endpoint catches the installed-but-
not-authenticated CLI state before a learner reaches POST /sessions/:id/turn.
"""
age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0)
cache_key = (provider, model or "", reasoning_effort or "")
entry = _READY_CACHE.get(
cache_key, {"checked_at": 0.0, "ok": False, "detail": "not checked"}
)
age = time.monotonic() - float(entry.get("checked_at", 0.0) or 0.0)
if not force and age < READY_TTL_SECONDS:
return _ready_response(cached=True, age_seconds=age)
return _ready_response(
entry,
provider=provider,
model=model,
reasoning_effort=reasoning_effort,
cached=True,
age_seconds=age,
)
async with _READY_LOCK:
age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0)
if not force and age < READY_TTL_SECONDS:
return _ready_response(cached=True, age_seconds=age)
probe = EngineSession(
system_prompt="You are a readiness probe. Reply with exactly OK.",
budget=READY_BUDGET_USD,
entry = _READY_CACHE.get(
cache_key, {"checked_at": 0.0, "ok": False, "detail": "not checked"}
)
age = time.monotonic() - float(entry.get("checked_at", 0.0) or 0.0)
if not force and age < READY_TTL_SECONDS:
return _ready_response(
entry,
provider=provider,
model=model,
reasoning_effort=reasoning_effort,
cached=True,
age_seconds=age,
)
ok = False
detail = "unknown readiness failure"
try:
await probe.start()
result = await probe.turn("Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS)
if result.get("is_error"):
detail = str(result.get("error") or "engine returned an error")
else:
text = str(result.get("text") or "").strip()
ok = bool(text)
detail = text or "empty engine response"
except Exception as exc:
detail = str(exc)
finally:
await probe.close()
if provider == "claude_cli":
probe = EngineSession(
system_prompt="You are a readiness probe. Reply with exactly OK.",
budget=READY_BUDGET_USD,
model=model,
reasoning_effort=reasoning_effort,
)
try:
await probe.start()
result = await probe.turn(
"Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS
)
if result.get("is_error"):
detail = str(result.get("error") or "engine returned an error")
else:
text = str(result.get("text") or "").strip()
ok = bool(text)
detail = text or "empty engine response"
except Exception as exc:
detail = str(exc)
finally:
await probe.close()
else:
request = GwGenerateReq(
provider=provider,
model=model,
reasoning_effort=reasoning_effort,
max_tokens=16,
temperature=0,
messages=[GwMessage(role="user", content="Reply with exactly OK.")],
)
try:
result = await generate_with_provider(
request,
system_prompt="You are a readiness probe. Reply with exactly OK.",
user_payload="Reply with exactly OK.",
)
ok = bool(result.text.strip())
detail = result.text.strip() or "empty engine response"
except Exception as exc:
detail = str(exc)
_READY_CACHE.update({"checked_at": time.monotonic(), "ok": ok, "detail": detail})
return _ready_response(cached=False)
entry = {"checked_at": time.monotonic(), "ok": ok, "detail": detail}
_READY_CACHE[cache_key] = entry
return _ready_response(
entry,
provider=provider,
model=model,
reasoning_effort=reasoning_effort,
cached=False,
)
@app.get("/v1/capabilities", response_model=EngineCapabilitiesResponse)
async def v1_capabilities(provider: EngineProvider, force: bool = False):
return await discover_capabilities(provider, force=force)
@app.post("/session")
@ -344,10 +459,14 @@ async def close_session(sid: str):
def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None) -> GatewayPromptParts:
"""EngineMessage[] → named prompt parts for the current gateway turn."""
system_parts: list[str] = []
turn_control_parts: list[str] = []
non_system: list[GwMessage] = []
for m in messages:
if m.role == "system":
system_parts.append(m.content)
if ai_role == "client" and not m.cache:
turn_control_parts.append(m.content)
else:
system_parts.append(m.content)
else:
non_system.append(m)
@ -361,7 +480,15 @@ def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None)
last_user = non_system[last_user_index].content
user_payload = last_user
current_user_payload = last_user
if ai_role == "client" and last_user_index is not None:
control = "\n\n".join(p for p in turn_control_parts if p.strip())
current_sections: list[str] = []
if control:
current_sections.append("[현재 턴 상태와 연기 지시]\n" + control)
current_sections.append("[이번 상담자 발화]\n" + last_user)
current_user_payload = "\n\n".join(current_sections)
history_parts: list[str] = []
for m in non_system[:last_user_index]:
content = m.content.strip()
@ -369,11 +496,18 @@ def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None)
continue
speaker = "상담자" if m.role == "user" else "내담자"
history_parts.append(f"{speaker}: {content}")
history_sections = list(current_sections[:-1])
if history_parts:
user_payload = "[직전 대화]\n" + "\n".join(history_parts) + "\n\n[이번 상담자 발화]\n" + last_user
history_sections.append("[직전 대화]\n" + "\n".join(history_parts))
history_sections.append(current_sections[-1])
user_payload = "\n\n".join(history_sections)
system_prompt = "\n\n".join(p for p in system_parts if p.strip())
return GatewayPromptParts(system_prompt=system_prompt, user_payload=user_payload)
return GatewayPromptParts(
system_prompt=system_prompt,
user_payload=user_payload,
current_user_payload=current_user_payload,
)
def _inject_schema(system_prompt: str, schema: Optional[dict[str, Any]]) -> str:
@ -397,26 +531,90 @@ def _response_model_name(session: EngineSession) -> str:
async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[EngineSession, bool]:
"""session_id 가 있고 살아있으면 재사용, 아니면 단발용 임시 세션 생성.
"""내담자 회기는 session_id 에 바인딩하고, 나머지는 단발 세션으로 실행한다.
반환: (session, ephemeral). ephemeral=True 호출부가 응답 close 한다.
"""
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:
if requested_model is None or (s.model or DEFAULT_MODEL) == requested_model:
return s, False
# 단발(또는 죽은 세션) → 1회성 세션
persistent_key = req.session_id if req.session_id and req.ai_role == "client" else None
if persistent_key:
async with _SESSION_RESOLVE_LOCK:
await _prune_resident_sessions(exclude={persistent_key})
existing = SESSIONS.get(persistent_key)
if existing is not None:
running = existing.proc is not None and existing.proc.returncode is None
same_model = requested_model is None or (existing.model or DEFAULT_MODEL) == requested_model
same_effort = req.reasoning_effort is None or existing.reasoning_effort == req.reasoning_effort
if running and same_model and same_effort:
existing.last_used_at = time.monotonic()
return existing, False
SESSIONS.pop(persistent_key, None)
await existing.close()
session = EngineSession(
system_prompt=system_prompt or None,
budget=DEFAULT_BUDGET,
model=requested_model,
reasoning_effort=req.reasoning_effort,
)
await session.start()
SESSIONS[persistent_key] = session
return session, False
# 평가·관리자 생성처럼 페르소나 회기와 정체성을 섞으면 안 되는 호출은 1회성 세션이다.
s = EngineSession(
system_prompt=system_prompt or None,
budget=DEFAULT_BUDGET,
model=requested_model,
reasoning_effort=req.reasoning_effort,
)
await s.start()
return s, True
async def _prune_resident_sessions(*, exclude: set[str] | None = None) -> None:
"""죽었거나 오래 유휴인 회기와 상한 초과 회기를 안전하게 정리한다."""
protected = exclude or set()
now = time.monotonic()
stale_keys = [
key
for key, session in SESSIONS.items()
if key not in protected
and not session.lock.locked()
and (
session.proc is None
or session.proc.returncode is not None
or now - session.last_used_at >= SESSION_IDLE_TTL_SECONDS
)
]
for key in stale_keys:
session = SESSIONS.pop(key, None)
if session is not None:
await session.close()
overflow = len(SESSIONS) - MAX_RESIDENT_SESSIONS + 1
if overflow <= 0:
return
candidates = sorted(
(
(key, session)
for key, session in SESSIONS.items()
if key not in protected and not session.lock.locked()
),
key=lambda item: item[1].last_used_at,
)
for key, session in candidates[:overflow]:
SESSIONS.pop(key, None)
await session.close()
def _session_turn_payload(session: EngineSession, prompt_parts: GatewayPromptParts) -> str:
"""상주 프로세스는 자체 대화기록을 가지므로 재사용 턴에는 L6를 중복 주입하지 않는다."""
if session.turns > 0:
return prompt_parts.current_user_payload
return prompt_parts.user_payload
@app.post("/v1/generate")
async def v1_generate(req: GwGenerateReq):
"""단발 생성 (평가 deep-loop, 회기종료 압축 등). GenerateResponse 호환 dict 반환."""
@ -425,9 +623,33 @@ async def v1_generate(req: GwGenerateReq):
if not prompt_parts.user_payload:
raise HTTPException(400, "no user message in payload")
provider = req.provider or "claude_cli"
if provider != "claude_cli":
try:
result = await generate_with_provider(
req,
system_prompt=system_prompt,
user_payload=prompt_parts.user_payload,
)
except ProviderError as exc:
raise HTTPException(502, f"engine provider error: {exc}") from exc
return GenerateResponse(
text=result.text,
model=result.model,
provider=result.provider,
tokens_in=result.tokens_in,
tokens_out=result.tokens_out,
cost_usd=result.cost_usd,
inference_geo=result.inference_geo,
structured=result.structured,
).model_dump()
s, ephemeral = await _resolve_session(req, system_prompt)
try:
result = await s.turn(prompt_parts.user_payload, timeout=GENERATE_TURN_TIMEOUT_SECONDS)
result = await s.turn(
_session_turn_payload(s, prompt_parts),
timeout=GENERATE_TURN_TIMEOUT_SECONDS,
)
finally:
if ephemeral:
await s.close()
@ -461,11 +683,53 @@ async def v1_stream(req: GwGenerateReq):
if not prompt_parts.user_payload:
raise HTTPException(400, "no user message in payload")
provider = req.provider or "claude_cli"
if provider != "claude_cli":
async def _provider_sse():
try:
async for event in stream_with_provider(
req,
system_prompt=system_prompt,
user_payload=prompt_parts.user_payload,
):
if event.type == "delta" and event.text:
yield sse_frame(
ENGINE_GATEWAY_SSE_TOKEN,
StreamTokenEvent(text=event.text),
)
elif event.type == "done" and event.result is not None:
result = event.result
yield sse_frame(
ENGINE_GATEWAY_SSE_DONE,
StreamDoneEvent(
provider=result.provider,
model=result.model,
tokens_in=result.tokens_in,
tokens_out=result.tokens_out,
cost_usd=result.cost_usd,
turns=1,
),
)
except ProviderError as exc:
yield sse_frame(
ENGINE_GATEWAY_SSE_ERROR,
StreamErrorEvent(detail=f"engine provider error: {exc}"),
)
return StreamingResponse(
_provider_sse(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
s, ephemeral = await _resolve_session(req, system_prompt)
async def _sse():
try:
async for evt in s.turn_stream(prompt_parts.user_payload, timeout=600.0):
async for evt in s.turn_stream(
_session_turn_payload(s, prompt_parts),
timeout=600.0,
):
if evt.get("type") == "delta":
yield sse_frame(
ENGINE_GATEWAY_SSE_TOKEN,