From 9ff67e04a7900b3b77f89d6182fbe2c504d44221 Mon Sep 17 00:00:00 2001 From: areum117 Date: Tue, 30 Jun 2026 05:58:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(todos):=20=ED=94=8C=EB=9E=98=EB=84=88=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/todos/ai_client.py | 44 ++++++++++++++++++++++++++++++---------- apps/todos/urls.py | 2 ++ apps/todos/views.py | 18 ++++++++++++++-- docs/backend-api-flow.md | 22 ++++++++++++++++---- tests/test_todos_ai.py | 36 ++++++++++++++++++++++++-------- 5 files changed, 97 insertions(+), 25 deletions(-) diff --git a/apps/todos/ai_client.py b/apps/todos/ai_client.py index 6365853..46f6f8f 100644 --- a/apps/todos/ai_client.py +++ b/apps/todos/ai_client.py @@ -38,6 +38,17 @@ def generate(self, *, user_id: str, prompt: str, today: str) -> dict[str, Any]: def chat( self, *, user_id: str, message: str, today: str, thread_id: str | None ) -> dict[str, Any]: + job_id = self.submit_chat( + user_id=user_id, + message=message, + today=today, + thread_id=thread_id, + ) + return self.poll_chat(job_id=job_id) + + def submit_chat( + self, *, user_id: str, message: str, today: str, thread_id: str | None + ) -> str: payload: dict[str, Any] = { "mode": "multi", "user_id": user_id, @@ -46,11 +57,18 @@ def chat( } if thread_id: payload["thread_id"] = thread_id - return self._submit_and_poll( - submit_path="/v1/todo/chat", - poll_prefix="/v1/todo/chat", - payload=payload, - ) + return self._submit_job(submit_path="/v1/todo/chat", payload=payload) + + def poll_chat(self, *, job_id: str) -> dict[str, Any]: + body = self._request("GET", f"/v1/todo/chat/{job_id}") + job_status = body.get("status") + if job_status == "done": + return body + if job_status == "pending": + return body + if job_status == "error": + return body + raise TodoAIClientError(f"mongle-ai returned unknown job status: {body}") def generate_quests( self, @@ -80,12 +98,7 @@ def _submit_and_poll( 개별 요청은 짧은 타임아웃, 전체 대기는 self.timeout_seconds 까지 폴링한다. Pod 프록시 100s 하드 타임아웃을 개별 요청이 넘지 않게 분리한다. """ - submit = self._request("POST", submit_path, payload=payload) - job_id = (submit.get("result") or {}).get("job_id") - if not job_id: - raise TodoAIClientError( - f"mongle-ai submit 응답에 job_id 가 없습니다: {submit}" - ) + job_id = self._submit_job(submit_path=submit_path, payload=payload) deadline = time.monotonic() + self.timeout_seconds while True: @@ -103,6 +116,15 @@ def _submit_and_poll( raise TodoAIClientError("mongle-ai 폴링 타임아웃") time.sleep(_POLL_INTERVAL_SECONDS) + def _submit_job(self, *, submit_path: str, payload: dict[str, Any]) -> str: + submit = self._request("POST", submit_path, payload=payload) + job_id = (submit.get("result") or {}).get("job_id") + if not job_id: + raise TodoAIClientError( + f"mongle-ai submit 응답에 job_id 가 없습니다: {submit}" + ) + return str(job_id) + @staticmethod def _unwrap_result(body: dict[str, Any]) -> dict[str, Any]: result = body.get("result") diff --git a/apps/todos/urls.py b/apps/todos/urls.py index 4a60ea8..0a8069c 100644 --- a/apps/todos/urls.py +++ b/apps/todos/urls.py @@ -1,6 +1,7 @@ from django.urls import path from apps.todos.views import ( + TodoChatAIJobView, TodoChatAIView, TodoCompleteView, TodoConfirmView, @@ -17,6 +18,7 @@ urlpatterns = [ path("generate/", TodoGenerateAIView.as_view(), name="todo-generate"), path("chat/", TodoChatAIView.as_view(), name="todo-chat"), + path("chat//", TodoChatAIJobView.as_view(), name="todo-chat-job"), path( "planner-confirm/", TodoPlannerConfirmView.as_view(), diff --git a/apps/todos/views.py b/apps/todos/views.py index 878c22b..ebde39d 100644 --- a/apps/todos/views.py +++ b/apps/todos/views.py @@ -122,7 +122,7 @@ def post(self, request) -> Response: today = timezone.localdate().isoformat() try: - result = _todo_ai_client().chat( + job_id = _todo_ai_client().submit_chat( user_id=str(user.user_id), message=serializer.validated_data["message"], today=today, @@ -130,7 +130,21 @@ def post(self, request) -> Response: ) except TodoAIClientError as err: return Response({"error": str(err)}, status=status.HTTP_502_BAD_GATEWAY) - return Response(result, status=status.HTTP_200_OK) + return Response( + {"status": "pending", "result": {"job_id": job_id}, "error": None}, + status=status.HTTP_202_ACCEPTED, + ) + + +class TodoChatAIJobView(APIView): + permission_classes = (IsAuthenticated,) + + def get(self, request, job_id: str) -> Response: + try: + envelope = _todo_ai_client().poll_chat(job_id=job_id) + except TodoAIClientError as err: + return Response({"error": str(err)}, status=status.HTTP_502_BAD_GATEWAY) + return Response(envelope, status=status.HTTP_200_OK) class TodoPlannerConfirmView(APIView): diff --git a/docs/backend-api-flow.md b/docs/backend-api-flow.md index a791202..fed8c3b 100644 --- a/docs/backend-api-flow.md +++ b/docs/backend-api-flow.md @@ -45,10 +45,22 @@ mongle-server X-API-Key: {MONGLE_AI_API_KEY} mongle-ai - follow_up 또는 candidates 반환 + 202 pending + job_id 반환 + +mongle-web + GET /api/v1/todos/chat/{job_id}/ + Authorization: Bearer {access_token} + +mongle-server + GET {MONGLE_AI_API_BASE}/v1/todo/chat/{job_id} + X-Internal-Service-Token: {MONGLE_AI_API_KEY} + X-API-Key: {MONGLE_AI_API_KEY} + +mongle-ai + pending, error, done + follow_up 또는 candidates 반환 ``` -프론트는 `/todos/chat/` 응답이 `follow_up`이면 같은 `thread_id`로 대화를 이어가고, `candidates`이면 생성된 계획을 화면에 보여준다. +프론트는 `/todos/chat/` 응답으로 받은 `job_id`를 폴링한다. 최종 응답이 `follow_up`이면 같은 `thread_id`로 대화를 이어가고, `candidates`이면 생성된 계획을 화면에 보여준다. 플랜 생성은 RunPod/LLM 호출 시간이 길 수 있으므로 브라우저 요청 하나를 끝까지 붙잡지 않는다. ### 2. 프론트 플래너 저장 @@ -96,7 +108,8 @@ mongle-server | Endpoint | 호출자 | 인증 | 역할 | | --- | --- | --- | --- | | `POST /api/v1/todos/generate/` | Web | `Authorization` | 싱글턴 TODO 후보 생성 | -| `POST /api/v1/todos/chat/` | Web | `Authorization` | 멀티턴 플래너 대화 | +| `POST /api/v1/todos/chat/` | Web | `Authorization` | 멀티턴 플래너 대화 job 생성 | +| `GET /api/v1/todos/chat/{job_id}/` | Web | `Authorization` | 멀티턴 플래너 대화 job 상태/결과 조회 | | `POST /api/v1/todos/planner-confirm/` | Web | `Authorization` | 플래너 후보를 현재 사용자 Todo/Schedule로 저장 | | `POST /api/v1/todos/confirm/` | Web | `Authorization` | 일반 TODO 후보 확정 저장 | | `POST /api/v1/todos/commit/` | Internal/AI | `X-Internal-Service-Token` | 내부 commit 계약 | @@ -106,7 +119,8 @@ mongle-server | AI endpoint | Django 호출 위치 | 역할 | | --- | --- | --- | | `POST /v1/todo/generate` | `TodoAIClient.generate()` | 싱글턴 후보 생성 | -| `POST /v1/todo/chat` | `TodoAIClient.chat()` | 플래너 멀티턴 대화 | +| `POST /v1/todo/chat` | `TodoAIClient.submit_chat()` | 플래너 멀티턴 대화 job 생성 | +| `GET /v1/todo/chat/{job_id}` | `TodoAIClient.poll_chat()` | 플래너 멀티턴 대화 job 상태/결과 조회 | | `POST /v1/todo/commit` | 현재 Django에서 직접 사용하지 않음 | AI 내부 commit 그래프 | | `POST /v1/quest/generate` | `_assign_quests_to_todos()` | 저장된 오늘 Todo에 퀘스트 분배 | diff --git a/tests/test_todos_ai.py b/tests/test_todos_ai.py index c2b1ff1..11123f0 100644 --- a/tests/test_todos_ai.py +++ b/tests/test_todos_ai.py @@ -73,12 +73,7 @@ def test_todo_generate_returns_502_when_ai_fails(auth_client) -> None: @override_settings(MONGLE_AI_API_KEY=TEST_INTERNAL_TOKEN) def test_todo_chat_returns_follow_up_for_authenticated_user(auth_client) -> None: with patch("apps.todos.views._todo_ai_client") as factory: - factory.return_value.chat.return_value = { - "kind": "follow_up", - "thread_id": "thread-2", - "question": "언제까지 끝내고 싶으세요?", - "missing_aspects": ["deadline"], - } + factory.return_value.submit_chat.return_value = "job-123" response = auth_client.post( "/api/v1/todos/chat/", @@ -86,9 +81,34 @@ def test_todo_chat_returns_follow_up_for_authenticated_user(auth_client) -> None content_type="application/json", ) + assert response.status_code == 202 + assert response.json()["status"] == "pending" + assert response.json()["result"]["job_id"] == "job-123" + factory.return_value.submit_chat.assert_called_once() + + +@pytest.mark.django_db +@override_settings(MONGLE_AI_API_KEY=TEST_INTERNAL_TOKEN) +def test_todo_chat_job_poll_returns_ai_envelope(auth_client) -> None: + job_id = "11111111111111111111111111111111" + with patch("apps.todos.views._todo_ai_client") as factory: + factory.return_value.poll_chat.return_value = { + "status": "done", + "result": { + "kind": "follow_up", + "thread_id": "thread-2", + "question": "언제까지 끝내고 싶으세요?", + "missing_aspects": ["deadline"], + }, + "error": None, + } + + response = auth_client.get(f"/api/v1/todos/chat/{job_id}/") + assert response.status_code == 200 - assert response.json()["kind"] == "follow_up" - assert response.json()["thread_id"] == "thread-2" + assert response.json()["status"] == "done" + assert response.json()["result"]["kind"] == "follow_up" + factory.return_value.poll_chat.assert_called_once_with(job_id=job_id) @pytest.mark.django_db