음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -13,11 +13,24 @@ import json
import os
import time
import uuid
from typing import Any, Literal, Optional
from typing import Any, Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.contracts.engine_gateway import (
AIRole,
ENGINE_GATEWAY_SSE_DONE,
ENGINE_GATEWAY_SSE_ERROR,
ENGINE_GATEWAY_SSE_TOKEN,
EngineMessage as GwMessage,
GenerateRequest as GwGenerateReq,
StreamDoneEvent,
StreamErrorEvent,
StreamTokenEvent,
sse_frame,
)
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
@ -321,26 +334,6 @@ async def close_session(sid: str):
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
# ════════════════════════════════════════════════════════════════════════════
AIRole = Literal["client", "counselor", "evaluator"]
class GwMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
cache: bool = False # 프롬프트 캐싱 힌트 (L0~L2 cache_control 대상)
class GwGenerateReq(BaseModel):
ai_role: AIRole = "client"
messages: list[GwMessage]
model: Optional[str] = None
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None
session_id: Optional[str] = None # 풀 재사용 키(있으면 멀티턴 캐시)
metadata: dict[str, Any] = Field(default_factory=dict)
def _split_messages(messages: list[GwMessage]) -> tuple[str, str]:
"""EngineMessage[] → (system_prompt, user_payload).
@ -452,28 +445,33 @@ async def v1_stream(req: GwGenerateReq):
try:
async for evt in s.turn_stream(user_payload, timeout=600.0):
if evt.get("type") == "delta":
payload = json.dumps({"text": evt["text"]}, ensure_ascii=False)
yield f"event: token\ndata: {payload}\n\n"
yield sse_frame(
ENGINE_GATEWAY_SSE_TOKEN,
StreamTokenEvent(text=evt["text"]),
)
elif evt.get("type") == "done":
if evt.get("is_error"):
err = json.dumps({"detail": str(evt.get("error"))}, ensure_ascii=False)
yield f"event: error\ndata: {err}\n\n"
else:
meta = json.dumps(
{
"provider": "claude_cli",
"model": s.model or DEFAULT_MODEL or "claude-opus-4-8",
"tokens_in": 0,
"tokens_out": 0,
"cost_usd": evt.get("cost_usd", 0.0),
"turns": evt.get("turns", 0),
},
ensure_ascii=False,
yield sse_frame(
ENGINE_GATEWAY_SSE_ERROR,
StreamErrorEvent(detail=str(evt.get("error"))),
)
else:
yield sse_frame(
ENGINE_GATEWAY_SSE_DONE,
StreamDoneEvent(
provider="claude_cli",
model=s.model or DEFAULT_MODEL or "claude-opus-4-8",
tokens_in=0,
tokens_out=0,
cost_usd=evt.get("cost_usd", 0.0),
turns=evt.get("turns", 0),
),
)
yield f"event: done\ndata: {meta}\n\n"
except Exception as e: # 전송 도중 실패도 SSE 프레임으로 알린다
err = json.dumps({"detail": str(e)}, ensure_ascii=False)
yield f"event: error\ndata: {err}\n\n"
yield sse_frame(
ENGINE_GATEWAY_SSE_ERROR,
StreamErrorEvent(detail=str(e)),
)
finally:
if ephemeral:
await s.close()

View file

@ -2,6 +2,8 @@ import asyncio
import unittest
from unittest.mock import patch
from app import engine_client
from app.contracts import engine_gateway as contract
from engine_gateway import gateway
@ -86,6 +88,22 @@ class GatewayModelTest(unittest.TestCase):
def tearDown(self):
gateway.SESSIONS.clear()
def test_gateway_reuses_shared_engine_contract_models(self):
self.assertIs(gateway.GwGenerateReq, contract.GenerateRequest)
self.assertIs(gateway.GwMessage, contract.EngineMessage)
self.assertIs(engine_client.GenerateRequest, contract.GenerateRequest)
self.assertEqual(contract.ENGINE_GATEWAY_SSE_EVENTS, ("token", "done", "error"))
def test_sse_frame_helper_preserves_gateway_wire_contract(self):
self.assertEqual(
contract.sse_frame("token", contract.StreamTokenEvent(text="hello")),
'event: token\ndata: {"text": "hello"}\n\n',
)
self.assertEqual(
contract.sse_frame("error", contract.StreamErrorEvent(detail="failed")),
'event: error\ndata: {"detail": "failed"}\n\n',
)
def test_resolve_session_uses_request_model_for_claude_cli(self):
captured, process_patch = _capture_subprocess()
with (