Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions apps/todos/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions apps/todos/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path

from apps.todos.views import (
TodoChatAIJobView,
TodoChatAIView,
TodoCompleteView,
TodoConfirmView,
Expand All @@ -17,6 +18,7 @@
urlpatterns = [
path("generate/", TodoGenerateAIView.as_view(), name="todo-generate"),
path("chat/", TodoChatAIView.as_view(), name="todo-chat"),
path("chat/<str:job_id>/", TodoChatAIJobView.as_view(), name="todo-chat-job"),
path(
"planner-confirm/",
TodoPlannerConfirmView.as_view(),
Expand Down
18 changes: 16 additions & 2 deletions apps/todos/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,29 @@ 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,
thread_id=serializer.validated_data.get("thread_id") or None,
)
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):
Expand Down
22 changes: 18 additions & 4 deletions docs/backend-api-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 프론트 플래너 저장

Expand Down Expand Up @@ -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 계약 |
Expand All @@ -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에 퀘스트 분배 |

Expand Down
36 changes: 28 additions & 8 deletions tests/test_todos_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,42 @@ 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/",
data={"message": "시험 공부 해야 해", "thread_id": 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
Expand Down
Loading