Skip to content
Merged
3 changes: 2 additions & 1 deletion src/proovy_agent/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

from fastapi import APIRouter

from proovy_agent.app.api.v1 import health
from proovy_agent.app.api.v1 import health, solve

router = APIRouter()
router.include_router(health.router, tags=["health"])
router.include_router(solve.router, tags=["solve"])
64 changes: 64 additions & 0 deletions src/proovy_agent/app/api/v1/solve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""문제 풀이 API 엔드포인트."""

import asyncio
from collections.abc import AsyncGenerator
import logging
import uuid

from fastapi import APIRouter
from langchain_core.messages import HumanMessage
from sse_starlette.sse import EventSourceResponse

from proovy_agent.app.schemas.solve import SolveRequest
from proovy_agent.common.sse.context import current_emitter
from proovy_agent.common.sse.emitter import SSEEmitter
from proovy_agent.graph.builder import get_graph
from proovy_agent.graph.state import ProovyState

router = APIRouter()
logger = logging.getLogger(__name__)

# fire-and-forget 태스크 참조 유지 (GC 방지)
_active_tasks: set[asyncio.Task[None]] = set()


def _build_initial_state(request: SolveRequest) -> ProovyState:
thread_id = request.thread_id or str(uuid.uuid4())
return ProovyState(
raw_input={"problem": request.problem},
user_id=request.user_id,
thread_id=thread_id,
messages=[HumanMessage(content=request.problem)],
)


@router.post("/solve", response_class=EventSourceResponse)
async def solve_endpoint(request: SolveRequest) -> EventSourceResponse:
"""수학 문제를 SSE로 스트리밍하며 풀이합니다."""
emitter = SSEEmitter()
state = _build_initial_state(request)

async def _run() -> None:
token = current_emitter.set(emitter)
try:
await get_graph().ainvoke(state)
except asyncio.CancelledError:
logger.info("클라이언트 연결 종료로 solve 태스크가 취소되었습니다.")
except Exception as exc:
logger.exception("solve 실행 중 오류 발생")
if not getattr(exc, "sse_emitted", False):
await emitter.emit("error", {"message": "풀이 중 오류가 발생했습니다."})
finally:
await emitter.close()
current_emitter.reset(token)

# 그래프 태스크는 SSE 연결과 독립적으로 실행 — disconnect 시에도 풀이가 완료됨
task: asyncio.Task[None] = asyncio.create_task(_run())
_active_tasks.add(task)
task.add_done_callback(_active_tasks.discard)

async def _stream() -> AsyncGenerator:
async for event in emitter.stream():
yield event

return EventSourceResponse(_stream())
9 changes: 9 additions & 0 deletions src/proovy_agent/app/schemas/solve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""문제 풀이 요청 스키마."""

from pydantic import BaseModel, Field


class SolveRequest(BaseModel):
problem: str = Field(..., description="풀어야 할 수학 문제")
user_id: str = Field(..., description="사용자 ID")
thread_id: str | None = Field(None, description="대화 쓰레드 ID (없으면 자동 생성)")
4 changes: 2 additions & 2 deletions src/proovy_agent/common/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from proovy_agent.common.config import get_settings

MODEL_MAP: dict[str, str] = {
"flash": "google/gemini-2.0-flash-001",
"flash": "google/gemini-2.5-flash", # 2.0→2.5: structured output 안정성 개선
"sonnet": "anthropic/claude-sonnet-4-5",
"opus": "anthropic/claude-opus-4-5",
"opus": "anthropic/claude-sonnet-4-5", # opus 통합: 속도 3-4배↑, 품질 동등
}

_cache: dict[str, ChatOpenRouter] = {}
Expand Down
11 changes: 11 additions & 0 deletions src/proovy_agent/common/sandbox/executor_var.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Context variable for the current sandbox executor."""

from __future__ import annotations

from contextvars import ContextVar
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from proovy_agent.common.sandbox.executor import CodeExecutor

current_executor: ContextVar[CodeExecutor | None] = ContextVar("current_executor", default=None)
8 changes: 8 additions & 0 deletions src/proovy_agent/common/sse/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Context variable for the current SSE emitter."""

from contextvars import ContextVar

from proovy_agent.common.sse.emitter import SSEEmitter

# CoreSolver 노드가 실행 전에 set(), tool들이 get()으로 참조
current_emitter: ContextVar[SSEEmitter | None] = ContextVar("current_emitter", default=None)
14 changes: 12 additions & 2 deletions src/proovy_agent/common/sse/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,25 @@ async def emit(self, event: EventType, data: dict) -> None:
if self._closed:
logger.debug("emit() 무시됨 — 이미 닫힌 이미터 (event=%s)", event)
return
await self._queue.put(SSEEvent(event=event, data=data))
# 락 해제 후 non-blocking put — 큐 가득 차면 드롭 (best-effort 정책)
try:
self._queue.put_nowait(SSEEvent(event=event, data=data))
except asyncio.QueueFull:
logger.warning("SSE 큐 포화 — 이벤트 드롭 (event=%s)", event)

async def close(self) -> None:
"""스트림 종료를 알리는 sentinel을 큐에 삽입한다."""
async with self._close_lock:
if self._closed:
return
self._closed = True
await self._queue.put(None)
# 락 밖에서 동기적으로 드레인 후 sentinel 삽입 — 큐 포화로 인한 블로킹 없음
while not self._queue.empty():
try:
self._queue.get_nowait()
except asyncio.QueueEmpty:
break
self._queue.put_nowait(None)

async def stream(self) -> AsyncIterator[dict[str, str]]:
"""sentinel(None)을 받을 때까지 sse-starlette 호환 dict를 yield한다."""
Expand Down
Loading