From e535100aef248574098ecbcad846df71a4fb355a Mon Sep 17 00:00:00 2001 From: "jinhee.lee" Date: Fri, 22 Aug 2025 23:59:32 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20Refactor=20project?= =?UTF-8?q?=20structure=20with=20backend=20and=20chatbot=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add environment example (.env-example) - Setup Makefile for development tasks - Configure pyproject.toml for proper package structure - Organize code into app/, chatbot/, backend/ directories --- .env-example | 1 + .gitignore | 175 +++++------------- .idea/.gitignore | 8 - .idea/aws.xml | 11 -- .idea/git_toolbox_prj.xml | 15 -- .../inspectionProfiles/profiles_settings.xml | 6 - .idea/material_theme_project_new.xml | 12 -- .idea/misc.xml | 7 - .idea/modules.xml | 8 - .idea/vcs.xml | 12 -- .idea/your-mode-fast-api.iml | 10 - .pre-commit-config.yaml | 20 ++ Dockerfile | 9 - Dockerfile.build | 34 ---- Makefile | 39 ++++ README.md | 24 +-- app/api/assistant.py | 29 ++- app/main.py | 22 ++- app/schemas/chat.py | 12 +- app/schemas/content.py | 4 +- app/schemas/diagnosis.py | 18 +- app/services/assistant_service.py | 171 ++++++++++------- backend/__init__.py | 1 + backend/main.py | 43 +++++ chatbot/__init__.py | 1 + chatbot/assistant.py | 27 +++ chatbot/schemas.py | 118 ++++++++++++ pyproject.toml | 90 +++++++++ requirements-dev.txt | 4 - requirements.txt | 4 - 30 files changed, 552 insertions(+), 383 deletions(-) create mode 100644 .env-example delete mode 100644 .idea/.gitignore delete mode 100644 .idea/aws.xml delete mode 100644 .idea/git_toolbox_prj.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/material_theme_project_new.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/your-mode-fast-api.iml create mode 100644 .pre-commit-config.yaml delete mode 100644 Dockerfile delete mode 100644 Dockerfile.build create mode 100644 Makefile create mode 100644 backend/__init__.py create mode 100644 backend/main.py create mode 100644 chatbot/__init__.py create mode 100644 chatbot/assistant.py create mode 100644 chatbot/schemas.py create mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..3cac181 --- /dev/null +++ b/.env-example @@ -0,0 +1 @@ +OPENAI_API_KEY=your_openai_api_key_here diff --git a/.gitignore b/.gitignore index 6b14aa6..09cdafc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,165 +1,74 @@ -### Python template -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so # Distribution / packaging -.Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST -python - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ +# Testing and coverage .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ .pytest_cache/ -cover/ - -# Translations -*.mo -*.pot +htmlcov/ +.tox/ +.nox/ -# Django stuff: +# Logs *.log -local_settings.py -db.sqlite3 -db.sqlite3-journal -# Flask stuff: +# IDE and Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Local development instance/ .webassets-cache +local_settings.py +db.sqlite3 +db.sqlite3-journal -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook +# Jupyter .ipynb_checkpoints -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - # mypy .mypy_cache/ .dmypy.json dmypy.json -# Pyre type checker +# uv +.uv/ +uv.lock + +# Make +*.o +*.a +*.so +*.dylib + +# Ruff +.ruff_cache/ + +# Pyre .pyre/ -# pytype static type analyzer +# pytype .pytype/ -# Cython debug symbols +# Cython cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index c3f502a..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/aws.xml b/.idea/aws.xml deleted file mode 100644 index b63b642..0000000 --- a/.idea/aws.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml deleted file mode 100644 index 02b915b..0000000 --- a/.idea/git_toolbox_prj.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml deleted file mode 100644 index a4effe7..0000000 --- a/.idea/material_theme_project_new.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index c8c60c3..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 97b76c1..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 7ddfc9e..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/your-mode-fast-api.iml b/.idea/your-mode-fast-api.iml deleted file mode 100644 index 0eee04b..0000000 --- a/.idea/your-mode-fast-api.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..024c5a0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + exclude: "license.txt|\\.md$" + - id: mixed-line-ending + - id: check-added-large-files + args: ["--maxkb=30000"] + - id: requirements-txt-fixer + + - repo: local + hooks: + - id: format + name: format + language: system + entry: make format diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0c2c394..0000000 --- a/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY app/ app/ -EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.build b/Dockerfile.build deleted file mode 100644 index 1beaf43..0000000 --- a/Dockerfile.build +++ /dev/null @@ -1,34 +0,0 @@ -# Dockerfile.build - -# 1) AWS Lambda Python 3.12 x86_64 -FROM public.ecr.aws/lambda/python:3.12 AS base - -# 2) Layer 빌드 -FROM base AS layer-builder -RUN microdnf install -y zip findutils \ - && pip install --upgrade pip - -WORKDIR /layer -COPY requirements.txt . - -RUN pip install --no-cache-dir \ - -r requirements.txt \ - --target python \ - && find python -type d -name "__pycache__" -exec rm -rf {} + \ - && find python -type d -name "tests" -exec rm -rf {} + \ - && rm -rf python/*.dist-info \ - && zip -9 -r /layer.zip python - -# 3) Function code 빌드 -FROM base AS func-builder -RUN microdnf install -y zip - -WORKDIR /package -COPY app ./app -RUN zip -9 -r /function.zip app - -# 4) Artifact stage -FROM scratch AS artifacts -COPY --from=layer-builder /layer.zip /layer.zip -COPY --from=func-builder /function.zip /function.zip -CMD ["true"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a2c06d3 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: help install install-dev run test check fix clean dev-setup all add-pkg add-dev-pkg build health + +help: ## Show help + @grep -E '^[.a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +init: ## Install production dependencies + uv pip install -e . + +init-dev: ## Install development dependencies + uv pip install -U pip + uv pip install -e ".[dev]" + pre-commit install --overwrite + +run: ## Run development server + uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +test: ## Run tests + uv run pytest + +check: ## Check code quality (lint + format check) + uv run ruff check . + uv run ruff format --check . + +fix: ## Fix code automatically + uv run ruff check --fix . + uv run ruff format . + +format: ## Format code only + uv run ruff format . + +clean: ## Clean cache files + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".coverage" -delete 2>/dev/null || true + find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true diff --git a/README.md b/README.md index 30909d8..3ec5963 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🌐 YourMode FastAPI -OpenAI Assistants API와 FastAPI를 활용한 YourMode 백엔드 서버입니다. +OpenAI Assistants API와 FastAPI를 활용한 YourMode 백엔드 서버입니다. 체형 분석 및 패션 스타일링 기능과 개인화 콘텐츠 추천 기능을 지원합니다. --- @@ -8,15 +8,15 @@ OpenAI Assistants API와 FastAPI를 활용한 YourMode 백엔드 서버입니다 ## 📁 프로젝트 구조 ```bash -my_assistant_app/ -├── app/ -│ ├── main.py # FastAPI 엔트리포인트 -│ ├── api/ -│ │ └── assistant.py # Assistant API 라우터 -│ ├── services/ -│ │ └── assistant_service.py # OpenAI API 호출 로직 -│ └── schemas/ -│ └── assistant.py # Pydantic 기반 요청/응답 모델 정의 -├── .env # 환경변수 (OPENAI_API_KEY 등) -├── requirements.txt +your-mode-fast-api/ +├── backend/ # 백엔드 서버 관련 +│ ├── main.py # FastAPI 메인 애플리케이션 +│ └── __init__.py +├── chatbot/ # 챗봇 관련 기능 +│ ├── assistant.py # 챗봇 API 라우터 +│ ├── schemas.py # 통합 스키마 (채팅, 콘텐츠, 진단) +│ └── __init__.py +├── .env-example # 환경변수 템플릿 +├── pyproject.toml # 프로젝트 설정 및 의존성 └── README.md +``` \ No newline at end of file diff --git a/app/api/assistant.py b/app/api/assistant.py index e226e99..ac3efeb 100644 --- a/app/api/assistant.py +++ b/app/api/assistant.py @@ -2,10 +2,16 @@ from fastapi.responses import JSONResponse from app.schemas.chat import ChatRequest, ChatResponse -from app.schemas.diagnosis import DiagnoseRequest, DiagnoseResponse from app.schemas.content import CreateContentRequest -from app.services.assistant_service import diagnose_body_type_with_assistant, create_content, chat_body_assistant, \ - chat_body_result, chat_body_result_soft, get_run_status, get_run_result +from app.schemas.diagnosis import DiagnoseRequest, DiagnoseResponse +from app.services.assistant_service import ( + chat_body_assistant, + chat_body_result, + create_content, + diagnose_body_type_with_assistant, + get_run_result, + get_run_status, +) router = APIRouter() @@ -32,11 +38,13 @@ def recommend_content(request: CreateContentRequest): recommended_situation=request.recommended_situation, recommended_style=request.recommended_style, avoid_style=request.avoid_style, - budget=request.budget + budget=request.budget, ) -@router.post("/chat", description="체형 진단 개별 질문에 대한 응답", response_model=ChatResponse) +@router.post( + "/chat", description="체형 진단 개별 질문에 대한 응답", response_model=ChatResponse +) def chat(request: ChatRequest): return chat_body_assistant(request.question, request.answer) @@ -50,6 +58,7 @@ def chat(request: ChatRequest): # gender=request.gender, # ) + @router.post("/body-result") def post_body_result(request: DiagnoseRequest): try: @@ -60,7 +69,7 @@ def post_body_result(request: DiagnoseRequest): gender=request.gender, ) except Exception as e: - raise HTTPException(502, f"assistants error: {e}") + raise HTTPException(502, f"assistants error: {e}") from e # 완료면 dict(결과) → 200 if "thread_id" not in out: @@ -69,13 +78,15 @@ def post_body_result(request: DiagnoseRequest): # 미완료면 202로 run 식별자 반환 return JSONResponse(status_code=202, content=out) + # --- 폴링: 상태 조회 --- @router.get("/run-status") def run_status(thread_id: str, run_id: str): try: return get_run_status(thread_id, run_id) except Exception as e: - raise HTTPException(502, f"assistants status error: {e}") + raise HTTPException(502, f"assistants status error: {e}") from e + # --- 폴링: 결과 조회 --- @router.get("/run-result", response_model=DiagnoseResponse) @@ -83,7 +94,7 @@ def run_result(thread_id: str, run_id: str): try: data = get_run_result(thread_id, run_id) except Exception as e: - raise HTTPException(502, f"assistants result error: {e}") + raise HTTPException(502, f"assistants result error: {e}") from e if data.get("status") != "completed": # 아직 준비 안 됨 @@ -91,4 +102,4 @@ def run_result(thread_id: str, run_id: str): # completed이면 DiagnoseResponse 스키마로 반환 data.pop("status", None) - return data \ No newline at end of file + return data diff --git a/app/main.py b/app/main.py index f52e29f..33d74e1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,23 +1,33 @@ +import logging + from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from mangum import Mangum +from starlette.types import ASGIApp from app.api.assistant import router as assistant_router -from mangum import Mangum -import logging app = FastAPI() logger = logging.getLogger("app.logger") + @app.middleware("http") -async def log_path(request: Request, call_next): +async def log_path(request: Request, call_next: ASGIApp): logger.info(f"▶▶ Raw request path: {request.url.path}") return await call_next(request) + app.add_middleware( CORSMiddleware, - allow_origins=["https://style-me-wine.vercel.app", "http://localhost:3000", "http://localhost:5173", "https://spring.yourmode.co.kr", "https://yourmode.co.kr/"], # 허용할 프론트 도메인 - allow_methods=["*"], # 모든 HTTP 메서드 허용 (GET, POST, OPTIONS 등) - allow_headers=["*"], # 모든 헤더 허용 (Content-Type 등) + allow_origins=[ + "https://style-me-wine.vercel.app", + "http://localhost:3000", + "http://localhost:5173", + "https://spring.yourmode.co.kr", + "https://yourmode.co.kr/", + ], # 허용할 프론트 도메인 + allow_methods=["*"], # 모든 HTTP 메서드 허용 (GET, POST, OPTIONS 등) + allow_headers=["*"], # 모든 헤더 허용 (Content-Type 등) ) app.include_router(assistant_router, prefix="/assistant") diff --git a/app/schemas/chat.py b/app/schemas/chat.py index ddc9c85..dbecf85 100644 --- a/app/schemas/chat.py +++ b/app/schemas/chat.py @@ -1,8 +1,8 @@ -from pydantic import Field, BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class ChatRequest(BaseModel): - question: str = Field(..., description="질문"), + question: str = (Field(..., description="질문"),) answer: str = Field(..., description="응답") model_config = ConfigDict( @@ -16,17 +16,17 @@ class ChatRequest(BaseModel): class ChatResponse(BaseModel): - isSuccess: bool + is_success: bool selected: str message: str - nextQuestion: str + next_question: str class Config: schema_extra = { "example": { - "isSuccess": "스트레이트", + "is_success": "스트레이트", "selected": "어깨와 엉덩이 폭이 비슷한...", "message": "...", - "nextQuestion": "...", + "next_question": "...", } } diff --git a/app/schemas/content.py b/app/schemas/content.py index 8f3faac..7c0750d 100644 --- a/app/schemas/content.py +++ b/app/schemas/content.py @@ -1,4 +1,4 @@ -from pydantic import Field, BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class CreateContentRequest(BaseModel): @@ -25,7 +25,7 @@ class CreateContentRequest(BaseModel): "recommended_situation": "IR발표", "recommended_style": "IR 발표에 어울리는 스타일", "avoid_style": "스트릿,힙한 스타일", - "budget": "20만원" + "budget": "20만원", } } ) diff --git a/app/schemas/diagnosis.py b/app/schemas/diagnosis.py index 80cc550..c9212d5 100644 --- a/app/schemas/diagnosis.py +++ b/app/schemas/diagnosis.py @@ -1,11 +1,11 @@ -from typing import List -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, ConfigDict, Field + class DiagnoseRequest(BaseModel): - answers: List[str] = Field(..., description="설문 응답 리스트") - height: float = Field(..., description="키 (cm)") - weight: float = Field(..., description="체중 (kg)") - gender: str = Field(..., description="성별") + answers: list[str] = Field(..., description="설문 응답 리스트") + height: float = Field(..., description="키 (cm)") + weight: float = Field(..., description="체중 (kg)") + gender: str = Field(..., description="성별") model_config = ConfigDict( json_schema_extra={ @@ -27,11 +27,11 @@ class DiagnoseRequest(BaseModel): "쇄골이 거의 보이지 않는다", "둥근 얼굴이며, 볼이 통통한 편이다", "상체가 발달한 느낌이며 허리가 짧고 탄탄한 인상을 준다", - "팔, 가슴, 배 등 상체 위주로 찐다" + "팔, 가슴, 배 등 상체 위주로 찐다", ], "height": 164.5, "weight": 55.2, - "gender": "여성" + "gender": "여성", } } ) @@ -57,6 +57,6 @@ class Config: "recommended_styles": "...", "avoid_styles": "...", "styling_fixes": "...", - "styling_tips": "..." + "styling_tips": "...", } } diff --git a/app/services/assistant_service.py b/app/services/assistant_service.py index da0b0f2..86c12f1 100644 --- a/app/services/assistant_service.py +++ b/app/services/assistant_service.py @@ -1,9 +1,11 @@ -import re +import contextlib import json -import time import os +import re +import time +from typing import Any, Optional + from openai import OpenAI -from typing import Any, Dict, Optional if os.getenv("AWS_LAMBDA_FUNCTION_NAME") is None: from dotenv import load_dotenv @@ -30,7 +32,12 @@ def _extract_json(raw: str) -> dict: m = re.search(r"```json\s*(\{.*?\})\s*```", raw, re.DOTALL) json_str = m.group(1) if m else raw # 2) 불필요한 ``` 제거 (혹시 raw에만 있을 때) - json_str = json_str.strip().lstrip("```").rstrip("```").strip() + json_str = json_str.strip() + if json_str.startswith("```"): + json_str = json_str[3:] + if json_str.endswith("```"): + json_str = json_str[:-3] + json_str = json_str.strip() # 3) 파싱 return json.loads(json_str, strict=False) @@ -60,13 +67,16 @@ def _extract_json(raw: str) -> dict: "additionalProperties": False, } + def _build_prompt(answers: list[str], height: float, weight: float, gender: str) -> str: return ( "당신은 골격 진단 및 패션 스타일리스트입니다.\n" "아래 사용자 정보를 바탕으로 체형을 진단하고, 반드시 JSON으로만 응답하세요.\n" - "출력은 다음 스키마의 각 필드를 한국어로 충실히 채우세요. 모든 값은 문자열입니다.\n" - "필드: body_type, type_description, detailed_features, attraction_points, " - "recommended_styles, avoid_styles, styling_fixes, styling_tips\n\n" + "출력은 다음 스키마의 각 필드를 한국어로 충실히 채우세요. " + "모든 값은 문자열입니다.\n" + "필드: body_type, type_description, detailed_features, " + "attraction_points, recommended_styles, avoid_styles, " + "styling_fixes, styling_tips\n\n" f"- 성별: {gender}\n" f"- 키: {height}cm\n" f"- 체중: {weight}kg\n" @@ -75,8 +85,9 @@ def _build_prompt(answers: list[str], height: float, weight: float, gender: str) + "\n\n주의: 코드블록 없이 순수 JSON만 출력하세요." ) + # ---------- 안전한 메시지 텍스트 추출기 ---------- -def _as_dict(obj): +def _as_dict(obj: any) -> dict: try: if hasattr(obj, "model_dump"): return obj.model_dump() @@ -86,7 +97,8 @@ def _as_dict(obj): pass return obj if isinstance(obj, dict) else json.loads(json.dumps(obj, default=str)) -def _extract_first_text_from_content_items(items): + +def _extract_first_text_from_content_items(items: list) -> str | None: for item in items or []: d = _as_dict(item) itype = d.get("type") @@ -109,7 +121,8 @@ def _extract_first_text_from_content_items(items): return d[key] return None -def _extract_first_text_from_message(msg): + +def _extract_first_text_from_message(msg: any) -> str | None: m = _as_dict(msg) for key in ("text", "output_text"): v = m.get(key) @@ -122,6 +135,7 @@ def _extract_first_text_from_message(msg): return _extract_first_text_from_content_items(content) return None + def diagnose_body_type_with_assistant( answers: list[str], height: float, @@ -129,7 +143,7 @@ def diagnose_body_type_with_assistant( gender: str, *, timeout_sec: int = 60, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ 1) 사용자 정보로 prompt 구성 2) create_and_run 으로 assistant 호출 (JSON 스키마 강제) @@ -177,15 +191,14 @@ def diagnose_body_type_with_assistant( raise ValueError("No assistant message found") # created_at이 있다면 최신 정렬, 없다면 그대로 첫 번째 사용 - try: + with contextlib.suppress(Exception): assistant_msgs.sort(key=lambda m: getattr(m, "created_at", 0), reverse=True) - except Exception: - pass first = assistant_msgs[0] if not first.content or getattr(first.content[0], "type", "text") != "text": # 도구 호출 등 다른 타입이 섞였을 가능성 방어 - raise ValueError(f"Unexpected message content type: {getattr(first.content[0], 'type', 'unknown')}") + content_type = getattr(first.content[0], "type", "unknown") + raise ValueError(f"Unexpected message content type: {content_type}") raw = first.content[0].text.value.strip() @@ -194,22 +207,24 @@ def diagnose_body_type_with_assistant( return json.loads(raw) except Exception as e: print("🛠️ [DEBUG] raw from assistant:\n", raw) - raise ValueError(f"JSON 파싱 실패: {e}") + raise ValueError(f"JSON 파싱 실패: {e}") from e def create_content( - name: str, - body_type: str, - height: int, - weight: int, - body_feature: str, - recommendation_items: list[str], - recommended_situation: str, - recommended_style: str, - avoid_style: str, - budget: str, + name: str, + body_type: str, + height: int, + weight: int, + body_feature: str, + recommendation_items: list[str], + recommended_situation: str, + recommended_style: str, + avoid_style: str, + budget: str, ): - items_section = "\n".join(f"{i + 1}. {item}" for i, item in enumerate(recommendation_items)) + items_section = "\n".join( + f"{i + 1}. {item}" for i, item in enumerate(recommendation_items) + ) prompt = ( "다음 정보를 바탕으로 **스타일 추천 콘텐츠 초안**을 작성해줘.\n\n" f"- 이름: {name}\n" @@ -228,16 +243,13 @@ def create_content( run = client.beta.threads.create_and_run( assistant_id=STYLE_ASSISTANT_ID, - thread={"messages": [{"role": "user", "content": prompt}]} + thread={"messages": [{"role": "user", "content": prompt}]}, ) thread_id = run.thread_id run_id = run.id while True: - status = client.beta.threads.runs.retrieve( - thread_id=thread_id, - run_id=run_id - ) + status = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) if status.status == "completed": break time.sleep(0.3) @@ -291,11 +303,17 @@ def chat_body_assistant(question: str, answer: str): time.sleep(0.3) msgs = client.beta.threads.messages.list(thread_id=thread_id).data - assistant_msg = next((m for m in msgs if getattr(m, "role", "") == "assistant"), None) + assistant_msg = next( + (m for m in msgs if getattr(m, "role", "") == "assistant"), None + ) if assistant_msg is None: raise ValueError("No assistant message found") - parts = [p for p in getattr(assistant_msg, "content", []) if getattr(p, "type", "") == "text"] + parts = [ + p + for p in getattr(assistant_msg, "content", []) + if getattr(p, "type", "") == "text" + ] if not parts: raise ValueError("Assistant message has no text content") @@ -315,12 +333,7 @@ def chat_body_assistant(question: str, answer: str): return data -def chat_body_result( - answers: list[str], - height: float, - weight: float, - gender: str -): +def chat_body_result(answers: list[str], height: float, weight: float, gender: str): schema = { "type": "object", "properties": { @@ -331,7 +344,7 @@ def chat_body_result( "recommended_styles": {"type": "string"}, "avoid_styles": {"type": "string"}, "styling_fixes": {"type": "string"}, - "styling_tips": {"type": "string"} + "styling_tips": {"type": "string"}, }, "required": [ "body_type", @@ -341,20 +354,20 @@ def chat_body_result( "recommended_styles", "avoid_styles", "styling_fixes", - "styling_tips" + "styling_tips", ], - "additionalProperties": False + "additionalProperties": False, } prompt = ( - f"다음 응답 내용을 바탕으로 골격 진단 결과를 알려줘\n" - f"- 성별: {gender}\n" - f"- 키: {height}cm\n" - f"- 체중: {weight}kg\n" - f"- 설문 응답:\n" - + "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers)) - + "\n\n" - "체형 진단" + f"다음 응답 내용을 바탕으로 골격 진단 결과를 알려줘\n" + f"- 성별: {gender}\n" + f"- 키: {height}cm\n" + f"- 체중: {weight}kg\n" + f"- 설문 응답:\n" + + "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers)) + + "\n\n" + "체형 진단" ) run = client.beta.threads.create_and_run( @@ -365,8 +378,8 @@ def chat_body_result( "json_schema": { "name": "BodyDiagnosisResult", "strict": True, - "schema": schema - } + "schema": schema, + }, }, ) @@ -380,8 +393,10 @@ def chat_body_result( if status.status == "completed": break if status.status in {"failed", "cancelled", "expired"}: + last_error = getattr(status, "last_error", None) raise RuntimeError( - f"Run ended with status={status.status}, last_error={getattr(status, 'last_error', None)}") + f"Run ended with status={status.status}, last_error={last_error}" + ) if time.time() > deadline: raise TimeoutError("Assistants run timed out") time.sleep(0.3) @@ -396,7 +411,9 @@ def chat_body_result( msg = assistant_msgs[0] # content 에서 text 파트만 안전하게 추출 - text_parts = [p for p in getattr(msg, "content", []) if getattr(p, "type", "") == "text"] + text_parts = [ + p for p in getattr(msg, "content", []) if getattr(p, "type", "") == "text" + ] if not text_parts: raise ValueError("Assistant message has no text content") @@ -407,16 +424,17 @@ def chat_body_result( data = json.loads(raw) except Exception as e: print("🛠️ [DEBUG] raw from assistant:\n", raw) - raise ValueError(f"JSON 파싱 실패: {e}") + raise ValueError(f"JSON 파싱 실패: {e}") from e return data + def chat_body_result_soft( answers: list[str], height: float, weight: float, gender: str, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ 1) 최대 SOFT_WAIT_SEC 동안만 동기 대기 2) 완료되면 결과 JSON(dict) 반환 @@ -455,7 +473,9 @@ def chat_body_result_soft( if status == "completed": # 결과 바로 파싱해서 반환 - msgs = client.beta.threads.messages.list(thread_id=thread_id, order="desc", limit=20).data + msgs = client.beta.threads.messages.list( + thread_id=thread_id, order="desc", limit=20 + ).data raw: Optional[str] = None for m in msgs: if getattr(m, "role", "") != "assistant": @@ -469,8 +489,16 @@ def chat_body_result_soft( data = json.loads(raw.strip()) # 필드 정규화(혹시 None/누락 방어) - for k in ("body_type","type_description","detailed_features","attraction_points", - "recommended_styles","avoid_styles","styling_fixes","styling_tips"): + for k in ( + "body_type", + "type_description", + "detailed_features", + "attraction_points", + "recommended_styles", + "avoid_styles", + "styling_fixes", + "styling_tips", + ): if data.get(k) is None: data[k] = "" return data @@ -478,17 +506,21 @@ def chat_body_result_soft( # 미완료면 run 식별자 반환 (컨트롤러가 202로 내려줌) return {"thread_id": thread_id, "run_id": run_id, "status": status} -def get_run_status(thread_id: str, run_id: str) -> Dict[str, Any]: + +def get_run_status(thread_id: str, run_id: str) -> dict[str, Any]: st = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) return {"status": st.status, "last_error": getattr(st, "last_error", None)} -def get_run_result(thread_id: str, run_id: str) -> Dict[str, Any]: + +def get_run_result(thread_id: str, run_id: str) -> dict[str, Any]: st = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) if st.status != "completed": # 컨트롤러에서 425로 매핑하기 좋게 상태만 던짐 return {"status": st.status} - msgs = client.beta.threads.messages.list(thread_id=thread_id, order="desc", limit=20).data + msgs = client.beta.threads.messages.list( + thread_id=thread_id, order="desc", limit=20 + ).data raw: Optional[str] = None for m in msgs: if getattr(m, "role", "") != "assistant": @@ -500,10 +532,17 @@ def get_run_result(thread_id: str, run_id: str) -> Dict[str, Any]: raise RuntimeError("assistant message has no extractable text") data = json.loads(raw.strip()) - for k in ("body_type","type_description","detailed_features","attraction_points", - "recommended_styles","avoid_styles","styling_fixes","styling_tips"): + for k in ( + "body_type", + "type_description", + "detailed_features", + "attraction_points", + "recommended_styles", + "avoid_styles", + "styling_fixes", + "styling_tips", + ): if data.get(k) is None: data[k] = "" data["status"] = "completed" return data - diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..7f83169 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Backend package diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..5d4cecd --- /dev/null +++ b/backend/main.py @@ -0,0 +1,43 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from mangum import Mangum +from starlette.types import ASGIApp + +from chatbot.assistant import router as chatbot_router + +app = FastAPI() +logger = logging.getLogger("app.logger") + + +@app.middleware("http") +async def log_path(request: Request, call_next: ASGIApp): + logger.info(f"▶▶ Raw request path: {request.url.path}") + return await call_next(request) + + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://style-me-wine.vercel.app", + "http://localhost:3000", + "http://localhost:5173", + "https://spring.yourmode.co.kr", + "https://yourmode.co.kr/", + ], # 허용할 프론트 도메인 + allow_methods=["*"], # 모든 HTTP 메서드 허용 (GET, POST, OPTIONS 등) + allow_headers=["*"], # 모든 헤더 허용 (Content-Type 등) +) + + +# Health check endpoint +@app.get("/health") +async def health_check(): + return {"status": "healthy", "service": "your-mode-backend"} + + +# Include chatbot router +app.include_router(chatbot_router, prefix="/chatbot") + +handler = Mangum(app, api_gateway_base_path="/prod") diff --git a/chatbot/__init__.py b/chatbot/__init__.py new file mode 100644 index 0000000..2a3fb03 --- /dev/null +++ b/chatbot/__init__.py @@ -0,0 +1 @@ +# Chatbot package diff --git a/chatbot/assistant.py b/chatbot/assistant.py new file mode 100644 index 0000000..4b0899c --- /dev/null +++ b/chatbot/assistant.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, HTTPException + +from app.schemas.chat import ChatRequest, ChatResponse +from app.services.assistant_service import AssistantService + +router = APIRouter() +assistant_service = AssistantService() + + +@router.post("/chat", response_model=ChatResponse) +async def chat_with_assistant(request: ChatRequest): + """ + 챗봇 어시스턴트와 대화 + """ + try: + response = await assistant_service.process_chat(request) + return response + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/status") +async def get_assistant_status(): + """ + 어시스턴트 상태 확인 + """ + return {"status": "active", "service": "your-mode-chatbot"} diff --git a/chatbot/schemas.py b/chatbot/schemas.py new file mode 100644 index 0000000..19d723d --- /dev/null +++ b/chatbot/schemas.py @@ -0,0 +1,118 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +# Chat related schemas +class ChatRequest(BaseModel): + """채팅 요청 모델""" + + message: str = Field(..., description="사용자 메시지") + user_id: Optional[str] = Field(None, description="사용자 ID") + session_id: Optional[str] = Field(None, description="세션 ID") + context: Optional[dict[str, Any]] = Field(None, description="대화 컨텍스트") + + +class ChatResponse(BaseModel): + """채팅 응답 모델""" + + message: str = Field(..., description="어시스턴트 응답") + session_id: str = Field(..., description="세션 ID") + timestamp: datetime = Field(default_factory=datetime.now) + context: Optional[dict[str, Any]] = Field(None, description="업데이트된 컨텍스트") + + +# Content related schemas +class ContentType(str, Enum): + """콘텐츠 타입""" + + ARTICLE = "article" + VIDEO = "video" + IMAGE = "image" + AUDIO = "audio" + + +class ContentItem(BaseModel): + """콘텐츠 아이템 모델""" + + id: str = Field(..., description="콘텐츠 ID") + title: str = Field(..., description="콘텐츠 제목") + description: Optional[str] = Field(None, description="콘텐츠 설명") + type: ContentType = Field(..., description="콘텐츠 타입") + url: Optional[str] = Field(None, description="콘텐츠 URL") + tags: list[str] = Field(default_factory=list, description="콘텐츠 태그") + created_at: datetime = Field(default_factory=datetime.now) + + +class ContentRecommendation(BaseModel): + """콘텐츠 추천 모델""" + + user_id: str = Field(..., description="사용자 ID") + recommendations: list[ContentItem] = Field(..., description="추천 콘텐츠 목록") + reason: str = Field(..., description="추천 이유") + confidence: float = Field(..., ge=0.0, le=1.0, description="추천 신뢰도") + + +# Diagnosis related schemas +class DiagnosisType(str, Enum): + """진단 타입""" + + BODY_SHAPE = "body_shape" + FASHION_STYLE = "fashion_style" + SKIN_TONE = "skin_tone" + PERSONALITY = "personality" + + +class DiagnosisQuestion(BaseModel): + """진단 질문 모델""" + + id: str = Field(..., description="질문 ID") + question: str = Field(..., description="질문 내용") + type: DiagnosisType = Field(..., description="진단 타입") + options: Optional[list[str]] = Field(None, description="선택지 옵션") + required: bool = Field(True, description="필수 질문 여부") + + +class DiagnosisAnswer(BaseModel): + """진단 답변 모델""" + + question_id: str = Field(..., description="질문 ID") + answer: str = Field(..., description="사용자 답변") + timestamp: datetime = Field(default_factory=datetime.now) + + +class DiagnosisResult(BaseModel): + """진단 결과 모델""" + + user_id: str = Field(..., description="사용자 ID") + diagnosis_type: DiagnosisType = Field(..., description="진단 타입") + answers: list[DiagnosisAnswer] = Field(..., description="답변 목록") + result: dict[str, Any] = Field(..., description="진단 결과") + confidence: float = Field(..., ge=0.0, le=1.0, description="진단 신뢰도") + created_at: datetime = Field(default_factory=datetime.now) + recommendations: Optional[list[str]] = Field(None, description="추천사항") + + +# Assistant service schemas +class AssistantConfig(BaseModel): + """어시스턴트 설정 모델""" + + model: str = Field(default="gpt-4", description="사용할 AI 모델") + temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="창의성 수준") + max_tokens: int = Field(default=1000, ge=1, description="최대 토큰 수") + system_prompt: Optional[str] = Field(None, description="시스템 프롬프트") + + +class AssistantContext(BaseModel): + """어시스턴트 컨텍스트 모델""" + + user_profile: Optional[dict[str, Any]] = Field(None, description="사용자 프로필") + conversation_history: list[dict[str, Any]] = Field( + default_factory=list, description="대화 히스토리" + ) + current_diagnosis: Optional[DiagnosisResult] = Field( + None, description="현재 진단 결과" + ) + preferences: Optional[dict[str, Any]] = Field(None, description="사용자 선호도") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5559625 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[project] +name = "your-mode-fast-api" +version = "0.1.0" +description = "FastAPI application for your mode" +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "fastapi~=0.115.13", + "openai~=1.88.0", + "pydantic~=2.11.7", + "mangum~=0.19.0", +] + +[project.optional-dependencies] +dev = [ + "uvicorn~=0.23.2", + "python-dotenv~=1.1.0", + "ruff~=0.3.0", + "pytest~=8.0.0", +] + +# [build-system] +# requires = ["hatchling"] +# build-backend = "hatchling.build" +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +# [tool.hatch.build.targets.wheel] +# packages = ["app"] +[tool.setuptools.packages.find] +include = ["app*", "chatbot*", "backend*"] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint.per-file-ignores] +"*.ipynb" = ["E501"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep-naming + "B", # flake8-bugbear + "ANN", # flake8-annotations + "A", # flake8-builtins + "Q", # flake8-quotes + "COM", # flake8-commas + "T10", # flake8-debugger + "TID", # flake8-tidy-imports + "SIM", # flake8-simplify + "ARG", # flake8-unused-arguments + "LOG", # flake8-logging + "PLC", # pylint-convention + "PLE", # pylint-errors + "UP", # pyupgrade + "NPY", # numpy + "PD", # pandas +] +extend-safe-fixes = [ + "ANN204", # Missing return type annotation for special method __init__ + "SIM118", # Use `key in dict` instead of `key in dict.keys()` +] +ignore = [ + "B905", # zip() without an explicit strict= parameter + "E741", # Ambiguous variable name + "ANN002", # Missing type annotation for *{name} + "ANN003", # Missing type annotation for **{name} + "ANN201", # Missing return type annotation for public function + "ANN202", # Missing return type annotation for private function + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name} + "ARG002", # Unused method argument: {name} + "B009", # Do not call getattr with a constant attribute value. + "B028", # No explicit stacklevel keyword argument found + "COM812", # Missing trailing comma in a list (Ignored because of conflict with formatting) + "N802", # Function name {name} should be lowercase + "N803", # Argument name {name} should be lowercase + "N806", # Variable {name} in function should be lowercase + "N812", # Lowercase {name} imported as non-lowercase {asname} +] + +# docstring 포맷 +[tool.ruff.format] +docstring-code-format = true diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 860d3fb..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ --r requirements.txt - -uvicorn~=0.23.2 -python-dotenv~=1.1.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 81b8e83..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -fastapi~=0.115.13 -openai~=1.88.0 -pydantic~=2.11.7 -mangum~=0.19.0 From 700ff873b385711cb03f2dcd7a76abef3a3c3e92 Mon Sep 17 00:00:00 2001 From: "jinhee.lee" Date: Sat, 23 Aug 2025 00:43:07 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20Add=20configuration=20files=20a?= =?UTF-8?q?nd=20dependencies=20for=20chatbot=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env-example => .env.example | 0 chatbot/config/config.yaml | 119 +++++++++++++++++++++++++++++++ chatbot/utiles/config_handler.py | 10 +++ pyproject.toml | 1 + 4 files changed, 130 insertions(+) rename .env-example => .env.example (100%) create mode 100644 chatbot/config/config.yaml create mode 100644 chatbot/utiles/config_handler.py diff --git a/.env-example b/.env.example similarity index 100% rename from .env-example rename to .env.example diff --git a/chatbot/config/config.yaml b/chatbot/config/config.yaml new file mode 100644 index 0000000..80cd018 --- /dev/null +++ b/chatbot/config/config.yaml @@ -0,0 +1,119 @@ +# Chatbot Configuration +# 프롬프트와 모델 설정을 관리하는 설정 파일 + +# OpenAI Assistant IDs +assistants: + body_assistant_id: ${OPENAI_BODY_ASSISTANT_ID} + style_assistant_id: ${OPENAI_STYLE_ASSISTANT_ID} + chat_assistant_id: ${OPENAI_CHAT_ASSISTANT_ID} + +# Model Configuration +models: + default_model: "gpt-4" + temperature: 0.7 + max_tokens: 1000 + timeout_seconds: 60 + soft_wait_seconds: 25 + +# Prompts +prompts: + body_diagnosis: + system: | + 당신은 골격 진단 및 패션 스타일리스트입니다. + 사용자 정보를 바탕으로 체형을 진단하고, 반드시 JSON으로만 응답하세요. + 출력은 다음 스키마의 각 필드를 한국어로 충실히 채우세요. + 모든 값은 문자열입니다. + + 필드: body_type, type_description, detailed_features, + attraction_points, recommended_styles, avoid_styles, + styling_fixes, styling_tips + + 주의: 코드블록 없이 순수 JSON만 출력하세요. + + user_template: | + - 성별: {gender} + - 키: {height}cm + - 체중: {weight}kg + - 설문 응답: + {answers_formatted} + + style_content: + system: | + 당신은 패션 스타일리스트입니다. + 사용자 정보를 바탕으로 스타일 추천 콘텐츠를 작성해주세요. + + user_template: | + 다음 정보를 바탕으로 **스타일 추천 콘텐츠 초안**을 작성해줘. + + - 이름: {name} + - 체형 타입: {body_type} + - 키: {height}cm + - 몸무게: {weight}kg + - 체형 특징: {body_feature} + - 추천 아이템: + {items_section} + + - 입고 싶은 상황: {recommended_situation} + - 추천 스타일: {recommended_style} + - 피하고 싶은 스타일: {avoid_style} + - 예산: {budget} + + ↳ 초안 작성. + + chat_assistant: + system: | + 당신은 체형 진단을 위한 질문-답변 어시스턴트입니다. + 사용자의 응답을 분석하고 다음 질문을 제시해주세요. + + user_template: | + {question}에 대한 응답입니다. + - 응답: {answer} + 응답을 위 JSON 형식에 맞춰서만 반환하세요. + +# JSON Schemas +json_schemas: + body_diagnosis: + name: "BodyDiagnosisResult" + strict: true + schema: + type: "object" + properties: + body_type: {"type": "string"} + type_description: {"type": "string"} + detailed_features: {"type": "string"} + attraction_points: {"type": "string"} + recommended_styles: {"type": "string"} + avoid_styles: {"type": "string"} + styling_fixes: {"type": "string"} + styling_tips: {"type": "string"} + required: + - body_type + - type_description + - detailed_features + - attraction_points + - recommended_styles + - avoid_styles + - styling_fixes + - styling_tips + additionalProperties: false + + chat_response: + name: "BodyQuestionAnswer" + strict: true + schema: + type: "object" + properties: + isSuccess: {"type": "boolean"} + selected: {"type": ["string", "null"]} + message: {"type": "string"} + nextQuestion: {"type": ["string", "null"]} + required: ["isSuccess", "selected", "message", "nextQuestion"] + additionalProperties: false + +# Error Messages +error_messages: + no_assistant_message: "No assistant message found" + no_text_content: "Assistant message has no text content" + json_parsing_failed: "JSON 파싱 실패: {error}" + run_timeout: "Assistants run timed out" + run_failed: "Assistants run ended with status={status}, last_error={error}" diff --git a/chatbot/utiles/config_handler.py b/chatbot/utiles/config_handler.py new file mode 100644 index 0000000..d1e4565 --- /dev/null +++ b/chatbot/utiles/config_handler.py @@ -0,0 +1,10 @@ +from typing import Any + +import yaml + + +def load_config(path: str) -> dict[str, Any]: + config = {} + with open(path, encoding="utf-8") as file: + config = yaml.load(file, Loader=yaml.FullLoader) + return config diff --git a/pyproject.toml b/pyproject.toml index 5559625..36247c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "openai~=1.88.0", "pydantic~=2.11.7", "mangum~=0.19.0", + "pyyaml>=6.0", ] [project.optional-dependencies] From dfb0795e69100a2e055e8496d974ad58a06ea623 Mon Sep 17 00:00:00 2001 From: "jinhee.lee" Date: Sun, 24 Aug 2025 21:03:49 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20Add=20new=20schema=20and=20util?= =?UTF-8?q?ity=20files=20(exclude=20config=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/__init__.py | 0 app/main.py | 34 -- app/schemas/__init__.py | 0 app/schemas/content.py | 31 -- app/services/__init__.py | 0 app/services/assistant_service.py | 548 ------------------------- {app => backend/api}/__init__.py | 0 {app => backend}/api/assistant.py | 9 +- backend/main.py | 4 + chatbot/__init__.py | 69 +++- chatbot/agents/__init__.py | 11 + chatbot/agents/body_diagnosis_agent.py | 250 +++++++++++ chatbot/agents/chat_assistant_agent.py | 188 +++++++++ chatbot/agents/conversation_graph.py | 299 ++++++++++++++ chatbot/agents/style_content_agent.py | 127 ++++++ chatbot/api/conversation.py | 168 ++++++++ chatbot/assistant.py | 108 ++++- chatbot/config/config.yaml | 119 ------ chatbot/schemas.py | 118 ------ {app => chatbot}/schemas/chat.py | 19 +- chatbot/schemas/chatbot.py | 31 ++ chatbot/schemas/content.py | 30 ++ {app => chatbot}/schemas/diagnosis.py | 31 +- chatbot/utils/config_handler.py | 10 + chatbot/utils/path.py | 8 + pyproject.toml | 5 +- test_conversation.py | 144 +++++++ 27 files changed, 1472 insertions(+), 889 deletions(-) delete mode 100644 app/api/__init__.py delete mode 100644 app/main.py delete mode 100644 app/schemas/__init__.py delete mode 100644 app/schemas/content.py delete mode 100644 app/services/__init__.py delete mode 100644 app/services/assistant_service.py rename {app => backend/api}/__init__.py (100%) rename {app => backend}/api/assistant.py (96%) create mode 100644 chatbot/agents/__init__.py create mode 100644 chatbot/agents/body_diagnosis_agent.py create mode 100644 chatbot/agents/chat_assistant_agent.py create mode 100644 chatbot/agents/conversation_graph.py create mode 100644 chatbot/agents/style_content_agent.py create mode 100644 chatbot/api/conversation.py delete mode 100644 chatbot/config/config.yaml delete mode 100644 chatbot/schemas.py rename {app => chatbot}/schemas/chat.py (55%) create mode 100644 chatbot/schemas/chatbot.py create mode 100644 chatbot/schemas/content.py rename {app => chatbot}/schemas/diagnosis.py (66%) create mode 100644 chatbot/utils/config_handler.py create mode 100644 chatbot/utils/path.py create mode 100644 test_conversation.py diff --git a/app/api/__init__.py b/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/main.py b/app/main.py deleted file mode 100644 index 33d74e1..0000000 --- a/app/main.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging - -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from mangum import Mangum -from starlette.types import ASGIApp - -from app.api.assistant import router as assistant_router - -app = FastAPI() -logger = logging.getLogger("app.logger") - - -@app.middleware("http") -async def log_path(request: Request, call_next: ASGIApp): - logger.info(f"▶▶ Raw request path: {request.url.path}") - return await call_next(request) - - -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "https://style-me-wine.vercel.app", - "http://localhost:3000", - "http://localhost:5173", - "https://spring.yourmode.co.kr", - "https://yourmode.co.kr/", - ], # 허용할 프론트 도메인 - allow_methods=["*"], # 모든 HTTP 메서드 허용 (GET, POST, OPTIONS 등) - allow_headers=["*"], # 모든 헤더 허용 (Content-Type 등) -) - -app.include_router(assistant_router, prefix="/assistant") -handler = Mangum(app, api_gateway_base_path="/prod") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/schemas/content.py b/app/schemas/content.py deleted file mode 100644 index 7c0750d..0000000 --- a/app/schemas/content.py +++ /dev/null @@ -1,31 +0,0 @@ -from pydantic import BaseModel, ConfigDict, Field - - -class CreateContentRequest(BaseModel): - name: str = Field(..., description="이름") - body_type: str = Field(..., description="체형 타입") - height: int = Field(..., description="키") - weight: int = Field(..., description="몸무게") - body_feature: str = Field(..., description="체형적 특징") - recommendation_items: list[str] = Field(..., description="추천 받고 싶은 아이템") - recommended_situation: str = Field(..., description="입고 싶은 상황") - recommended_style: str = Field(..., description="추천받고 싶은 스타일") - avoid_style: str = Field(..., description="피하고 싶은 스타일") - budget: str = Field(..., description="예산") - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "name": "전여진", - "body_type": "웨이브", - "height": 160, - "weight": 40, - "body_feature": "체형이 너무 얇다", - "recommendation_items": ["상의", "하의"], - "recommended_situation": "IR발표", - "recommended_style": "IR 발표에 어울리는 스타일", - "avoid_style": "스트릿,힙한 스타일", - "budget": "20만원", - } - } - ) diff --git a/app/services/__init__.py b/app/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/services/assistant_service.py b/app/services/assistant_service.py deleted file mode 100644 index 86c12f1..0000000 --- a/app/services/assistant_service.py +++ /dev/null @@ -1,548 +0,0 @@ -import contextlib -import json -import os -import re -import time -from typing import Any, Optional - -from openai import OpenAI - -if os.getenv("AWS_LAMBDA_FUNCTION_NAME") is None: - from dotenv import load_dotenv - - load_dotenv() - -client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - -BODY_ASSISTANT_ID = os.getenv("OPENAI_BODY_ASSISTANT_ID") -STYLE_ASSISTANT_ID = os.getenv("OPENAI_STYLE_ASSISTANT_ID") -CHAT_ASSISTANT_ID = os.getenv("OPENAI_CHAT_ASSISTANT_ID") -SOFT_WAIT_SEC = 25 # API GW(29~30s)보다 짧게 - - -def _extract_json(raw: str) -> dict: - """ - raw 에서 ```json ... ``` 블록 안의 순수 JSON만 꺼내서 dict로 반환. - ```json - { ... } - ``` - 코드블록이 없으면 raw 전체를 json.loads 시도. - """ - # 1) ```json … ``` 코드블록 추출 - m = re.search(r"```json\s*(\{.*?\})\s*```", raw, re.DOTALL) - json_str = m.group(1) if m else raw - # 2) 불필요한 ``` 제거 (혹시 raw에만 있을 때) - json_str = json_str.strip() - if json_str.startswith("```"): - json_str = json_str[3:] - if json_str.endswith("```"): - json_str = json_str[:-3] - json_str = json_str.strip() - # 3) 파싱 - return json.loads(json_str, strict=False) - - -RESULT_SCHEMA = { - "type": "object", - "properties": { - "body_type": {"type": "string"}, - "type_description": {"type": "string"}, - "detailed_features": {"type": "string"}, - "attraction_points": {"type": "string"}, - "recommended_styles": {"type": "string"}, - "avoid_styles": {"type": "string"}, - "styling_fixes": {"type": "string"}, - "styling_tips": {"type": "string"}, - }, - "required": [ - "body_type", - "type_description", - "detailed_features", - "attraction_points", - "recommended_styles", - "avoid_styles", - "styling_fixes", - "styling_tips", - ], - "additionalProperties": False, -} - - -def _build_prompt(answers: list[str], height: float, weight: float, gender: str) -> str: - return ( - "당신은 골격 진단 및 패션 스타일리스트입니다.\n" - "아래 사용자 정보를 바탕으로 체형을 진단하고, 반드시 JSON으로만 응답하세요.\n" - "출력은 다음 스키마의 각 필드를 한국어로 충실히 채우세요. " - "모든 값은 문자열입니다.\n" - "필드: body_type, type_description, detailed_features, " - "attraction_points, recommended_styles, avoid_styles, " - "styling_fixes, styling_tips\n\n" - f"- 성별: {gender}\n" - f"- 키: {height}cm\n" - f"- 체중: {weight}kg\n" - "- 설문 응답:\n" - + "\n".join(f"{i+1}. {a}" for i, a in enumerate(answers)) - + "\n\n주의: 코드블록 없이 순수 JSON만 출력하세요." - ) - - -# ---------- 안전한 메시지 텍스트 추출기 ---------- -def _as_dict(obj: any) -> dict: - try: - if hasattr(obj, "model_dump"): - return obj.model_dump() - if hasattr(obj, "dict"): - return obj.dict() - except Exception: - pass - return obj if isinstance(obj, dict) else json.loads(json.dumps(obj, default=str)) - - -def _extract_first_text_from_content_items(items: list) -> str | None: - for item in items or []: - d = _as_dict(item) - itype = d.get("type") - - # 중첩 content(tool_result 등) - if isinstance(d.get("content"), list): - inner = _extract_first_text_from_content_items(d["content"]) - if inner: - return inner - - if itype in ("output_text", "text", "input_text"): - t = d.get("text") - if isinstance(t, str): - return t - if isinstance(t, dict) and isinstance(t.get("value"), str): - return t["value"] - - for key in ("output_text", "value"): - if isinstance(d.get(key), str): - return d[key] - return None - - -def _extract_first_text_from_message(msg: any) -> str | None: - m = _as_dict(msg) - for key in ("text", "output_text"): - v = m.get(key) - if isinstance(v, str): - return v - if isinstance(v, dict) and isinstance(v.get("value"), str): - return v["value"] - content = m.get("content") or [] - if isinstance(content, list): - return _extract_first_text_from_content_items(content) - return None - - -def diagnose_body_type_with_assistant( - answers: list[str], - height: float, - weight: float, - gender: str, - *, - timeout_sec: int = 60, -) -> dict[str, Any]: - """ - 1) 사용자 정보로 prompt 구성 - 2) create_and_run 으로 assistant 호출 (JSON 스키마 강제) - 3) 폴링하여 run 완료 대기(타임아웃/에러 처리) - 4) 마지막 어시스턴트 메시지(raw)에서 JSON 파싱 → dict 반환 - """ - prompt = _build_prompt(answers, height, weight, gender) - - run = client.beta.threads.create_and_run( - assistant_id=BODY_ASSISTANT_ID, - thread={"messages": [{"role": "user", "content": prompt}]}, - response_format={ - "type": "json_schema", - "json_schema": { - "name": "BodyDiagnosisResult", - "strict": True, - "schema": RESULT_SCHEMA, - }, - }, - ) - - thread_id = run.thread_id - run_id = run.id - - # 상태 폴링 (에러/타임아웃 처리) - deadline = time.time() + timeout_sec - while True: - status = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - if status.status == "completed": - break - if status.status in {"failed", "cancelled", "expired"}: - raise RuntimeError( - f"Assistants run ended with status={status.status}, " - f"last_error={getattr(status, 'last_error', None)}" - ) - if time.time() > deadline: - raise TimeoutError("Assistants run timed out") - time.sleep(0.3) - - # 최신 assistant 메시지 안전 추출 - # (일반적으로 list()는 최신이 앞에 오지만, role/시간 기준으로 한 번 더 필터) - messages = client.beta.threads.messages.list(thread_id=thread_id).data - assistant_msgs = [m for m in messages if getattr(m, "role", "") == "assistant"] - if not assistant_msgs: - raise ValueError("No assistant message found") - - # created_at이 있다면 최신 정렬, 없다면 그대로 첫 번째 사용 - with contextlib.suppress(Exception): - assistant_msgs.sort(key=lambda m: getattr(m, "created_at", 0), reverse=True) - - first = assistant_msgs[0] - if not first.content or getattr(first.content[0], "type", "text") != "text": - # 도구 호출 등 다른 타입이 섞였을 가능성 방어 - content_type = getattr(first.content[0], "type", "unknown") - raise ValueError(f"Unexpected message content type: {content_type}") - - raw = first.content[0].text.value.strip() - - try: - # strict json_schema 덕분에 대부분 안전하지만, 혹시 모를 포맷 이슈 방어 - return json.loads(raw) - except Exception as e: - print("🛠️ [DEBUG] raw from assistant:\n", raw) - raise ValueError(f"JSON 파싱 실패: {e}") from e - - -def create_content( - name: str, - body_type: str, - height: int, - weight: int, - body_feature: str, - recommendation_items: list[str], - recommended_situation: str, - recommended_style: str, - avoid_style: str, - budget: str, -): - items_section = "\n".join( - f"{i + 1}. {item}" for i, item in enumerate(recommendation_items) - ) - prompt = ( - "다음 정보를 바탕으로 **스타일 추천 콘텐츠 초안**을 작성해줘.\n\n" - f"- 이름: {name}\n" - f"- 체형 타입: {body_type}\n" - f"- 키: {height}cm\n" - f"- 몸무게: {weight}kg\n" - f"- 체형 특징: {body_feature}\n" - "- 추천 아이템:\n" - f"{items_section}\n\n" - f"- 입고 싶은 상황: {recommended_situation}\n" - f"- 추천 스타일: {recommended_style}\n" - f"- 피하고 싶은 스타일: {avoid_style}\n" - f"- 예산: {budget}\n\n" - "↳ 초안 작성." - ) - - run = client.beta.threads.create_and_run( - assistant_id=STYLE_ASSISTANT_ID, - thread={"messages": [{"role": "user", "content": prompt}]}, - ) - thread_id = run.thread_id - run_id = run.id - - while True: - status = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - if status.status == "completed": - break - time.sleep(0.3) - - msgs = client.beta.threads.messages.list(thread_id=thread_id).data - raw = msgs[0].content[0].text.value # 어시스턴트가 첫 번째 메시지로 보낸 응답 - - return raw - - -def chat_body_assistant(question: str, answer: str): - schema = { - "type": "object", - "properties": { - "isSuccess": {"type": "boolean"}, - "selected": {"type": ["string", "null"]}, - "message": {"type": "string"}, - "nextQuestion": {"type": ["string", "null"]}, - }, - # ← 키는 모두 존재해야 함(값은 string 또는 null 허용) - "required": ["isSuccess", "selected", "message", "nextQuestion"], - "additionalProperties": False, - } - - prompt = ( - f"{question}에 대한 응답입니다.\n" - f"- 응답: {answer}\n" - "응답을 위 JSON 형식에 맞춰서만 반환하세요." - ) - - run = client.beta.threads.create_and_run( - assistant_id=CHAT_ASSISTANT_ID, - thread={"messages": [{"role": "user", "content": prompt}]}, - response_format={ - "type": "json_schema", - "json_schema": { - "name": "BodyQuestionAnswer", - "strict": True, # ← 엄격 모드 유지 - "schema": schema, - }, - }, - ) - - thread_id = run.thread_id - run_id = run.id - - while True: - status = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - if status.status == "completed": - break - time.sleep(0.3) - - msgs = client.beta.threads.messages.list(thread_id=thread_id).data - assistant_msg = next( - (m for m in msgs if getattr(m, "role", "") == "assistant"), None - ) - if assistant_msg is None: - raise ValueError("No assistant message found") - - parts = [ - p - for p in getattr(assistant_msg, "content", []) - if getattr(p, "type", "") == "text" - ] - if not parts: - raise ValueError("Assistant message has no text content") - - text_obj = parts[0].text - raw = (text_obj.value if hasattr(text_obj, "value") else str(text_obj)).strip() - - data = json.loads(raw) - - # (선택) 서버에서 일관 포맷으로 정규화: null -> "" - # - FastAPI response_model이 selected/nextQuestion를 str로 요구한다면 필수 - # - optional로 둘 거면 이 블록은 생략해도 됨 - if data.get("selected") is None: - data["selected"] = "" - if data.get("nextQuestion") is None: - data["nextQuestion"] = "" - - return data - - -def chat_body_result(answers: list[str], height: float, weight: float, gender: str): - schema = { - "type": "object", - "properties": { - "body_type": {"type": "string"}, - "type_description": {"type": "string"}, - "detailed_features": {"type": "string"}, - "attraction_points": {"type": "string"}, - "recommended_styles": {"type": "string"}, - "avoid_styles": {"type": "string"}, - "styling_fixes": {"type": "string"}, - "styling_tips": {"type": "string"}, - }, - "required": [ - "body_type", - "type_description", - "detailed_features", - "attraction_points", - "recommended_styles", - "avoid_styles", - "styling_fixes", - "styling_tips", - ], - "additionalProperties": False, - } - - prompt = ( - f"다음 응답 내용을 바탕으로 골격 진단 결과를 알려줘\n" - f"- 성별: {gender}\n" - f"- 키: {height}cm\n" - f"- 체중: {weight}kg\n" - f"- 설문 응답:\n" - + "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers)) - + "\n\n" - "체형 진단" - ) - - run = client.beta.threads.create_and_run( - assistant_id=CHAT_ASSISTANT_ID, - thread={"messages": [{"role": "user", "content": prompt}]}, - response_format={ - "type": "json_schema", - "json_schema": { - "name": "BodyDiagnosisResult", - "strict": True, - "schema": schema, - }, - }, - ) - - thread_id = run.thread_id - run_id = run.id - - deadline = time.time() + 60 - - while True: - status = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - if status.status == "completed": - break - if status.status in {"failed", "cancelled", "expired"}: - last_error = getattr(status, "last_error", None) - raise RuntimeError( - f"Run ended with status={status.status}, last_error={last_error}" - ) - if time.time() > deadline: - raise TimeoutError("Assistants run timed out") - time.sleep(0.3) - - msgs = client.beta.threads.messages.list(thread_id=thread_id).data - - # 최신 assistant 메시지 선택 (created_at 기준 내림차순) - assistant_msgs = [m for m in msgs if getattr(m, "role", "") == "assistant"] - if not assistant_msgs: - raise ValueError("No assistant message found") - assistant_msgs.sort(key=lambda m: getattr(m, "created_at", 0), reverse=True) - msg = assistant_msgs[0] - - # content 에서 text 파트만 안전하게 추출 - text_parts = [ - p for p in getattr(msg, "content", []) if getattr(p, "type", "") == "text" - ] - if not text_parts: - raise ValueError("Assistant message has no text content") - - raw = text_parts[0].text.value.strip() - - # JSON 파싱 후 반환 (여기서 반드시 dict를 return) - try: - data = json.loads(raw) - except Exception as e: - print("🛠️ [DEBUG] raw from assistant:\n", raw) - raise ValueError(f"JSON 파싱 실패: {e}") from e - - return data - - -def chat_body_result_soft( - answers: list[str], - height: float, - weight: float, - gender: str, -) -> dict[str, Any]: - """ - 1) 최대 SOFT_WAIT_SEC 동안만 동기 대기 - 2) 완료되면 결과 JSON(dict) 반환 - 3) 미완료면 {"thread_id","run_id","status"} 반환(컨트롤러에서 202로 내려주기) - """ - prompt = _build_prompt(answers, height, weight, gender) - - run = client.beta.threads.create_and_run( - assistant_id=BODY_ASSISTANT_ID, - thread={"messages": [{"role": "user", "content": prompt}]}, - response_format={ - "type": "json_schema", - "json_schema": { - "name": "BodyDiagnosisResult", - "strict": True, - "schema": RESULT_SCHEMA, - }, - }, - ) - - thread_id = run.thread_id - run_id = run.id - - # 소프트 대기 - deadline = time.time() + SOFT_WAIT_SEC - status = run.status - while time.time() < deadline: - st = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - status = st.status - if status == "completed": - break - if status in {"failed", "cancelled", "expired"}: - last_err = getattr(st, "last_error", None) - raise RuntimeError(f"assistants run {status}: {last_err}") - time.sleep(0.3) - - if status == "completed": - # 결과 바로 파싱해서 반환 - msgs = client.beta.threads.messages.list( - thread_id=thread_id, order="desc", limit=20 - ).data - raw: Optional[str] = None - for m in msgs: - if getattr(m, "role", "") != "assistant": - continue - raw = _extract_first_text_from_message(m) - if raw: - break - if not raw: - raise RuntimeError("assistant message has no extractable text") - - data = json.loads(raw.strip()) - - # 필드 정규화(혹시 None/누락 방어) - for k in ( - "body_type", - "type_description", - "detailed_features", - "attraction_points", - "recommended_styles", - "avoid_styles", - "styling_fixes", - "styling_tips", - ): - if data.get(k) is None: - data[k] = "" - return data - - # 미완료면 run 식별자 반환 (컨트롤러가 202로 내려줌) - return {"thread_id": thread_id, "run_id": run_id, "status": status} - - -def get_run_status(thread_id: str, run_id: str) -> dict[str, Any]: - st = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - return {"status": st.status, "last_error": getattr(st, "last_error", None)} - - -def get_run_result(thread_id: str, run_id: str) -> dict[str, Any]: - st = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - if st.status != "completed": - # 컨트롤러에서 425로 매핑하기 좋게 상태만 던짐 - return {"status": st.status} - - msgs = client.beta.threads.messages.list( - thread_id=thread_id, order="desc", limit=20 - ).data - raw: Optional[str] = None - for m in msgs: - if getattr(m, "role", "") != "assistant": - continue - raw = _extract_first_text_from_message(m) - if raw: - break - if not raw: - raise RuntimeError("assistant message has no extractable text") - - data = json.loads(raw.strip()) - for k in ( - "body_type", - "type_description", - "detailed_features", - "attraction_points", - "recommended_styles", - "avoid_styles", - "styling_fixes", - "styling_tips", - ): - if data.get(k) is None: - data[k] = "" - data["status"] = "completed" - return data diff --git a/app/__init__.py b/backend/api/__init__.py similarity index 100% rename from app/__init__.py rename to backend/api/__init__.py diff --git a/app/api/assistant.py b/backend/api/assistant.py similarity index 96% rename from app/api/assistant.py rename to backend/api/assistant.py index ac3efeb..e6f3e40 100644 --- a/app/api/assistant.py +++ b/backend/api/assistant.py @@ -1,6 +1,3 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse - from app.schemas.chat import ChatRequest, ChatResponse from app.schemas.content import CreateContentRequest from app.schemas.diagnosis import DiagnoseRequest, DiagnoseResponse @@ -12,6 +9,8 @@ get_run_result, get_run_status, ) +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse router = APIRouter() @@ -42,9 +41,7 @@ def recommend_content(request: CreateContentRequest): ) -@router.post( - "/chat", description="체형 진단 개별 질문에 대한 응답", response_model=ChatResponse -) +@router.post("/chat", description="체형 진단 개별 질문에 대한 응답", response_model=ChatResponse) def chat(request: ChatRequest): return chat_body_assistant(request.question, request.answer) diff --git a/backend/main.py b/backend/main.py index 5d4cecd..d0cf665 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,6 +5,7 @@ from mangum import Mangum from starlette.types import ASGIApp +from chatbot.api.conversation import router as conversation_router from chatbot.assistant import router as chatbot_router app = FastAPI() @@ -40,4 +41,7 @@ async def health_check(): # Include chatbot router app.include_router(chatbot_router, prefix="/chatbot") +# Include conversation router +app.include_router(conversation_router, prefix="/api") + handler = Mangum(app, api_gateway_base_path="/prod") diff --git a/chatbot/__init__.py b/chatbot/__init__.py index 2a3fb03..f2699d8 100644 --- a/chatbot/__init__.py +++ b/chatbot/__init__.py @@ -1 +1,68 @@ -# Chatbot package +# Chatbot package for your-mode-fast-api + +# Export schemas +# Export agents +from .agents import ( + BodyDiagnosisAgent, + ChatAssistantAgent, + StyleContentAgent, +) + +# Export config utilities +from .config import ( + get_assistant_id, + get_error_message, + get_json_schema, + get_model_config, + get_prompt, + load_config, +) +from .schemas import ( + AssistantConfig, + AssistantContext, + ChatbotChatRequest, + ChatbotChatResponse, + ChatRequest, + ChatResponse, + ContentItem, + ContentRecommendation, + ContentType, + CreateContentRequest, + DiagnoseRequest, + DiagnoseResponse, + DiagnosisAnswer, + DiagnosisQuestion, + DiagnosisResult, + DiagnosisType, +) + +__all__ = [ + # Schemas + "ChatRequest", + "ChatResponse", + "CreateContentRequest", + "DiagnoseRequest", + "DiagnoseResponse", + "ChatbotChatRequest", + "ChatbotChatResponse", + "ContentType", + "ContentItem", + "ContentRecommendation", + "DiagnosisType", + "DiagnosisQuestion", + "DiagnosisAnswer", + "DiagnosisResult", + "AssistantConfig", + "AssistantContext", + # Agents + "BodyDiagnosisAgent", + "StyleContentAgent", + "ChatAssistantAgent", + # Config utilities + "load_config", + "get_assistant_id", + "get_model_config", + "get_prompt", + "get_json_schema", + "get_error_message", +] diff --git a/chatbot/agents/__init__.py b/chatbot/agents/__init__.py new file mode 100644 index 0000000..f166032 --- /dev/null +++ b/chatbot/agents/__init__.py @@ -0,0 +1,11 @@ +# Agents module for chatbot functionality + +from .body_diagnosis_agent import BodyDiagnosisAgent +from .chat_assistant_agent import ChatAssistantAgent +from .style_content_agent import StyleContentAgent + +__all__ = [ + "BodyDiagnosisAgent", + "StyleContentAgent", + "ChatAssistantAgent", +] diff --git a/chatbot/agents/body_diagnosis_agent.py b/chatbot/agents/body_diagnosis_agent.py new file mode 100644 index 0000000..dec31e8 --- /dev/null +++ b/chatbot/agents/body_diagnosis_agent.py @@ -0,0 +1,250 @@ +""" +Body Diagnosis Agent +체형 진단을 담당하는 에이전트 +""" + +import contextlib +import json +import time +from typing import Any, Optional + +from openai import OpenAI + +from chatbot.config import load_config + + +class BodyDiagnosisAgent: + """체형 진단 에이전트""" + + def __init__(self) -> None: + self.config = load_config() + self.client = OpenAI(api_key=self.config.get("openai_api_key")) + self.assistant_id = self.config["assistants"]["body_assistant_id"] + self.timeout = self.config["models"]["timeout_seconds"] + self.soft_wait = self.config["models"]["soft_wait_seconds"] + + def diagnose( + self, + answers: list[str], + height: float, + weight: float, + gender: str, + *, + timeout_sec: Optional[int] = None, + ) -> dict[str, Any]: + """ + 체형 진단 수행 + + Args: + answers: 설문 응답 리스트 + height: 키 (cm) + weight: 체중 (kg) + gender: 성별 + timeout_sec: 타임아웃 시간 (초) + + Returns: + 진단 결과 딕셔너리 + """ + timeout = timeout_sec or self.timeout + prompt = self._build_prompt(answers, height, weight, gender) + + run = self.client.beta.threads.create_and_run( + assistant_id=self.assistant_id, + thread={"messages": [{"role": "user", "content": prompt}]}, + response_format={ + "type": "json_schema", + "json_schema": self.config["json_schemas"]["body_diagnosis"], + }, + ) + + thread_id = run.thread_id + run_id = run.id + + # 상태 폴링 (에러/타임아웃 처리) + deadline = time.time() + timeout + while True: + status = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) + if status.status == "completed": + break + if status.status in {"failed", "cancelled", "expired"}: + raise RuntimeError( + f"Assistants run ended with status={status.status}, " + f"last_error={getattr(status, 'last_error', None)}" + ) + if time.time() > deadline: + raise TimeoutError(self.config["error_messages"]["run_timeout"]) + time.sleep(0.3) + + # 최신 assistant 메시지 추출 + messages = self.client.beta.threads.messages.list(thread_id=thread_id).data + assistant_msgs = [m for m in messages if getattr(m, "role", "") == "assistant"] + if not assistant_msgs: + raise ValueError(self.config["error_messages"]["no_assistant_message"]) + + # created_at 기준으로 최신 정렬 + with contextlib.suppress(Exception): + assistant_msgs.sort(key=lambda m: getattr(m, "created_at", 0), reverse=True) + + first = assistant_msgs[0] + if not first.content or getattr(first.content[0], "type", "text") != "text": + content_type = getattr(first.content[0], "type", "unknown") + raise ValueError(f"Unexpected message content type: {content_type}") + + raw = first.content[0].text.value.strip() + + try: + return json.loads(raw) + except Exception as e: + print("🛠️ [DEBUG] raw from assistant:\n", raw) + raise ValueError( + self.config["error_messages"]["json_parsing_failed"].format(error=e) + ) from e + + def diagnose_soft( + self, + answers: list[str], + height: float, + weight: float, + gender: str, + ) -> dict[str, Any]: + """ + 소프트 체형 진단 (비동기 대기) + + Args: + answers: 설문 응답 리스트 + height: 키 (cm) + weight: 체중 (kg) + gender: 성별 + + Returns: + 완료시: 진단 결과 딕셔너리 + 미완료시: {"thread_id", "run_id", "status"} + """ + prompt = self._build_prompt(answers, height, weight, gender) + + run = self.client.beta.threads.create_and_run( + assistant_id=self.assistant_id, + thread={"messages": [{"role": "user", "content": prompt}]}, + response_format={ + "type": "json_schema", + "json_schema": self.config["json_schemas"]["body_diagnosis"], + }, + ) + + thread_id = run.thread_id + run_id = run.id + + # 소프트 대기 + deadline = time.time() + self.soft_wait + status = run.status + while time.time() < deadline: + st = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) + status = st.status + if status == "completed": + break + if status in {"failed", "cancelled", "expired"}: + last_err = getattr(st, "last_error", None) + raise RuntimeError(f"assistants run {status}: {last_err}") + time.sleep(0.3) + + if status == "completed": + # 결과 파싱해서 반환 + msgs = self.client.beta.threads.messages.list( + thread_id=thread_id, order="desc", limit=20 + ).data + raw: Optional[str] = None + for m in msgs: + if getattr(m, "role", "") != "assistant": + continue + raw = self._extract_text_from_message(m) + if raw: + break + if not raw: + raise RuntimeError("assistant message has no extractable text") + + data = json.loads(raw.strip()) + + # 필드 정규화 + for k in ( + "body_type", + "type_description", + "detailed_features", + "attraction_points", + "recommended_styles", + "avoid_styles", + "styling_fixes", + "styling_tips", + ): + if data.get(k) is None: + data[k] = "" + return data + + # 미완료시 run 식별자 반환 + return {"thread_id": thread_id, "run_id": run_id, "status": status} + + def get_run_status(self, thread_id: str, run_id: str) -> dict[str, Any]: + """Run 상태 조회""" + st = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) + return {"status": st.status, "last_error": getattr(st, "last_error", None)} + + def get_run_result(self, thread_id: str, run_id: str) -> dict[str, Any]: + """Run 결과 조회""" + st = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) + if st.status != "completed": + return {"status": st.status} + + msgs = self.client.beta.threads.messages.list( + thread_id=thread_id, order="desc", limit=20 + ).data + raw: Optional[str] = None + for m in msgs: + if getattr(m, "role", "") != "assistant": + continue + raw = self._extract_text_from_message(m) + if raw: + break + if not raw: + raise RuntimeError("assistant message has no extractable text") + + data = json.loads(raw.strip()) + for k in ( + "body_type", + "type_description", + "detailed_features", + "attraction_points", + "recommended_styles", + "avoid_styles", + "styling_fixes", + "styling_tips", + ): + if data.get(k) is None: + data[k] = "" + data["status"] = "completed" + return data + + def _build_prompt(self, answers: list[str], height: float, weight: float, gender: str) -> str: + """프롬프트 구성""" + system_prompt = self.config["prompts"]["body_diagnosis"]["system"] + user_template = self.config["prompts"]["body_diagnosis"]["user_template"] + + answers_formatted = "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers)) + + return f"{system_prompt}\n\n{ + user_template.format( + gender=gender, + height=height, + weight=weight, + answers_formatted=answers_formatted, + ) + }" + + def _extract_text_from_message(self, msg: Any) -> Optional[str]: + """메시지에서 텍스트 추출""" + try: + if hasattr(msg, "content") and msg.content: + for content_item in msg.content: + if getattr(content_item, "type", "") == "text": + return content_item.text.value.strip() + except Exception: + pass + return None diff --git a/chatbot/agents/chat_assistant_agent.py b/chatbot/agents/chat_assistant_agent.py new file mode 100644 index 0000000..0a4e7f9 --- /dev/null +++ b/chatbot/agents/chat_assistant_agent.py @@ -0,0 +1,188 @@ +""" +Chat Assistant Agent +채팅 상호작용을 담당하는 에이전트 +""" + +import contextlib +import json +import time +from typing import Any + +from openai import OpenAI + +from chatbot.config import load_config + + +class ChatAssistantAgent: + """채팅 어시스턴트 에이전트""" + + def __init__(self) -> None: + self.config = load_config() + self.client = OpenAI(api_key=self.config.get("openai_api_key")) + self.assistant_id = self.config["assistants"]["chat_assistant_id"] + + def chat( + self, + question: str, + answer: str, + ) -> dict[str, Any]: + """ + 채팅 상호작용 처리 + + Args: + question: 질문 + answer: 응답 + + Returns: + 채팅 응답 딕셔너리 + """ + prompt = self._build_prompt(question, answer) + + run = self.client.beta.threads.create_and_run( + assistant_id=self.assistant_id, + thread={"messages": [{"role": "user", "content": prompt}]}, + response_format={ + "type": "json_schema", + "json_schema": self.config["json_schemas"]["chat_response"], + }, + ) + + thread_id = run.thread_id + run_id = run.id + + # 완료 대기 + while True: + status = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) + if status.status == "completed": + break + if status.status in {"failed", "cancelled", "expired"}: + last_error = getattr(status, "last_error", None) + raise RuntimeError(f"Chat assistant failed: {last_error}") + time.sleep(0.3) + + # 결과 추출 + msgs = self.client.beta.threads.messages.list(thread_id=thread_id).data + assistant_msg = next((m for m in msgs if getattr(m, "role", "") == "assistant"), None) + if assistant_msg is None: + raise ValueError(self.config["error_messages"]["no_assistant_message"]) + + # 텍스트 콘텐츠 추출 + text_parts = [ + p for p in getattr(assistant_msg, "content", []) if getattr(p, "type", "") == "text" + ] + if not text_parts: + raise ValueError(self.config["error_messages"]["no_text_content"]) + + text_obj = text_parts[0].text + raw = (text_obj.value if hasattr(text_obj, "value") else str(text_obj)).strip() + + try: + data = json.loads(raw) + except Exception as e: + raise ValueError( + self.config["error_messages"]["json_parsing_failed"].format(error=e) + ) from e + + # null 값 정규화 + if data.get("selected") is None: + data["selected"] = "" + if data.get("nextQuestion") is None: + data["nextQuestion"] = "" + + return data + + def chat_body_result( + self, + answers: list[str], + height: float, + weight: float, + gender: str, + ) -> dict[str, Any]: + """ + 체형 진단 결과를 위한 채팅 + + Args: + answers: 설문 응답 리스트 + height: 키 (cm) + weight: 체중 (kg) + gender: 성별 + + Returns: + 진단 결과 딕셔너리 + """ + prompt = self._build_body_result_prompt(answers, height, weight, gender) + + run = self.client.beta.threads.create_and_run( + assistant_id=self.assistant_id, + thread={"messages": [{"role": "user", "content": prompt}]}, + response_format={ + "type": "json_schema", + "json_schema": self.config["json_schemas"]["body_diagnosis"], + }, + ) + + thread_id = run.thread_id + run_id = run.id + + deadline = time.time() + self.config["models"]["timeout_seconds"] + + while True: + status = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) + if status.status == "completed": + break + if status.status in {"failed", "cancelled", "expired"}: + last_error = getattr(status, "last_error", None) + raise RuntimeError( + f"Run ended with status={status.status}, last_error={last_error}" + ) + if time.time() > deadline: + raise TimeoutError(self.config["error_messages"]["run_timeout"]) + time.sleep(0.3) + + msgs = self.client.beta.threads.messages.list(thread_id=thread_id).data + + # 최신 assistant 메시지 선택 + assistant_msgs = [m for m in msgs if getattr(m, "role", "") == "assistant"] + if not assistant_msgs: + raise ValueError(self.config["error_messages"]["no_assistant_message"]) + + # created_at 기준으로 정렬 + with contextlib.suppress(Exception): + assistant_msgs.sort(key=lambda m: getattr(m, "created_at", 0), reverse=True) + + msg = assistant_msgs[0] + + # 텍스트 콘텐츠 추출 + text_parts = [p for p in getattr(msg, "content", []) if getattr(p, "type", "") == "text"] + if not text_parts: + raise ValueError(self.config["error_messages"]["no_text_content"]) + + raw = text_parts[0].text.value.strip() + + try: + return json.loads(raw) + except Exception as e: + print("🛠️ [DEBUG] raw from assistant:\n", raw) + raise ValueError( + self.config["error_messages"]["json_parsing_failed"].format(error=e) + ) from e + + def _build_prompt(self, question: str, answer: str) -> str: + """프롬프트 구성""" + system_prompt = self.config["prompts"]["chat_assistant"]["system"] + user_template = self.config["prompts"]["chat_assistant"]["user_template"] + + return f"{system_prompt}\n\n{user_template.format(question=question, answer=answer)}" + + def _build_body_result_prompt( + self, answers: list[str], height: float, weight: float, gender: str + ) -> str: + """체형 진단 결과 프롬프트 구성""" + return ( + f"다음 응답 내용을 바탕으로 골격 진단 결과를 알려줘\n" + f"- 성별: {gender}\n" + f"- 키: {height}cm\n" + f"- 체중: {weight}kg\n" + f"- 설문 응답:\n" + "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers)) + "\n\n" + "체형 진단" + ) diff --git a/chatbot/agents/conversation_graph.py b/chatbot/agents/conversation_graph.py new file mode 100644 index 0000000..f2d45a0 --- /dev/null +++ b/chatbot/agents/conversation_graph.py @@ -0,0 +1,299 @@ +import time +from typing import Any, TypedDict + +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, StateGraph + +from chatbot.config.chatbot_config import config + + +class ConversationState(TypedDict): + current_question_id: int + questions: list[dict[str, Any]] + user_answers: dict[int, str] + validation_results: dict[int, dict[str, Any]] + conversation_history: list[dict[str, Any]] + current_status: str + error_message: str + next_question: dict[str, Any] + is_completed: bool + retry_count: dict[int, int] + + +class ConversationGraphAgent: + """LangGraph를 사용한 대화 그래프 에이전트""" + + def __init__(self) -> None: + self.questions = config.body_diagnosis_config.questions + self.graph = self._build_graph() + self.memory = MemorySaver() + + def _build_graph(self) -> StateGraph: + """대화 그래프 구성""" + workflow = StateGraph(ConversationState) + + # 노드 추가 + workflow.add_node("ask_question", self._ask_question) + workflow.add_node("validate_answer", self._validate_answer) + workflow.add_node("handle_error", self._handle_error) + workflow.add_node("save_answer", self._save_answer) + workflow.add_node("check_completion", self._check_completion) + + # 엣지 연결 + workflow.set_entry_point("ask_question") + + workflow.add_edge("ask_question", "validate_answer") + workflow.add_conditional_edges( + "validate_answer", + self._route_after_validation, + {"valid": "save_answer", "invalid": "handle_error", "completed": END}, + ) + workflow.add_edge("handle_error", "ask_question") + workflow.add_edge("save_answer", "check_completion") + workflow.add_conditional_edges( + "check_completion", + self._route_after_save, + {"continue": "ask_question", "completed": END}, + ) + + return workflow.compile(checkpointer=self.memory) + + def _ask_question(self, state: ConversationState) -> ConversationState: + """질문 제시""" + current_id = state["current_question_id"] + question = next((q for q in state["questions"] if q["id"] == current_id), None) + + if question: + state["next_question"] = question + state["current_status"] = "asking" + state["error_message"] = "" + else: + state["is_completed"] = True + state["current_status"] = "completed" + + return state + + def _validate_answer(self, state: ConversationState) -> ConversationState: + """답변 검증""" + current_id = state["current_question_id"] + question = next((q for q in state["questions"] if q["id"] == current_id), None) + + if not question: + state["current_status"] = "completed" + return state + + # 사용자 답변 가져오기 (실제 구현에서는 외부에서 주입) + user_answer = state.get("user_answers", {}).get(current_id, "") + + if not user_answer: + state["current_status"] = "waiting_answer" + return state + + # 검증 수행 + validation_result = self._perform_validation(user_answer, question["validation"]) + + if validation_result["valid"]: + state["current_status"] = "valid" + state["validation_results"][current_id] = validation_result + else: + state["current_status"] = "invalid" + state["error_message"] = validation_result["error_message"] + + return state + + def _perform_validation(self, answer: str, rules: dict[str, Any]) -> dict[str, Any]: + """답변 검증 로직""" + validation_type = rules["type"] + + if validation_type == "numeric_range": + try: + value = float(answer) + min_val = rules["min"] + max_val = rules["max"] + + if min_val <= value <= max_val: + return { + "valid": True, + "normalized_value": value, + "error_message": "", + } + else: + return {"valid": False, "error_message": rules["error_message"]} + except ValueError: + return {"valid": False, "error_message": "숫자를 입력해주세요."} + + elif validation_type == "choice": + if answer in rules["options"]: + return {"valid": True, "normalized_value": answer, "error_message": ""} + else: + return {"valid": False, "error_message": rules["error_message"]} + + return {"valid": True, "normalized_value": answer, "error_message": ""} + + def _generate_help_message(self, question: dict[str, Any], user_answer: str) -> str: + """사용자 답변을 분석하여 도움말 생성""" + validation_type = question["validation"]["type"] + retry_count = question.get("retry_count", 0) + + # 기본 도움말 + base_help = question.get("help_text", "") + + if validation_type == "numeric_range": + min_val = question["validation"]["min"] + max_val = question["validation"]["max"] + + if retry_count == 1: + return f"{base_help}\n💡 힌트: {min_val}에서 {max_val} 사이의 숫자를 입력해주세요." + elif retry_count == 2: + return f"{base_help}\n💡 구체적 예시: {min_val + (max_val - min_val) // 2}와 같은 숫자를 입력해보세요." + else: + return f"{base_help}\n💡 자세한 설명: {question['question']}에 대한 답변은 {min_val}에서 {max_val} 사이의 숫자여야 합니다." + + elif validation_type == "choice": + options = question["validation"]["options"] + + if retry_count == 1: + return f"{base_help}\n💡 힌트: 다음 중에서 선택해주세요: {', '.join(options)}" + elif retry_count == 2: + return f"{base_help}\n💡 구체적 예시: '{options[0]}' 또는 '{options[1]}' 중 하나를 입력해보세요." + else: + return f"{base_help}\n💡 자세한 설명: 정확히 '{' 또는 '.join(options)}' 중 하나를 입력해야 합니다." + + # 일반적인 경우 + if retry_count == 1: + return f"{base_help}\n💡 힌트: 질문을 다시 한 번 읽어보세요." + elif retry_count == 2: + return f"{base_help}\n💡 구체적 예시: {base_help}에 나와있는 형식으로 답변해보세요." + else: + return f"{base_help}\n💡 자세한 설명: {question['question']}에 대한 답변을 정확히 입력해주세요." + + def _handle_error(self, state: ConversationState) -> ConversationState: + """에러 처리 및 재질문 (도움말 포함)""" + current_id = state["current_question_id"] + question = next((q for q in state["questions"] if q["id"] == current_id), None) + + # 에러 분석 및 도움말 생성 + help_message = self._generate_help_message( + question, state.get("user_answers", {}).get(current_id, "") + ) + + # 질문에 도움말 추가 + enhanced_question = question.copy() + enhanced_question["help_message"] = help_message + enhanced_question["retry_count"] = state.get("retry_count", {}).get(current_id, 0) + 1 + + # 재시도 횟수 업데이트 + if "retry_count" not in state: + state["retry_count"] = {} + state["retry_count"][current_id] = enhanced_question["retry_count"] + + # 에러 메시지와 함께 같은 질문 다시 제시 + state["next_question"] = enhanced_question + state["current_status"] = "retry" + + # 대화 히스토리에 에러 기록 + state["conversation_history"].append( + { + "type": "error", + "question_id": current_id, + "error": state["error_message"], + "help_message": help_message, + "retry_count": enhanced_question["retry_count"], + "timestamp": time.time(), + } + ) + + return state + + def _save_answer(self, state: ConversationState) -> ConversationState: + """유효한 답변 저장""" + current_id = state["current_question_id"] + validation_result = state["validation_results"][current_id] + + # 답변 저장 + state["user_answers"][current_id] = validation_result["normalized_value"] + + # 대화 히스토리에 성공 기록 + state["conversation_history"].append( + { + "type": "success", + "question_id": current_id, + "answer": validation_result["normalized_value"], + "timestamp": time.time(), + } + ) + + return state + + def _check_completion(self, state: ConversationState) -> ConversationState: + """완료 여부 확인""" + current_id = state["current_question_id"] + + if current_id >= len(state["questions"]): + state["is_completed"] = True + state["current_status"] = "completed" + return state + + # 다음 질문으로 이동 + state["current_question_id"] = current_id + 1 + state["current_status"] = "continue" + + return state + + def _route_after_validation(self, state: ConversationState) -> str: + """검증 후 라우팅""" + if state["current_status"] == "completed": + return "completed" + elif state["current_status"] == "valid": + return "valid" + else: + return "invalid" + + def _route_after_save(self, state: ConversationState) -> str: + """저장 후 라우팅""" + if state["is_completed"]: + return "completed" + else: + return "continue" + + def start_conversation(self, config: dict[str, Any] = None) -> ConversationState: + """대화 시작""" + initial_state = ConversationState( + current_question_id=1, + questions=self.questions, + user_answers={}, + validation_results={}, + conversation_history=[], + current_status="starting", + error_message="", + next_question={}, + is_completed=False, + retry_count={}, + ) + + if config: + initial_state.update(config) + + return initial_state + + def submit_answer(self, state: ConversationState, answer: str) -> ConversationState: + """답변 제출""" + current_id = state["current_question_id"] + state["user_answers"][current_id] = answer + + # 그래프 실행 + result = self.graph.invoke(state) + return result + + def get_current_status(self, state: ConversationState) -> dict[str, Any]: + """현재 상태 반환""" + return { + "current_question_id": state["current_question_id"], + "total_questions": len(state["questions"]), + "progress": f"{state['current_question_id']}/{len(state['questions'])}", + "current_status": state["current_status"], + "next_question": state.get("next_question", {}), + "error_message": state.get("error_message", ""), + "is_completed": state["is_completed"], + "answers": state["user_answers"], + } diff --git a/chatbot/agents/style_content_agent.py b/chatbot/agents/style_content_agent.py new file mode 100644 index 0000000..fe6b256 --- /dev/null +++ b/chatbot/agents/style_content_agent.py @@ -0,0 +1,127 @@ +""" +Style Content Agent +스타일 콘텐츠 생성을 담당하는 에이전트 +""" + +import time + +from openai import OpenAI + +from chatbot.config import load_config + + +class StyleContentAgent: + """스타일 콘텐츠 생성 에이전트""" + + def __init__(self) -> None: + self.config = load_config() + self.client = OpenAI(api_key=self.config.get("openai_api_key")) + self.assistant_id = self.config["assistants"]["style_assistant_id"] + + def create_content( + self, + name: str, + body_type: str, + height: int, + weight: int, + body_feature: str, + recommendation_items: list[str], + recommended_situation: str, + recommended_style: str, + avoid_style: str, + budget: str, + ) -> str: + """ + 스타일 추천 콘텐츠 생성 + + Args: + name: 이름 + body_type: 체형 타입 + height: 키 (cm) + weight: 몸무게 (kg) + body_feature: 체형적 특징 + recommendation_items: 추천 받고 싶은 아이템 + recommended_situation: 입고 싶은 상황 + recommended_style: 추천받고 싶은 스타일 + avoid_style: 피하고 싶은 스타일 + budget: 예산 + + Returns: + 생성된 콘텐츠 문자열 + """ + prompt = self._build_prompt( + name=name, + body_type=body_type, + height=height, + weight=weight, + body_feature=body_feature, + recommendation_items=recommendation_items, + recommended_situation=recommended_situation, + recommended_style=recommended_style, + avoid_style=avoid_style, + budget=budget, + ) + + run = self.client.beta.threads.create_and_run( + assistant_id=self.assistant_id, + thread={"messages": [{"role": "user", "content": prompt}]}, + ) + + thread_id = run.thread_id + run_id = run.id + + # 완료 대기 + while True: + status = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) + if status.status == "completed": + break + if status.status in {"failed", "cancelled", "expired"}: + last_error = getattr(status, "last_error", None) + raise RuntimeError(f"Style content generation failed: {last_error}") + time.sleep(0.3) + + # 결과 추출 + msgs = self.client.beta.threads.messages.list(thread_id=thread_id).data + if not msgs: + raise RuntimeError("No messages found from style assistant") + + # 첫 번째 메시지에서 텍스트 추출 + first_msg = msgs[0] + if not first_msg.content or getattr(first_msg.content[0], "type", "text") != "text": + raise RuntimeError("Style assistant message has no text content") + + return first_msg.content[0].text.value + + def _build_prompt( + self, + name: str, + body_type: str, + height: int, + weight: int, + body_feature: str, + recommendation_items: list[str], + recommended_situation: str, + recommended_style: str, + avoid_style: str, + budget: str, + ) -> str: + """프롬프트 구성""" + system_prompt = self.config["prompts"]["style_content"]["system"] + user_template = self.config["prompts"]["style_content"]["user_template"] + + items_section = "\n".join(f"{i + 1}. {item}" for i, item in enumerate(recommendation_items)) + + return f"{system_prompt}\n\n{ + user_template.format( + name=name, + body_type=body_type, + height=height, + weight=weight, + body_feature=body_feature, + items_section=items_section, + recommended_situation=recommended_situation, + recommended_style=recommended_style, + avoid_style=avoid_style, + budget=budget, + ) + }" diff --git a/chatbot/api/conversation.py b/chatbot/api/conversation.py new file mode 100644 index 0000000..22c7dcb --- /dev/null +++ b/chatbot/api/conversation.py @@ -0,0 +1,168 @@ +""" +Conversation API endpoints +대화 그래프 에이전트를 사용하는 API 엔드포인트 +""" + +import uuid +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from chatbot.agents.conversation_graph import ConversationGraphAgent, ConversationState + +router = APIRouter(prefix="/conversation", tags=["conversation"]) + +# 세션 저장소 (실제 구현에서는 Redis나 데이터베이스 사용) +conversation_sessions: Dict[str, ConversationState] = {} +conversation_agent = ConversationGraphAgent() + + +class StartConversationRequest(BaseModel): + custom_questions: Optional[list] = None + user_preferences: Optional[Dict[str, Any]] = None + + +class SubmitAnswerRequest(BaseModel): + session_id: str + answer: str + + +class ConversationResponse(BaseModel): + session_id: str + status: str + current_question: Optional[Dict[str, Any]] = None + error_message: Optional[str] = None + progress: Optional[str] = None + is_completed: bool = False + answers: Optional[Dict[str, Any]] = None + + +@router.post("/start", response_model=ConversationResponse) +async def start_conversation(request: StartConversationRequest): + """대화 시작""" + try: + # 세션 ID 생성 + session_id = str(uuid.uuid4()) + + # 대화 시작 + initial_state = conversation_agent.start_conversation() + + if request.custom_questions: + initial_state["questions"] = request.custom_questions + + if request.user_preferences: + initial_state["user_preferences"] = request.user_preferences + + # 세션 저장 + conversation_sessions[session_id] = initial_state + + # 첫 번째 질문 가져오기 + current_status = conversation_agent.get_current_status(initial_state) + + return ConversationResponse( + session_id=session_id, + status="started", + current_question=current_status.get("next_question"), + progress=current_status.get("progress"), + is_completed=False, + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"대화 시작 실패: {str(e)}") + + +@router.post("/answer", response_model=ConversationResponse) +async def submit_answer(request: SubmitAnswerRequest): + """답변 제출""" + try: + # 세션 확인 + if request.session_id not in conversation_sessions: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") + + current_state = conversation_sessions[request.session_id] + + # 답변 제출 및 검증 + updated_state = conversation_agent.submit_answer(current_state, request.answer) + + # 세션 업데이트 + conversation_sessions[request.session_id] = updated_state + + # 현재 상태 반환 + current_status = conversation_agent.get_current_status(updated_state) + + return ConversationResponse( + session_id=request.session_id, + status=current_status["current_status"], + current_question=current_status.get("next_question"), + error_message=current_status.get("error_message"), + progress=current_status.get("progress"), + is_completed=current_status["is_completed"], + answers=current_status.get("answers"), + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"답변 처리 실패: {str(e)}") + + +@router.get("/status/{session_id}", response_model=ConversationResponse) +async def get_conversation_status(session_id: str): + """대화 상태 확인""" + try: + if session_id not in conversation_sessions: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") + + current_state = conversation_sessions[session_id] + current_status = conversation_agent.get_current_status(current_state) + + return ConversationResponse( + session_id=session_id, + status=current_status["current_status"], + current_question=current_status.get("next_question"), + error_message=current_status.get("error_message"), + progress=current_status.get("progress"), + is_completed=current_status["is_completed"], + answers=current_status.get("answers"), + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"상태 확인 실패: {str(e)}") + + +@router.delete("/session/{session_id}") +async def delete_conversation_session(session_id: str): + """대화 세션 삭제""" + try: + if session_id in conversation_sessions: + del conversation_sessions[session_id] + return {"message": "세션이 삭제되었습니다"} + else: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") + + except Exception as e: + raise HTTPException(status_code=500, detail=f"세션 삭제 실패: {str(e)}") + + +@router.get("/sessions") +async def list_conversation_sessions(): + """활성 세션 목록""" + try: + sessions = [] + for session_id, state in conversation_sessions.items(): + current_status = conversation_agent.get_current_status(state) + sessions.append( + { + "session_id": session_id, + "status": current_status["current_status"], + "progress": current_status.get("progress"), + "is_completed": current_status["is_completed"], + "created_at": state.get("conversation_history", [{}])[0].get("timestamp") + if state.get("conversation_history") + else None, + } + ) + + return {"sessions": sessions} + + except Exception as e: + raise HTTPException(status_code=500, detail=f"세션 목록 조회 실패: {str(e)}") diff --git a/chatbot/assistant.py b/chatbot/assistant.py index 4b0899c..7fbc57b 100644 --- a/chatbot/assistant.py +++ b/chatbot/assistant.py @@ -1,10 +1,14 @@ from fastapi import APIRouter, HTTPException -from app.schemas.chat import ChatRequest, ChatResponse -from app.services.assistant_service import AssistantService +from chatbot.agents import BodyDiagnosisAgent, ChatAssistantAgent, StyleContentAgent +from chatbot.schemas import ChatRequest, ChatResponse router = APIRouter() -assistant_service = AssistantService() + +# 에이전트 인스턴스 생성 +body_diagnosis_agent = BodyDiagnosisAgent() +style_content_agent = StyleContentAgent() +chat_assistant_agent = ChatAssistantAgent() @router.post("/chat", response_model=ChatResponse) @@ -13,8 +17,102 @@ async def chat_with_assistant(request: ChatRequest): 챗봇 어시스턴트와 대화 """ try: - response = await assistant_service.process_chat(request) - return response + response = chat_assistant_agent.chat(request.question, request.answer) + return ChatResponse( + is_success=response.get("isSuccess", False), + selected=response.get("selected", ""), + message=response.get("message", ""), + next_question=response.get("nextQuestion", ""), + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/diagnose") +async def diagnose_body_type(request: dict): + """ + 체형 진단 + """ + try: + result = body_diagnosis_agent.diagnose( + request["answers"], request["height"], request["weight"], request["gender"] + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/diagnose/soft") +async def diagnose_body_type_soft(request: dict): + """ + 소프트 체형 진단 (비동기) + """ + try: + result = body_diagnosis_agent.diagnose_soft( + request["answers"], request["height"], request["weight"], request["gender"] + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/diagnose/status/{thread_id}/{run_id}") +async def get_diagnosis_status(thread_id: str, run_id: str): + """ + 진단 상태 조회 + """ + try: + result = body_diagnosis_agent.get_run_status(thread_id, run_id) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/diagnose/result/{thread_id}/{run_id}") +async def get_diagnosis_result(thread_id: str, run_id: str): + """ + 진단 결과 조회 + """ + try: + result = body_diagnosis_agent.get_run_result(thread_id, run_id) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/content") +async def create_style_content(request: dict): + """ + 스타일 콘텐츠 생성 + """ + try: + result = style_content_agent.create_content( + request["name"], + request["body_type"], + request["height"], + request["weight"], + request["body_feature"], + request["recommendation_items"], + request["recommended_situation"], + request["recommended_style"], + request["avoid_style"], + request["budget"], + ) + return {"content": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/chat/body-result") +async def chat_body_result(request: dict): + """ + 체형 진단 결과를 위한 채팅 + """ + try: + result = chat_assistant_agent.chat_body_result( + request["answers"], request["height"], request["weight"], request["gender"] + ) + return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/chatbot/config/config.yaml b/chatbot/config/config.yaml deleted file mode 100644 index 80cd018..0000000 --- a/chatbot/config/config.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# Chatbot Configuration -# 프롬프트와 모델 설정을 관리하는 설정 파일 - -# OpenAI Assistant IDs -assistants: - body_assistant_id: ${OPENAI_BODY_ASSISTANT_ID} - style_assistant_id: ${OPENAI_STYLE_ASSISTANT_ID} - chat_assistant_id: ${OPENAI_CHAT_ASSISTANT_ID} - -# Model Configuration -models: - default_model: "gpt-4" - temperature: 0.7 - max_tokens: 1000 - timeout_seconds: 60 - soft_wait_seconds: 25 - -# Prompts -prompts: - body_diagnosis: - system: | - 당신은 골격 진단 및 패션 스타일리스트입니다. - 사용자 정보를 바탕으로 체형을 진단하고, 반드시 JSON으로만 응답하세요. - 출력은 다음 스키마의 각 필드를 한국어로 충실히 채우세요. - 모든 값은 문자열입니다. - - 필드: body_type, type_description, detailed_features, - attraction_points, recommended_styles, avoid_styles, - styling_fixes, styling_tips - - 주의: 코드블록 없이 순수 JSON만 출력하세요. - - user_template: | - - 성별: {gender} - - 키: {height}cm - - 체중: {weight}kg - - 설문 응답: - {answers_formatted} - - style_content: - system: | - 당신은 패션 스타일리스트입니다. - 사용자 정보를 바탕으로 스타일 추천 콘텐츠를 작성해주세요. - - user_template: | - 다음 정보를 바탕으로 **스타일 추천 콘텐츠 초안**을 작성해줘. - - - 이름: {name} - - 체형 타입: {body_type} - - 키: {height}cm - - 몸무게: {weight}kg - - 체형 특징: {body_feature} - - 추천 아이템: - {items_section} - - - 입고 싶은 상황: {recommended_situation} - - 추천 스타일: {recommended_style} - - 피하고 싶은 스타일: {avoid_style} - - 예산: {budget} - - ↳ 초안 작성. - - chat_assistant: - system: | - 당신은 체형 진단을 위한 질문-답변 어시스턴트입니다. - 사용자의 응답을 분석하고 다음 질문을 제시해주세요. - - user_template: | - {question}에 대한 응답입니다. - - 응답: {answer} - 응답을 위 JSON 형식에 맞춰서만 반환하세요. - -# JSON Schemas -json_schemas: - body_diagnosis: - name: "BodyDiagnosisResult" - strict: true - schema: - type: "object" - properties: - body_type: {"type": "string"} - type_description: {"type": "string"} - detailed_features: {"type": "string"} - attraction_points: {"type": "string"} - recommended_styles: {"type": "string"} - avoid_styles: {"type": "string"} - styling_fixes: {"type": "string"} - styling_tips: {"type": "string"} - required: - - body_type - - type_description - - detailed_features - - attraction_points - - recommended_styles - - avoid_styles - - styling_fixes - - styling_tips - additionalProperties: false - - chat_response: - name: "BodyQuestionAnswer" - strict: true - schema: - type: "object" - properties: - isSuccess: {"type": "boolean"} - selected: {"type": ["string", "null"]} - message: {"type": "string"} - nextQuestion: {"type": ["string", "null"]} - required: ["isSuccess", "selected", "message", "nextQuestion"] - additionalProperties: false - -# Error Messages -error_messages: - no_assistant_message: "No assistant message found" - no_text_content: "Assistant message has no text content" - json_parsing_failed: "JSON 파싱 실패: {error}" - run_timeout: "Assistants run timed out" - run_failed: "Assistants run ended with status={status}, last_error={error}" diff --git a/chatbot/schemas.py b/chatbot/schemas.py deleted file mode 100644 index 19d723d..0000000 --- a/chatbot/schemas.py +++ /dev/null @@ -1,118 +0,0 @@ -from datetime import datetime -from enum import Enum -from typing import Any, Optional - -from pydantic import BaseModel, Field - - -# Chat related schemas -class ChatRequest(BaseModel): - """채팅 요청 모델""" - - message: str = Field(..., description="사용자 메시지") - user_id: Optional[str] = Field(None, description="사용자 ID") - session_id: Optional[str] = Field(None, description="세션 ID") - context: Optional[dict[str, Any]] = Field(None, description="대화 컨텍스트") - - -class ChatResponse(BaseModel): - """채팅 응답 모델""" - - message: str = Field(..., description="어시스턴트 응답") - session_id: str = Field(..., description="세션 ID") - timestamp: datetime = Field(default_factory=datetime.now) - context: Optional[dict[str, Any]] = Field(None, description="업데이트된 컨텍스트") - - -# Content related schemas -class ContentType(str, Enum): - """콘텐츠 타입""" - - ARTICLE = "article" - VIDEO = "video" - IMAGE = "image" - AUDIO = "audio" - - -class ContentItem(BaseModel): - """콘텐츠 아이템 모델""" - - id: str = Field(..., description="콘텐츠 ID") - title: str = Field(..., description="콘텐츠 제목") - description: Optional[str] = Field(None, description="콘텐츠 설명") - type: ContentType = Field(..., description="콘텐츠 타입") - url: Optional[str] = Field(None, description="콘텐츠 URL") - tags: list[str] = Field(default_factory=list, description="콘텐츠 태그") - created_at: datetime = Field(default_factory=datetime.now) - - -class ContentRecommendation(BaseModel): - """콘텐츠 추천 모델""" - - user_id: str = Field(..., description="사용자 ID") - recommendations: list[ContentItem] = Field(..., description="추천 콘텐츠 목록") - reason: str = Field(..., description="추천 이유") - confidence: float = Field(..., ge=0.0, le=1.0, description="추천 신뢰도") - - -# Diagnosis related schemas -class DiagnosisType(str, Enum): - """진단 타입""" - - BODY_SHAPE = "body_shape" - FASHION_STYLE = "fashion_style" - SKIN_TONE = "skin_tone" - PERSONALITY = "personality" - - -class DiagnosisQuestion(BaseModel): - """진단 질문 모델""" - - id: str = Field(..., description="질문 ID") - question: str = Field(..., description="질문 내용") - type: DiagnosisType = Field(..., description="진단 타입") - options: Optional[list[str]] = Field(None, description="선택지 옵션") - required: bool = Field(True, description="필수 질문 여부") - - -class DiagnosisAnswer(BaseModel): - """진단 답변 모델""" - - question_id: str = Field(..., description="질문 ID") - answer: str = Field(..., description="사용자 답변") - timestamp: datetime = Field(default_factory=datetime.now) - - -class DiagnosisResult(BaseModel): - """진단 결과 모델""" - - user_id: str = Field(..., description="사용자 ID") - diagnosis_type: DiagnosisType = Field(..., description="진단 타입") - answers: list[DiagnosisAnswer] = Field(..., description="답변 목록") - result: dict[str, Any] = Field(..., description="진단 결과") - confidence: float = Field(..., ge=0.0, le=1.0, description="진단 신뢰도") - created_at: datetime = Field(default_factory=datetime.now) - recommendations: Optional[list[str]] = Field(None, description="추천사항") - - -# Assistant service schemas -class AssistantConfig(BaseModel): - """어시스턴트 설정 모델""" - - model: str = Field(default="gpt-4", description="사용할 AI 모델") - temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="창의성 수준") - max_tokens: int = Field(default=1000, ge=1, description="최대 토큰 수") - system_prompt: Optional[str] = Field(None, description="시스템 프롬프트") - - -class AssistantContext(BaseModel): - """어시스턴트 컨텍스트 모델""" - - user_profile: Optional[dict[str, Any]] = Field(None, description="사용자 프로필") - conversation_history: list[dict[str, Any]] = Field( - default_factory=list, description="대화 히스토리" - ) - current_diagnosis: Optional[DiagnosisResult] = Field( - None, description="현재 진단 결과" - ) - preferences: Optional[dict[str, Any]] = Field(None, description="사용자 선호도") diff --git a/app/schemas/chat.py b/chatbot/schemas/chat.py similarity index 55% rename from app/schemas/chat.py rename to chatbot/schemas/chat.py index dbecf85..6148b97 100644 --- a/app/schemas/chat.py +++ b/chatbot/schemas/chat.py @@ -1,25 +1,24 @@ -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field class ChatRequest(BaseModel): - question: str = (Field(..., description="질문"),) - answer: str = Field(..., description="응답") + question: str = Field(..., description="Question") + answer: str = Field(..., description="Answer") - model_config = ConfigDict( - json_schema_extra={ + class Config: + json_schema_extra = { "example": { "question": "1. 전체적인 골격의 인상은 어떠한가요?", "answer": "두께감이 있고, 육감적입니다.", } } - ) class ChatResponse(BaseModel): - is_success: bool - selected: str - message: str - next_question: str + is_success: bool = Field(..., description="Success status") + selected: str = Field(..., description="Selected option") + message: str = Field(..., description="Response message") + next_question: str = Field(..., description="Next question to ask") class Config: schema_extra = { diff --git a/chatbot/schemas/chatbot.py b/chatbot/schemas/chatbot.py new file mode 100644 index 0000000..6148b97 --- /dev/null +++ b/chatbot/schemas/chatbot.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field + + +class ChatRequest(BaseModel): + question: str = Field(..., description="Question") + answer: str = Field(..., description="Answer") + + class Config: + json_schema_extra = { + "example": { + "question": "1. 전체적인 골격의 인상은 어떠한가요?", + "answer": "두께감이 있고, 육감적입니다.", + } + } + + +class ChatResponse(BaseModel): + is_success: bool = Field(..., description="Success status") + selected: str = Field(..., description="Selected option") + message: str = Field(..., description="Response message") + next_question: str = Field(..., description="Next question to ask") + + class Config: + schema_extra = { + "example": { + "is_success": "스트레이트", + "selected": "어깨와 엉덩이 폭이 비슷한...", + "message": "...", + "next_question": "...", + } + } diff --git a/chatbot/schemas/content.py b/chatbot/schemas/content.py new file mode 100644 index 0000000..3cf2656 --- /dev/null +++ b/chatbot/schemas/content.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field + + +class CreateContentRequest(BaseModel): + name: str = Field(..., description="Name") + body_type: str = Field(..., description="Body type") + height: int = Field(..., description="Height in cm") + weight: int = Field(..., description="Weight in kg") + body_feature: str = Field(..., description="Body features") + recommendation_items: list[str] = Field(..., description="Items to recommend") + recommended_situation: str = Field(..., description="Desired situation") + recommended_style: str = Field(..., description="Desired style") + avoid_style: str = Field(..., description="Style to avoid") + budget: str = Field(..., description="Budget") + + class Config: + json_schema_extra = { + "example": { + "name": "전여진", + "body_type": "웨이브", + "height": 160, + "weight": 40, + "body_feature": "체형이 너무 얇다", + "recommendation_items": ["상의", "하의"], + "recommended_situation": "IR발표", + "recommended_style": "IR 발표에 어울리는 스타일", + "avoid_style": "스트릿,힙한 스타일", + "budget": "20만원", + } + } diff --git a/app/schemas/diagnosis.py b/chatbot/schemas/diagnosis.py similarity index 66% rename from app/schemas/diagnosis.py rename to chatbot/schemas/diagnosis.py index c9212d5..dcb4abe 100644 --- a/app/schemas/diagnosis.py +++ b/chatbot/schemas/diagnosis.py @@ -1,14 +1,14 @@ -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field class DiagnoseRequest(BaseModel): - answers: list[str] = Field(..., description="설문 응답 리스트") - height: float = Field(..., description="키 (cm)") - weight: float = Field(..., description="체중 (kg)") - gender: str = Field(..., description="성별") + answers: list[str] = Field(..., description="List of survey responses") + height: float = Field(..., description="Height in cm") + weight: float = Field(..., description="Weight in kg") + gender: str = Field(..., description="Gender") - model_config = ConfigDict( - json_schema_extra={ + class Config: + json_schema_extra = { "example": { "answers": [ "두께감이 있고 육감적이다", @@ -34,18 +34,17 @@ class DiagnoseRequest(BaseModel): "gender": "여성", } } - ) class DiagnoseResponse(BaseModel): - body_type: str - type_description: str - detailed_features: str - attraction_points: str - recommended_styles: str - avoid_styles: str - styling_fixes: str - styling_tips: str + body_type: str = Field(..., description="Body type classification") + type_description: str = Field(..., description="Detailed description of body type") + detailed_features: str = Field(..., description="Specific body features and characteristics") + attraction_points: str = Field(..., description="Highlighted attractive features") + recommended_styles: str = Field(..., description="Recommended fashion styles") + avoid_styles: str = Field(..., description="Styles to avoid") + styling_fixes: str = Field(..., description="Styling tips and fixes") + styling_tips: str = Field(..., description="Additional styling advice") class Config: schema_extra = { diff --git a/chatbot/utils/config_handler.py b/chatbot/utils/config_handler.py new file mode 100644 index 0000000..d1e4565 --- /dev/null +++ b/chatbot/utils/config_handler.py @@ -0,0 +1,10 @@ +from typing import Any + +import yaml + + +def load_config(path: str) -> dict[str, Any]: + config = {} + with open(path, encoding="utf-8") as file: + config = yaml.load(file, Loader=yaml.FullLoader) + return config diff --git a/chatbot/utils/path.py b/chatbot/utils/path.py new file mode 100644 index 0000000..ae5a539 --- /dev/null +++ b/chatbot/utils/path.py @@ -0,0 +1,8 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] + +CONFIG_DIR = PROJECT_ROOT / "config" + +CHATBOT_CONFIG = CONFIG_DIR / "chatbotAgent.yaml" +BODY_DIAGNOSIS_QUESTIONS = CONFIG_DIR / "body_diagnosis_questions.json" diff --git a/pyproject.toml b/pyproject.toml index 36247c4..bd67908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "pydantic~=2.11.7", "mangum~=0.19.0", "pyyaml>=6.0", + "langgraph>=0.2.0", + "langchain>=0.2.0", ] [project.optional-dependencies] @@ -36,7 +38,8 @@ build-backend = "setuptools.build_meta" include = ["app*", "chatbot*", "backend*"] [tool.ruff] -line-length = 88 +line-length = 100 +fix = true # 자동 수정 활성화 [tool.ruff.lint.per-file-ignores] "*.ipynb" = ["E501"] diff --git a/test_conversation.py b/test_conversation.py new file mode 100644 index 0000000..84b6abb --- /dev/null +++ b/test_conversation.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Conversation Graph Agent Test Script +대화 그래프 에이전트 테스트 스크립트 +""" + +from chatbot.agents.conversation_graph import ConversationGraphAgent + + +def test_conversation_flow(): + """대화 플로우 테스트""" + print("=== 대화 그래프 에이전트 테스트 ===\n") + + # 에이전트 초기화 + agent = ConversationGraphAgent() + + # 대화 시작 + print("1. 대화 시작") + state = agent.start_conversation() + print(f" 세션 상태: {state['current_status']}") + print(f" 첫 번째 질문: {state['next_question']['question']}") + print(f" 도움말: {state['next_question']['help_text']}") + print() + + # 첫 번째 답변 (유효한 답변) + print("2. 첫 번째 답변 제출 (유효한 답변)") + print(" 답변: 175") + state = agent.submit_answer(state, "175") + current_status = agent.get_current_status(state) + print(f" 상태: {current_status['current_status']}") + print(f" 진행률: {current_status['progress']}") + print(f" 다음 질문: {current_status['next_question']['question']}") + print() + + # 두 번째 답변 (유효한 답변) + print("3. 두 번째 답변 제출 (유효한 답변)") + print(" 답변: 남성") + state = agent.submit_answer(state, "남성") + current_status = agent.get_current_status(state) + print(f" 상태: {current_status['current_status']}") + print(f" 진행률: {current_status['progress']}") + print(f" 다음 질문: {current_status['next_question']['question']}") + print() + + # 세 번째 답변 (유효한 답변) + print("4. 세 번째 답변 제출 (유효한 답변)") + print(" 답변: 70") + state = agent.submit_answer(state, "70") + current_status = agent.get_current_status(state) + print(f" 상태: {current_status['current_status']}") + print(f" 진행률: {current_status['progress']}") + print(f" 완료 여부: {current_status['is_completed']}") + print(f" 수집된 답변: {current_status['answers']}") + print() + + print("=== 모든 질문 완료! ===") + + +def test_validation_errors(): + """검증 에러 테스트""" + print("\n=== 검증 에러 테스트 ===\n") + + agent = ConversationGraphAgent() + state = agent.start_conversation() + + # 첫 번째 질문에 잘못된 답변 + print("1. 잘못된 답변 테스트 (범위 초과)") + print(" 질문: 키는 얼마인가요? (cm)") + print(" 답변: 300") + state = agent.submit_answer(state, "300") + current_status = agent.get_current_status(state) + print(f" 상태: {current_status['current_status']}") + print(f" 에러 메시지: {current_status['error_message']}") + print(f" 재질문: {current_status['next_question']['question']}") + print() + + # 올바른 답변으로 수정 + print("2. 올바른 답변으로 수정") + print(" 답변: 180") + state = agent.submit_answer(state, "180") + current_status = agent.get_current_status(state) + print(f" 상태: {current_status['current_status']}") + print(f" 진행률: {current_status['progress']}") + print() + + +def test_custom_questions(): + """커스텀 질문 테스트""" + print("\n=== 커스텀 질문 테스트 ===\n") + + custom_questions = [ + { + "id": 1, + "question": "나이는 몇 살인가요?", + "validation": { + "type": "numeric_range", + "min": 10, + "max": 100, + "error_message": "나이는 10세에서 100세 사이여야 합니다.", + }, + "help_text": "예: 25, 30", + }, + { + "id": 2, + "question": "직업은 무엇인가요?", + "validation": { + "type": "choice", + "options": ["학생", "회사원", "자영업자", "기타"], + "error_message": "제시된 옵션 중에서 선택해주세요.", + }, + "help_text": "학생, 회사원, 자영업자, 기타 중 선택", + }, + ] + + agent = ConversationGraphAgent() + state = agent.start_conversation({"questions": custom_questions}) + + print("1. 커스텀 질문으로 대화 시작") + current_status = agent.get_current_status(state) + print(f" 첫 번째 질문: {current_status['next_question']['question']}") + print(f" 도움말: {current_status['next_question']['help_text']}") + print() + + # 답변 제출 + print("2. 답변 제출") + print(" 답변: 25") + state = agent.submit_answer(state, "25") + current_status = agent.get_current_status(state) + print(f" 상태: {current_status['current_status']}") + print(f" 다음 질문: {current_status['next_question']['question']}") + print() + + +if __name__ == "__main__": + # 기본 대화 플로우 테스트 + test_conversation_flow() + + # 검증 에러 테스트 + test_validation_errors() + + # 커스텀 질문 테스트 + test_custom_questions() + + print("\n=== 모든 테스트 완료 ===") From 866ce9b31208cbf8b81f37bac0e872d0561cf0d0 Mon Sep 17 00:00:00 2001 From: "jinhee.lee" Date: Sun, 24 Aug 2025 21:04:23 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Update=20.gitignore?= =?UTF-8?q?=20to=20exclude=20chatbot=20configuration=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 09cdafc..19eb58e 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ uv.lock # Cython cython_debug/ + +# Config files +chatbot/config/ From 5a988b642ecfa853b88d400dd8dbb568432ca3a7 Mon Sep 17 00:00:00 2001 From: "jinhee.lee" Date: Sun, 24 Aug 2025 21:09:54 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Remove=20unused=20C?= =?UTF-8?q?onversation=20API=20and=20configuration=20handler=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatbot/api/conversation.py | 168 ------------------------------- chatbot/utiles/config_handler.py | 10 -- 2 files changed, 178 deletions(-) delete mode 100644 chatbot/api/conversation.py delete mode 100644 chatbot/utiles/config_handler.py diff --git a/chatbot/api/conversation.py b/chatbot/api/conversation.py deleted file mode 100644 index 22c7dcb..0000000 --- a/chatbot/api/conversation.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -Conversation API endpoints -대화 그래프 에이전트를 사용하는 API 엔드포인트 -""" - -import uuid -from typing import Any, Dict, Optional - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from chatbot.agents.conversation_graph import ConversationGraphAgent, ConversationState - -router = APIRouter(prefix="/conversation", tags=["conversation"]) - -# 세션 저장소 (실제 구현에서는 Redis나 데이터베이스 사용) -conversation_sessions: Dict[str, ConversationState] = {} -conversation_agent = ConversationGraphAgent() - - -class StartConversationRequest(BaseModel): - custom_questions: Optional[list] = None - user_preferences: Optional[Dict[str, Any]] = None - - -class SubmitAnswerRequest(BaseModel): - session_id: str - answer: str - - -class ConversationResponse(BaseModel): - session_id: str - status: str - current_question: Optional[Dict[str, Any]] = None - error_message: Optional[str] = None - progress: Optional[str] = None - is_completed: bool = False - answers: Optional[Dict[str, Any]] = None - - -@router.post("/start", response_model=ConversationResponse) -async def start_conversation(request: StartConversationRequest): - """대화 시작""" - try: - # 세션 ID 생성 - session_id = str(uuid.uuid4()) - - # 대화 시작 - initial_state = conversation_agent.start_conversation() - - if request.custom_questions: - initial_state["questions"] = request.custom_questions - - if request.user_preferences: - initial_state["user_preferences"] = request.user_preferences - - # 세션 저장 - conversation_sessions[session_id] = initial_state - - # 첫 번째 질문 가져오기 - current_status = conversation_agent.get_current_status(initial_state) - - return ConversationResponse( - session_id=session_id, - status="started", - current_question=current_status.get("next_question"), - progress=current_status.get("progress"), - is_completed=False, - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"대화 시작 실패: {str(e)}") - - -@router.post("/answer", response_model=ConversationResponse) -async def submit_answer(request: SubmitAnswerRequest): - """답변 제출""" - try: - # 세션 확인 - if request.session_id not in conversation_sessions: - raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") - - current_state = conversation_sessions[request.session_id] - - # 답변 제출 및 검증 - updated_state = conversation_agent.submit_answer(current_state, request.answer) - - # 세션 업데이트 - conversation_sessions[request.session_id] = updated_state - - # 현재 상태 반환 - current_status = conversation_agent.get_current_status(updated_state) - - return ConversationResponse( - session_id=request.session_id, - status=current_status["current_status"], - current_question=current_status.get("next_question"), - error_message=current_status.get("error_message"), - progress=current_status.get("progress"), - is_completed=current_status["is_completed"], - answers=current_status.get("answers"), - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"답변 처리 실패: {str(e)}") - - -@router.get("/status/{session_id}", response_model=ConversationResponse) -async def get_conversation_status(session_id: str): - """대화 상태 확인""" - try: - if session_id not in conversation_sessions: - raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") - - current_state = conversation_sessions[session_id] - current_status = conversation_agent.get_current_status(current_state) - - return ConversationResponse( - session_id=session_id, - status=current_status["current_status"], - current_question=current_status.get("next_question"), - error_message=current_status.get("error_message"), - progress=current_status.get("progress"), - is_completed=current_status["is_completed"], - answers=current_status.get("answers"), - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"상태 확인 실패: {str(e)}") - - -@router.delete("/session/{session_id}") -async def delete_conversation_session(session_id: str): - """대화 세션 삭제""" - try: - if session_id in conversation_sessions: - del conversation_sessions[session_id] - return {"message": "세션이 삭제되었습니다"} - else: - raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") - - except Exception as e: - raise HTTPException(status_code=500, detail=f"세션 삭제 실패: {str(e)}") - - -@router.get("/sessions") -async def list_conversation_sessions(): - """활성 세션 목록""" - try: - sessions = [] - for session_id, state in conversation_sessions.items(): - current_status = conversation_agent.get_current_status(state) - sessions.append( - { - "session_id": session_id, - "status": current_status["current_status"], - "progress": current_status.get("progress"), - "is_completed": current_status["is_completed"], - "created_at": state.get("conversation_history", [{}])[0].get("timestamp") - if state.get("conversation_history") - else None, - } - ) - - return {"sessions": sessions} - - except Exception as e: - raise HTTPException(status_code=500, detail=f"세션 목록 조회 실패: {str(e)}") diff --git a/chatbot/utiles/config_handler.py b/chatbot/utiles/config_handler.py deleted file mode 100644 index d1e4565..0000000 --- a/chatbot/utiles/config_handler.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Any - -import yaml - - -def load_config(path: str) -> dict[str, Any]: - config = {} - with open(path, encoding="utf-8") as file: - config = yaml.load(file, Loader=yaml.FullLoader) - return config From 759db8e4ec2a73bfd91ab2fed7c31110a729e936 Mon Sep 17 00:00:00 2001 From: "jinhee.lee" Date: Sun, 24 Aug 2025 21:45:36 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=20=E2=9C=A8=20Implement=20ChatAgent=20and?= =?UTF-8?q?=20related=20schemas=20for=20chatbot=20functionalit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {chatbot => backend}/schemas/chat.py | 0 {chatbot => backend}/schemas/content.py | 0 {chatbot => backend}/schemas/diagnosis.py | 0 chatbot/agents/chat_agent.py | 164 ++++++++++++ chatbot/agents/chat_assistant_agent.py | 188 -------------- chatbot/agents/conversation_graph.py | 299 ---------------------- chatbot/api/chat_agent_api.py | 143 +++++++++++ chatbot/schemas/chatbot.py | 31 --- 8 files changed, 307 insertions(+), 518 deletions(-) rename {chatbot => backend}/schemas/chat.py (100%) rename {chatbot => backend}/schemas/content.py (100%) rename {chatbot => backend}/schemas/diagnosis.py (100%) create mode 100644 chatbot/agents/chat_agent.py delete mode 100644 chatbot/agents/chat_assistant_agent.py delete mode 100644 chatbot/agents/conversation_graph.py create mode 100644 chatbot/api/chat_agent_api.py delete mode 100644 chatbot/schemas/chatbot.py diff --git a/chatbot/schemas/chat.py b/backend/schemas/chat.py similarity index 100% rename from chatbot/schemas/chat.py rename to backend/schemas/chat.py diff --git a/chatbot/schemas/content.py b/backend/schemas/content.py similarity index 100% rename from chatbot/schemas/content.py rename to backend/schemas/content.py diff --git a/chatbot/schemas/diagnosis.py b/backend/schemas/diagnosis.py similarity index 100% rename from chatbot/schemas/diagnosis.py rename to backend/schemas/diagnosis.py diff --git a/chatbot/agents/chat_agent.py b/chatbot/agents/chat_agent.py new file mode 100644 index 0000000..8d6fc9f --- /dev/null +++ b/chatbot/agents/chat_agent.py @@ -0,0 +1,164 @@ +from typing import Any + +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, StateGraph +from openai import OpenAI +from pydantic import BaseModel, Field + +from chatbot.utils.config_handler import load_config +from chatbot.utils.path import CHATBOT_CONFIG + + +class ChatAgentState(BaseModel): + current_question_id: int = Field(..., description="Current question identifier") + questions: list[dict[str, Any]] = Field(..., description="List of question objects") + user_answers: dict[int, str] = Field( + default_factory=dict, description="User answers mapped by question ID" + ) + validation_results: dict[int, dict[str, Any]] = Field( + default_factory=dict, description="Validation results for each answer" + ) + conversation_history: list[dict[str, Any]] = Field( + default_factory=list, description="Conversation interaction history" + ) + current_status: str = Field(default="starting", description="Current conversation status") + error_message: str = Field(default="", description="Error message if validation fails") + next_question: dict[str, Any] = Field( + default_factory=dict, description="Next question to be asked" + ) + is_completed: bool = Field(default=False, description="Whether the conversation is completed") + retry_count: dict[int, int] = Field( + default_factory=dict, description="Retry count for each question" + ) + chatbot_response: str = Field(default="", description="Chatbot response message") + requires_retry: bool = Field(default=False, description="Whether retry is required") + + +class ChatAgent: + def __init__(self) -> None: + self.config = load_config(CHATBOT_CONFIG) + self.client = OpenAI(api_key=self.config.get("openai_api_key")) + self.assistant_id = self.config["assistants"]["chat_assistant_id"] + self.max_retries = 3 # 최대 재시도 횟수 + self.graph = self._build_graph() + self.memory = MemorySaver() + + def _build_graph(self) -> StateGraph: + """대화 그래프 구성""" + workflow = StateGraph(ChatAgentState) + + # 노드 추가 + workflow.add_node("ask_question", self._ask_question) + workflow.add_node("validate_answer", self._validate_answer) + workflow.add_node("handle_invalid_answer", self._handle_invalid_answer) + workflow.add_node("generate_chatbot_response", self._generate_chatbot_response) + workflow.add_node("save_answer", self._save_answer) + workflow.add_node("check_completion", self._check_completion) + + # 엣지 연결 + workflow.set_entry_point("ask_question") + + workflow.add_edge("ask_question", "validate_answer") + workflow.add_conditional_edges( + "validate_answer", + self._route_after_validation, + { + "valid": "generate_chatbot_response", + "invalid": "handle_invalid_answer", + "completed": END, + }, + ) + workflow.add_edge("handle_invalid_answer", "ask_question") + workflow.add_edge("generate_chatbot_response", "save_answer") + workflow.add_edge("save_answer", "check_completion") + workflow.add_conditional_edges( + "check_completion", + self._route_after_save, + {"continue": "ask_question", "completed": END}, + ) + + return workflow.compile(checkpointer=self.memory) + + def _ask_question(self, state: ChatAgentState) -> ChatAgentState: + """질문 제시""" + # TODO: 질문 제시 로직 구현 + # 재시도인 경우 도움말 포함 + return state + + def _validate_answer(self, state: ChatAgentState) -> ChatAgentState: + """답변 유효성 검증""" + # TODO: 답변 유효성 검증 로직 구현 + return state + + def _handle_invalid_answer(self, state: ChatAgentState) -> ChatAgentState: + """유효하지 않은 답변 처리 및 재질문 가이드""" + # TODO: 재시도 횟수 확인 및 가이드 생성 로직 구현 + return state + + def _generate_chatbot_response(self, state: ChatAgentState) -> ChatAgentState: + """유효한 답변에 대한 챗봇 응답 생성""" + # TODO: 챗봇 응답 생성 로직 구현 + return state + + def _save_answer(self, state: ChatAgentState) -> ChatAgentState: + """유효한 답변 저장""" + # TODO: 답변 저장 로직 구현 + return state + + def _check_completion(self, state: ChatAgentState) -> ChatAgentState: + """완료 여부 확인""" + # TODO: 완료 여부 확인 로직 구현 + return state + + def _route_after_validation(self, state: ChatAgentState) -> str: + """검증 후 라우팅""" + # TODO: 검증 후 라우팅 로직 구현 + return "valid" + + def _route_after_save(self, state: ChatAgentState) -> str: + """저장 후 라우팅""" + # TODO: 저장 후 라우팅 로직 구현 + return "continue" + + def start_conversation(self, questions: list[dict[str, Any]]) -> ChatAgentState: + """대화 시작""" + initial_state = ChatAgentState( + current_question_id=1, + questions=questions, + user_answers={}, + validation_results={}, + conversation_history=[], + current_status="starting", + error_message="", + next_question={}, + is_completed=False, + retry_count={}, + chatbot_response="", + requires_retry=False, + ) + + return initial_state + + def submit_answer(self, state: ChatAgentState, answer: str) -> ChatAgentState: + """답변 제출""" + current_id = state.current_question_id + state.user_answers[current_id] = answer + + # 그래프 실행 + result = self.graph.invoke(state) + return result + + def get_current_status(self, state: ChatAgentState) -> dict[str, Any]: + """현재 상태 반환""" + return { + "current_question_id": state.current_question_id, + "total_questions": len(state.questions), + "progress": f"{state.current_question_id}/{len(state.questions)}", + "current_status": state.current_status, + "next_question": state.next_question, + "error_message": state.error_message, + "is_completed": state.is_completed, + "chatbot_response": state.chatbot_response, + "requires_retry": state.requires_retry, + "answers": state.user_answers, + } diff --git a/chatbot/agents/chat_assistant_agent.py b/chatbot/agents/chat_assistant_agent.py deleted file mode 100644 index 0a4e7f9..0000000 --- a/chatbot/agents/chat_assistant_agent.py +++ /dev/null @@ -1,188 +0,0 @@ -""" -Chat Assistant Agent -채팅 상호작용을 담당하는 에이전트 -""" - -import contextlib -import json -import time -from typing import Any - -from openai import OpenAI - -from chatbot.config import load_config - - -class ChatAssistantAgent: - """채팅 어시스턴트 에이전트""" - - def __init__(self) -> None: - self.config = load_config() - self.client = OpenAI(api_key=self.config.get("openai_api_key")) - self.assistant_id = self.config["assistants"]["chat_assistant_id"] - - def chat( - self, - question: str, - answer: str, - ) -> dict[str, Any]: - """ - 채팅 상호작용 처리 - - Args: - question: 질문 - answer: 응답 - - Returns: - 채팅 응답 딕셔너리 - """ - prompt = self._build_prompt(question, answer) - - run = self.client.beta.threads.create_and_run( - assistant_id=self.assistant_id, - thread={"messages": [{"role": "user", "content": prompt}]}, - response_format={ - "type": "json_schema", - "json_schema": self.config["json_schemas"]["chat_response"], - }, - ) - - thread_id = run.thread_id - run_id = run.id - - # 완료 대기 - while True: - status = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - if status.status == "completed": - break - if status.status in {"failed", "cancelled", "expired"}: - last_error = getattr(status, "last_error", None) - raise RuntimeError(f"Chat assistant failed: {last_error}") - time.sleep(0.3) - - # 결과 추출 - msgs = self.client.beta.threads.messages.list(thread_id=thread_id).data - assistant_msg = next((m for m in msgs if getattr(m, "role", "") == "assistant"), None) - if assistant_msg is None: - raise ValueError(self.config["error_messages"]["no_assistant_message"]) - - # 텍스트 콘텐츠 추출 - text_parts = [ - p for p in getattr(assistant_msg, "content", []) if getattr(p, "type", "") == "text" - ] - if not text_parts: - raise ValueError(self.config["error_messages"]["no_text_content"]) - - text_obj = text_parts[0].text - raw = (text_obj.value if hasattr(text_obj, "value") else str(text_obj)).strip() - - try: - data = json.loads(raw) - except Exception as e: - raise ValueError( - self.config["error_messages"]["json_parsing_failed"].format(error=e) - ) from e - - # null 값 정규화 - if data.get("selected") is None: - data["selected"] = "" - if data.get("nextQuestion") is None: - data["nextQuestion"] = "" - - return data - - def chat_body_result( - self, - answers: list[str], - height: float, - weight: float, - gender: str, - ) -> dict[str, Any]: - """ - 체형 진단 결과를 위한 채팅 - - Args: - answers: 설문 응답 리스트 - height: 키 (cm) - weight: 체중 (kg) - gender: 성별 - - Returns: - 진단 결과 딕셔너리 - """ - prompt = self._build_body_result_prompt(answers, height, weight, gender) - - run = self.client.beta.threads.create_and_run( - assistant_id=self.assistant_id, - thread={"messages": [{"role": "user", "content": prompt}]}, - response_format={ - "type": "json_schema", - "json_schema": self.config["json_schemas"]["body_diagnosis"], - }, - ) - - thread_id = run.thread_id - run_id = run.id - - deadline = time.time() + self.config["models"]["timeout_seconds"] - - while True: - status = self.client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run_id) - if status.status == "completed": - break - if status.status in {"failed", "cancelled", "expired"}: - last_error = getattr(status, "last_error", None) - raise RuntimeError( - f"Run ended with status={status.status}, last_error={last_error}" - ) - if time.time() > deadline: - raise TimeoutError(self.config["error_messages"]["run_timeout"]) - time.sleep(0.3) - - msgs = self.client.beta.threads.messages.list(thread_id=thread_id).data - - # 최신 assistant 메시지 선택 - assistant_msgs = [m for m in msgs if getattr(m, "role", "") == "assistant"] - if not assistant_msgs: - raise ValueError(self.config["error_messages"]["no_assistant_message"]) - - # created_at 기준으로 정렬 - with contextlib.suppress(Exception): - assistant_msgs.sort(key=lambda m: getattr(m, "created_at", 0), reverse=True) - - msg = assistant_msgs[0] - - # 텍스트 콘텐츠 추출 - text_parts = [p for p in getattr(msg, "content", []) if getattr(p, "type", "") == "text"] - if not text_parts: - raise ValueError(self.config["error_messages"]["no_text_content"]) - - raw = text_parts[0].text.value.strip() - - try: - return json.loads(raw) - except Exception as e: - print("🛠️ [DEBUG] raw from assistant:\n", raw) - raise ValueError( - self.config["error_messages"]["json_parsing_failed"].format(error=e) - ) from e - - def _build_prompt(self, question: str, answer: str) -> str: - """프롬프트 구성""" - system_prompt = self.config["prompts"]["chat_assistant"]["system"] - user_template = self.config["prompts"]["chat_assistant"]["user_template"] - - return f"{system_prompt}\n\n{user_template.format(question=question, answer=answer)}" - - def _build_body_result_prompt( - self, answers: list[str], height: float, weight: float, gender: str - ) -> str: - """체형 진단 결과 프롬프트 구성""" - return ( - f"다음 응답 내용을 바탕으로 골격 진단 결과를 알려줘\n" - f"- 성별: {gender}\n" - f"- 키: {height}cm\n" - f"- 체중: {weight}kg\n" - f"- 설문 응답:\n" + "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers)) + "\n\n" - "체형 진단" - ) diff --git a/chatbot/agents/conversation_graph.py b/chatbot/agents/conversation_graph.py deleted file mode 100644 index f2d45a0..0000000 --- a/chatbot/agents/conversation_graph.py +++ /dev/null @@ -1,299 +0,0 @@ -import time -from typing import Any, TypedDict - -from langgraph.checkpoint.memory import MemorySaver -from langgraph.graph import END, StateGraph - -from chatbot.config.chatbot_config import config - - -class ConversationState(TypedDict): - current_question_id: int - questions: list[dict[str, Any]] - user_answers: dict[int, str] - validation_results: dict[int, dict[str, Any]] - conversation_history: list[dict[str, Any]] - current_status: str - error_message: str - next_question: dict[str, Any] - is_completed: bool - retry_count: dict[int, int] - - -class ConversationGraphAgent: - """LangGraph를 사용한 대화 그래프 에이전트""" - - def __init__(self) -> None: - self.questions = config.body_diagnosis_config.questions - self.graph = self._build_graph() - self.memory = MemorySaver() - - def _build_graph(self) -> StateGraph: - """대화 그래프 구성""" - workflow = StateGraph(ConversationState) - - # 노드 추가 - workflow.add_node("ask_question", self._ask_question) - workflow.add_node("validate_answer", self._validate_answer) - workflow.add_node("handle_error", self._handle_error) - workflow.add_node("save_answer", self._save_answer) - workflow.add_node("check_completion", self._check_completion) - - # 엣지 연결 - workflow.set_entry_point("ask_question") - - workflow.add_edge("ask_question", "validate_answer") - workflow.add_conditional_edges( - "validate_answer", - self._route_after_validation, - {"valid": "save_answer", "invalid": "handle_error", "completed": END}, - ) - workflow.add_edge("handle_error", "ask_question") - workflow.add_edge("save_answer", "check_completion") - workflow.add_conditional_edges( - "check_completion", - self._route_after_save, - {"continue": "ask_question", "completed": END}, - ) - - return workflow.compile(checkpointer=self.memory) - - def _ask_question(self, state: ConversationState) -> ConversationState: - """질문 제시""" - current_id = state["current_question_id"] - question = next((q for q in state["questions"] if q["id"] == current_id), None) - - if question: - state["next_question"] = question - state["current_status"] = "asking" - state["error_message"] = "" - else: - state["is_completed"] = True - state["current_status"] = "completed" - - return state - - def _validate_answer(self, state: ConversationState) -> ConversationState: - """답변 검증""" - current_id = state["current_question_id"] - question = next((q for q in state["questions"] if q["id"] == current_id), None) - - if not question: - state["current_status"] = "completed" - return state - - # 사용자 답변 가져오기 (실제 구현에서는 외부에서 주입) - user_answer = state.get("user_answers", {}).get(current_id, "") - - if not user_answer: - state["current_status"] = "waiting_answer" - return state - - # 검증 수행 - validation_result = self._perform_validation(user_answer, question["validation"]) - - if validation_result["valid"]: - state["current_status"] = "valid" - state["validation_results"][current_id] = validation_result - else: - state["current_status"] = "invalid" - state["error_message"] = validation_result["error_message"] - - return state - - def _perform_validation(self, answer: str, rules: dict[str, Any]) -> dict[str, Any]: - """답변 검증 로직""" - validation_type = rules["type"] - - if validation_type == "numeric_range": - try: - value = float(answer) - min_val = rules["min"] - max_val = rules["max"] - - if min_val <= value <= max_val: - return { - "valid": True, - "normalized_value": value, - "error_message": "", - } - else: - return {"valid": False, "error_message": rules["error_message"]} - except ValueError: - return {"valid": False, "error_message": "숫자를 입력해주세요."} - - elif validation_type == "choice": - if answer in rules["options"]: - return {"valid": True, "normalized_value": answer, "error_message": ""} - else: - return {"valid": False, "error_message": rules["error_message"]} - - return {"valid": True, "normalized_value": answer, "error_message": ""} - - def _generate_help_message(self, question: dict[str, Any], user_answer: str) -> str: - """사용자 답변을 분석하여 도움말 생성""" - validation_type = question["validation"]["type"] - retry_count = question.get("retry_count", 0) - - # 기본 도움말 - base_help = question.get("help_text", "") - - if validation_type == "numeric_range": - min_val = question["validation"]["min"] - max_val = question["validation"]["max"] - - if retry_count == 1: - return f"{base_help}\n💡 힌트: {min_val}에서 {max_val} 사이의 숫자를 입력해주세요." - elif retry_count == 2: - return f"{base_help}\n💡 구체적 예시: {min_val + (max_val - min_val) // 2}와 같은 숫자를 입력해보세요." - else: - return f"{base_help}\n💡 자세한 설명: {question['question']}에 대한 답변은 {min_val}에서 {max_val} 사이의 숫자여야 합니다." - - elif validation_type == "choice": - options = question["validation"]["options"] - - if retry_count == 1: - return f"{base_help}\n💡 힌트: 다음 중에서 선택해주세요: {', '.join(options)}" - elif retry_count == 2: - return f"{base_help}\n💡 구체적 예시: '{options[0]}' 또는 '{options[1]}' 중 하나를 입력해보세요." - else: - return f"{base_help}\n💡 자세한 설명: 정확히 '{' 또는 '.join(options)}' 중 하나를 입력해야 합니다." - - # 일반적인 경우 - if retry_count == 1: - return f"{base_help}\n💡 힌트: 질문을 다시 한 번 읽어보세요." - elif retry_count == 2: - return f"{base_help}\n💡 구체적 예시: {base_help}에 나와있는 형식으로 답변해보세요." - else: - return f"{base_help}\n💡 자세한 설명: {question['question']}에 대한 답변을 정확히 입력해주세요." - - def _handle_error(self, state: ConversationState) -> ConversationState: - """에러 처리 및 재질문 (도움말 포함)""" - current_id = state["current_question_id"] - question = next((q for q in state["questions"] if q["id"] == current_id), None) - - # 에러 분석 및 도움말 생성 - help_message = self._generate_help_message( - question, state.get("user_answers", {}).get(current_id, "") - ) - - # 질문에 도움말 추가 - enhanced_question = question.copy() - enhanced_question["help_message"] = help_message - enhanced_question["retry_count"] = state.get("retry_count", {}).get(current_id, 0) + 1 - - # 재시도 횟수 업데이트 - if "retry_count" not in state: - state["retry_count"] = {} - state["retry_count"][current_id] = enhanced_question["retry_count"] - - # 에러 메시지와 함께 같은 질문 다시 제시 - state["next_question"] = enhanced_question - state["current_status"] = "retry" - - # 대화 히스토리에 에러 기록 - state["conversation_history"].append( - { - "type": "error", - "question_id": current_id, - "error": state["error_message"], - "help_message": help_message, - "retry_count": enhanced_question["retry_count"], - "timestamp": time.time(), - } - ) - - return state - - def _save_answer(self, state: ConversationState) -> ConversationState: - """유효한 답변 저장""" - current_id = state["current_question_id"] - validation_result = state["validation_results"][current_id] - - # 답변 저장 - state["user_answers"][current_id] = validation_result["normalized_value"] - - # 대화 히스토리에 성공 기록 - state["conversation_history"].append( - { - "type": "success", - "question_id": current_id, - "answer": validation_result["normalized_value"], - "timestamp": time.time(), - } - ) - - return state - - def _check_completion(self, state: ConversationState) -> ConversationState: - """완료 여부 확인""" - current_id = state["current_question_id"] - - if current_id >= len(state["questions"]): - state["is_completed"] = True - state["current_status"] = "completed" - return state - - # 다음 질문으로 이동 - state["current_question_id"] = current_id + 1 - state["current_status"] = "continue" - - return state - - def _route_after_validation(self, state: ConversationState) -> str: - """검증 후 라우팅""" - if state["current_status"] == "completed": - return "completed" - elif state["current_status"] == "valid": - return "valid" - else: - return "invalid" - - def _route_after_save(self, state: ConversationState) -> str: - """저장 후 라우팅""" - if state["is_completed"]: - return "completed" - else: - return "continue" - - def start_conversation(self, config: dict[str, Any] = None) -> ConversationState: - """대화 시작""" - initial_state = ConversationState( - current_question_id=1, - questions=self.questions, - user_answers={}, - validation_results={}, - conversation_history=[], - current_status="starting", - error_message="", - next_question={}, - is_completed=False, - retry_count={}, - ) - - if config: - initial_state.update(config) - - return initial_state - - def submit_answer(self, state: ConversationState, answer: str) -> ConversationState: - """답변 제출""" - current_id = state["current_question_id"] - state["user_answers"][current_id] = answer - - # 그래프 실행 - result = self.graph.invoke(state) - return result - - def get_current_status(self, state: ConversationState) -> dict[str, Any]: - """현재 상태 반환""" - return { - "current_question_id": state["current_question_id"], - "total_questions": len(state["questions"]), - "progress": f"{state['current_question_id']}/{len(state['questions'])}", - "current_status": state["current_status"], - "next_question": state.get("next_question", {}), - "error_message": state.get("error_message", ""), - "is_completed": state["is_completed"], - "answers": state["user_answers"], - } diff --git a/chatbot/api/chat_agent_api.py b/chatbot/api/chat_agent_api.py new file mode 100644 index 0000000..92f8041 --- /dev/null +++ b/chatbot/api/chat_agent_api.py @@ -0,0 +1,143 @@ +""" +Chat Agent API endpoints +ChatAgent를 사용하는 API 엔드포인트 +""" + +import uuid +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from chatbot.agents.chat_agent import ChatAgent, ChatAgentState + +router = APIRouter(prefix="/chat-agent", tags=["chat-agent"]) + +# 세션 저장소 (실제 구현에서는 Redis나 데이터베이스 사용) +chat_sessions: Dict[str, ChatAgentState] = {} +chat_agent = ChatAgent() + + +class StartChatRequest(BaseModel): + questions: list[dict[str, Any]] + + +class SubmitAnswerRequest(BaseModel): + session_id: str + answer: str + + +class ChatResponse(BaseModel): + session_id: str + status: str + current_question: Optional[dict[str, Any]] = None + chatbot_message: Optional[str] = None + error_message: Optional[str] = None + progress: Optional[str] = None + is_completed: bool = False + answers: Optional[dict[str, Any]] = None + # 추가 가이드 관련 필드 + additional_guide: Optional[str] = None + guide_type: Optional[str] = None # "hint", "explanation", "example", "correction" + requires_action: Optional[str] = None # "retry", "clarify", "continue" + + +@router.post("/start", response_model=ChatResponse) +async def start_chat(request: StartChatRequest): + """채팅 시작""" + try: + # 세션 ID 생성 + session_id = str(uuid.uuid4()) + + # ChatAgent로 대화 시작 + initial_state = chat_agent.start_conversation(request.questions) + + # 세션 저장 + chat_sessions[session_id] = initial_state + + # 첫 번째 질문 가져오기 + current_status = chat_agent.get_current_status(initial_state) + + return ChatResponse( + session_id=session_id, + status="started", + current_question=current_status.get("next_question"), + progress=current_status.get("progress"), + is_completed=False, + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"채팅 시작 실패: {str(e)}") + + +@router.post("/answer", response_model=ChatResponse) +async def submit_answer(request: SubmitAnswerRequest): + """답변 제출""" + try: + # 세션 확인 + if request.session_id not in chat_sessions: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") + + current_state = chat_sessions[request.session_id] + + # ChatAgent로 답변 제출 및 LangGraph 실행 + updated_state = chat_agent.submit_answer(current_state, request.answer) + + # 세션 업데이트 + chat_sessions[request.session_id] = updated_state + + # 현재 상태 반환 + current_status = chat_agent.get_current_status(updated_state) + + return ChatResponse( + session_id=request.session_id, + status=current_status["current_status"], + current_question=current_status.get("next_question"), + chatbot_message=current_status.get("chatbot_message"), + error_message=current_status.get("error_message"), + progress=current_status.get("progress"), + is_completed=current_status["is_completed"], + answers=current_status.get("answers"), + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"답변 처리 실패: {str(e)}") + + +@router.get("/status/{session_id}", response_model=ChatResponse) +async def get_chat_status(session_id: str): + """채팅 상태 확인""" + try: + if session_id not in chat_sessions: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") + + current_state = chat_sessions[session_id] + current_status = chat_agent.get_current_status(current_state) + + return ChatResponse( + session_id=session_id, + status=current_status["current_status"], + current_question=current_status.get("next_question"), + chatbot_message=current_status.get("chatbot_message"), + error_message=current_status.get("error_message"), + progress=current_status.get("progress"), + is_completed=current_status["is_completed"], + answers=current_status.get("answers"), + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"상태 확인 실패: {str(e)}") + + +@router.delete("/session/{session_id}") +async def delete_chat_session(session_id: str): + """채팅 세션 삭제""" + try: + if session_id in chat_sessions: + del chat_sessions[session_id] + return {"message": "세션이 삭제되었습니다"} + else: + raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") + + except Exception as e: + raise HTTPException(status_code=500, detail=f"세션 삭제 실패: {str(e)}") diff --git a/chatbot/schemas/chatbot.py b/chatbot/schemas/chatbot.py deleted file mode 100644 index 6148b97..0000000 --- a/chatbot/schemas/chatbot.py +++ /dev/null @@ -1,31 +0,0 @@ -from pydantic import BaseModel, Field - - -class ChatRequest(BaseModel): - question: str = Field(..., description="Question") - answer: str = Field(..., description="Answer") - - class Config: - json_schema_extra = { - "example": { - "question": "1. 전체적인 골격의 인상은 어떠한가요?", - "answer": "두께감이 있고, 육감적입니다.", - } - } - - -class ChatResponse(BaseModel): - is_success: bool = Field(..., description="Success status") - selected: str = Field(..., description="Selected option") - message: str = Field(..., description="Response message") - next_question: str = Field(..., description="Next question to ask") - - class Config: - schema_extra = { - "example": { - "is_success": "스트레이트", - "selected": "어깨와 엉덩이 폭이 비슷한...", - "message": "...", - "next_question": "...", - } - } From 5990c66a63c2542898b3fcf3498cace46ba7f654 Mon Sep 17 00:00:00 2001 From: "jinhee.lee" Date: Sun, 24 Aug 2025 22:33:00 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=A8=20Enhance=20ChatAgent=20functiona?= =?UTF-8?q?lity=20with=20structured=20conversation=20management=20and=20ex?= =?UTF-8?q?ternal=20state=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 316 ++++++++++++++++++++++++++++-- chatbot/agents/chat_agent.py | 329 ++++++++++++++++++++++++++------ chatbot/utils/config_handler.py | 8 +- 3 files changed, 573 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 3ec5963..c2c5391 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,304 @@ -# 🌐 YourMode FastAPI +# Your Mode Fast API - ChatAgent -OpenAI Assistants API와 FastAPI를 활용한 YourMode 백엔드 서버입니다. -체형 분석 및 패션 스타일링 기능과 개인화 콘텐츠 추천 기능을 지원합니다. +## 개요 ---- +ChatAgent는 LangGraph를 사용하여 구조화된 대화를 관리하는 챗봇 시스템입니다. 질문-답변-검증-응답/재질문의 완전한 사이클을 UUID 기반으로 관리합니다. -## 📁 프로젝트 구조 +## 아키텍처 -```bash -your-mode-fast-api/ -├── backend/ # 백엔드 서버 관련 -│ ├── main.py # FastAPI 메인 애플리케이션 -│ └── __init__.py -├── chatbot/ # 챗봇 관련 기능 -│ ├── assistant.py # 챗봇 API 라우터 -│ ├── schemas.py # 통합 스키마 (채팅, 콘텐츠, 진단) -│ └── __init__.py -├── .env-example # 환경변수 템플릿 -├── pyproject.toml # 프로젝트 설정 및 의존성 -└── README.md +### 핵심 컴포넌트 + +- **ChatAgentState**: 대화 상태를 담는 Pydantic 모델 +- **ChatAgent**: LangGraph 기반의 대화 관리 클래스 +- **외부 상태 관리**: `external_answers`, `question_callbacks`, `conversation_states` +- **LangGraph**: 상태 기반 워크플로우 관리 +- **UUID 기반 식별**: 각 대화마다 고유한 `thread_id` 할당 + +## LangGraph 워크플로우 구조 + +``` + ┌─────────────────┐ + │ START │ + └─────────┬───────┘ + │ + ▼ + ┌─────────────────┐ + │ ask_question │ + │ (질문 제시) │ + │ │ + │ • 질문을 state에 저장 + │ • current_status = "question_asked" + │ • 외부로 질문 전송 (콜백) + │ • 대화 내역에 질문 기록 + └─────────┬───────┘ + │ + ▼ + ┌─────────────────┐ + │ wait_for_answer │ + │ (답변 대기/수집) │ + │ │ + │ • 외부 저장소에서 답변 확인 + │ • 답변이 있으면: answer_received + │ • 답변이 없으면: waiting_answer + │ • 대화 내역에 답변 기록 + └─────────┬───────┘ + │ + ▼ + ┌─────────────────┐ + │ _route_after_wait│ ← 답변 대기 상태 확인 + │ (답변 대기 상태 확인)│ + └─────────┬───────┘ + │ + ┌─────────┴─────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ "waiting" │ │"answer_received"│ + │ (계속 대기) │ │ (답변 수신) │ + │ │ │ │ + └─────┬───────────┘ └─────────┬───────┘ + │ │ + │ ▼ + │ ┌─────────────────┐ + │ │validate_answer │ + │ │ (답변 검증) │ + │ │ │ + │ │ • answer_received 상태일 때만 검증 + │ │ • valid/invalid 판정 + │ └─────────┬───────┘ + │ │ + │ ▼ + │ ┌─────────────────┐ + │ │_route_after_val │ + │ │ (검증 후 라우팅) │ + │ └─────────┬───────┘ + │ │ + │ ┌─────────┴─────────┐ + │ │ │ + │ ▼ ▼ + │ ┌─────────────────┐ ┌─────────────────┐ + │ │ "valid" │ │ "invalid" │ + │ │ (유효한 답변) │ │ (유효하지 않은 답변)│ + │ │ │ │ │ + │ └─────┬───────────┘ └─────────┬───────┘ + │ │ │ + │ ▼ ▼ + │ ┌─────────────────┐ ┌─────────────────┐ + │ │generate_chatbot │ │handle_invalid │ + │ │_response │ │_answer │ + │ │(챗봇 응답 생성) │ │(에러 처리/가이드)│ + │ │ │ │ │ + │ │ • 챗봇 응답 생성 │ │ • 재시도 횟수 증가 + │ │ • 대화 내역에 기록│ │ • 에러 메시지 기록 + │ │ │ │ • max_retries 확인 + │ └─────────┬───────┘ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ ask_question │ + │ │ │ (재질문) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ wait_for_answer │ + │ │ │ (답변 대기) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ _route_after_wait│ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ "waiting" │ + │ │ │ (계속 대기) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ wait_for_answer │ + │ │ │ (무한 루프 방지) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ _route_after_wait│ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ "timeout" │ + │ │ │ (타임아웃) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │handle_invalid │ + │ │ │_answer │ + │ │ │(타임아웃 처리) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ ask_question │ + │ │ │ (다음 질문) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ wait_for_answer │ + │ │ │ (새 질문 대기) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ _route_after_wait│ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │"answer_received"│ + │ │ │ (답변 수신) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │validate_answer │ + │ │ │ (답변 검증) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │_route_after_val │ + │ │ │ (검증 후 라우팅) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ "valid" │ + │ │ │ (유효한 답변) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │generate_chatbot │ + │ │ │_response │ + │ │ │(챗봇 응답 생성) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ save_answer │ + │ │ │ (답변 저장) │ + │ │ │ │ + │ │ │ • 대화 내역에 성공 기록 + │ │ │ • 다음 단계로 진행 + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │check_completion │ + │ │ │ (완료 확인) │ + │ │ │ │ + │ │ │ • 모든 질문 완료 확인 + │ │ │ • 다음 질문으로 이동 + │ │ │ • 완료 시 종료 + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │_route_after_save│ + │ │ │ (저장 후 라우팅)│ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────┴─────────┐ + │ │ │ │ + │ │ ▼ ▼ + │ │ ┌─────────────────┐ ┌─────────────────┐ + │ │ │ "continue" │ │ "completed" │ + │ │ │ (계속) │ │ (완료) │ + │ │ │ │ │ │ + │ │ └─────┬───────────┘ └─────────┬───────┘ + │ │ │ │ + │ │ │ │ + │ │ ▼ ▼ + │ │ ┌─────────────────┐ ┌─────────────────┐ + │ │ │ ask_question │ │ END │ + │ │ │ (다음 질문) │ │ (그래프 종료) │ + │ │ └─────────┬───────┘ └─────────────────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ wait_for_answer │ + │ │ │ (새 질문 대기) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ _route_after_wait│ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │"answer_received"│ + │ │ │ (답변 수신) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │validate_answer │ + │ │ │ (답변 검증) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │_route_after_val │ + │ │ │ (검증 후 라우팅)│ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ "valid" │ + │ │ │ (유효한 답변) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │generate_chatbot │ + │ │ │_response │ + │ │ │(챗봇 응답 생성) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ save_answer │ + │ │ │ (답변 저장) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │check_completion │ + │ │ │ (완료 확인) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │_route_after_save│ + │ │ │ (저장 후 라우팅)│ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ "completed" │ + │ │ │ (완료) │ + │ │ └─────────┬───────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ END │ + │ │ │ (그래프 종료) │ + │ │ └─────────────────┘ ``` \ No newline at end of file diff --git a/chatbot/agents/chat_agent.py b/chatbot/agents/chat_agent.py index 8d6fc9f..89baf6e 100644 --- a/chatbot/agents/chat_agent.py +++ b/chatbot/agents/chat_agent.py @@ -1,64 +1,86 @@ +import time +import uuid from typing import Any from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, StateGraph -from openai import OpenAI from pydantic import BaseModel, Field -from chatbot.utils.config_handler import load_config -from chatbot.utils.path import CHATBOT_CONFIG +from chatbot.utils.config_handler import load_json, load_yaml +from chatbot.utils.path import BODY_DIAGNOSIS_QUESTIONS, CHATBOT_CONFIG class ChatAgentState(BaseModel): - current_question_id: int = Field(..., description="Current question identifier") - questions: list[dict[str, Any]] = Field(..., description="List of question objects") - user_answers: dict[int, str] = Field( - default_factory=dict, description="User answers mapped by question ID" + thread_id: str = Field( + description="Unique conversation thread ID", default_factory=lambda: str(uuid.uuid4()) ) - validation_results: dict[int, dict[str, Any]] = Field( - default_factory=dict, description="Validation results for each answer" + current_question_id: int = Field( + description="Current Question ID", + default=1, ) - conversation_history: list[dict[str, Any]] = Field( - default_factory=list, description="Conversation interaction history" + retry_count: int = Field( + description="Retry count for question", + default=0, ) - current_status: str = Field(default="starting", description="Current conversation status") - error_message: str = Field(default="", description="Error message if validation fails") - next_question: dict[str, Any] = Field( - default_factory=dict, description="Next question to be asked" + current_status: str = Field(description="Current conversation status", default="starting") + chatbot_message: str = Field( + description="Chatbot's message", + default="", ) - is_completed: bool = Field(default=False, description="Whether the conversation is completed") - retry_count: dict[int, int] = Field( - default_factory=dict, description="Retry count for each question" + user_answer: str = Field( + description="User's input message", + default="", + ) + chatbot_response: str = Field( + description="Chatbot's response message about the user's answer", + default="", + ) + conversation_history: list[dict[str, Any]] = Field( + description="Conversation interaction history", default_factory=list ) - chatbot_response: str = Field(default="", description="Chatbot response message") - requires_retry: bool = Field(default=False, description="Whether retry is required") + is_completed: bool = Field(default=False, description="Whether the conversation is completed") class ChatAgent: def __init__(self) -> None: - self.config = load_config(CHATBOT_CONFIG) - self.client = OpenAI(api_key=self.config.get("openai_api_key")) - self.assistant_id = self.config["assistants"]["chat_assistant_id"] + self.config = load_yaml(CHATBOT_CONFIG) + self.questions = load_json(BODY_DIAGNOSIS_QUESTIONS) + self.max_retries = 3 # 최대 재시도 횟수 self.graph = self._build_graph() self.memory = MemorySaver() + # 외부 상태 관리 + self.external_answers = {} # 외부에서 받은 답변 저장소 + self.question_callbacks = {} # 질문 전송 콜백 함수들 + self.conversation_states = {} # 대화 상태 저장소 + def _build_graph(self) -> StateGraph: - """대화 그래프 구성""" workflow = StateGraph(ChatAgentState) - # 노드 추가 workflow.add_node("ask_question", self._ask_question) + workflow.add_node("wait_for_answer", self._wait_for_answer) workflow.add_node("validate_answer", self._validate_answer) workflow.add_node("handle_invalid_answer", self._handle_invalid_answer) workflow.add_node("generate_chatbot_response", self._generate_chatbot_response) workflow.add_node("save_answer", self._save_answer) workflow.add_node("check_completion", self._check_completion) - # 엣지 연결 workflow.set_entry_point("ask_question") - workflow.add_edge("ask_question", "validate_answer") + workflow.add_edge("ask_question", "wait_for_answer") + + # wait_for_answer → validate_answer (답변 대기 상태 확인을 위한 라우터) + workflow.add_conditional_edges( + "wait_for_answer", + self._route_after_wait, + { + "waiting": "wait_for_answer", # 답변 대기 상태 유지 + "answer_received": "validate_answer", # 답변 수신됨 + "timeout": "handle_invalid_answer", # 타임아웃 처리 + }, + ) + workflow.add_conditional_edges( "validate_answer", self._route_after_validation, @@ -80,85 +102,268 @@ def _build_graph(self) -> StateGraph: return workflow.compile(checkpointer=self.memory) def _ask_question(self, state: ChatAgentState) -> ChatAgentState: - """질문 제시""" - # TODO: 질문 제시 로직 구현 - # 재시도인 경우 도움말 포함 + current_question = self.questions[state.current_question_id - 1] + + # 질문을 state에 저장 + state.chatbot_message = current_question["question"] + state.current_status = "question_asked" + + # 대화 내역에 질문 추가 + state.conversation_history.append( + { + "type": "question", + "question_id": state.current_question_id, + "question": current_question["question"], + "timestamp": time.time(), + "thread_id": state.thread_id, + } + ) + + # 외부로 질문 전송 (콜백 함수가 있으면) + if state.thread_id in self.question_callbacks: + self.question_callbacks[state.thread_id]( + { + "type": "question", + "question_id": state.current_question_id, + "question": current_question["question"], + "thread_id": state.thread_id, + "timestamp": time.time(), + } + ) + + return state + + def _wait_for_answer(self, state: ChatAgentState) -> ChatAgentState: + """사용자 답변 대기/수집""" + # 외부 저장소에서 답변 확인 + if state.thread_id in self.external_answers: + external_data = self.external_answers[state.thread_id] + + # 현재 질문에 대한 답변인지 확인 + if external_data["question_id"] == state.current_question_id: + # 답변을 state에 저장 + state.user_answer = external_data["answer"] + state.current_status = "answer_received" + + # 대화 내역에 답변 추가 + state.conversation_history.append( + { + "type": "user_answer", + "question_id": state.current_question_id, + "answer": external_data["answer"], + "timestamp": time.time(), + "thread_id": state.thread_id, + } + ) + + # 사용된 답변은 제거 + del self.external_answers[state.thread_id] + + return state + + # 답변이 없으면 대기 상태 유지 + state.current_status = "waiting_answer" return state def _validate_answer(self, state: ChatAgentState) -> ChatAgentState: """답변 유효성 검증""" - # TODO: 답변 유효성 검증 로직 구현 + if state.current_status == "answer_received": + # 간단한 검증 로직 (실제로는 더 복잡한 검증 필요) + if state.user_answer and len(state.user_answer.strip()) > 0: + state.current_status = "valid" + else: + state.current_status = "invalid" return state def _handle_invalid_answer(self, state: ChatAgentState) -> ChatAgentState: """유효하지 않은 답변 처리 및 재질문 가이드""" - # TODO: 재시도 횟수 확인 및 가이드 생성 로직 구현 + # 재시도 횟수 증가 + state.retry_count += 1 + + # 대화 내역에 에러 추가 + state.conversation_history.append( + { + "type": "error", + "question_id": state.current_question_id, + "error_message": "답변이 유효하지 않습니다. 다시 시도해주세요.", + "retry_count": state.retry_count, + "timestamp": time.time(), + "thread_id": state.thread_id, + } + ) + + # 최대 재시도 횟수 확인 + if state.retry_count >= self.max_retries: + state.current_status = "max_retries_exceeded" + else: + state.current_status = "retry" + return state def _generate_chatbot_response(self, state: ChatAgentState) -> ChatAgentState: """유효한 답변에 대한 챗봇 응답 생성""" - # TODO: 챗봇 응답 생성 로직 구현 + # 챗봇 응답 생성 (예시) + response = f"좋습니다! '{state.user_answer}'에 대한 답변을 받았습니다." + state.chatbot_response = response + + # 대화 내역에 챗봇 응답 추가 + state.conversation_history.append( + { + "type": "chatbot_response", + "question_id": state.current_question_id, + "response": response, + "timestamp": time.time(), + "thread_id": state.thread_id, + } + ) + return state def _save_answer(self, state: ChatAgentState) -> ChatAgentState: """유효한 답변 저장""" - # TODO: 답변 저장 로직 구현 + # 대화 내역에 성공 기록 추가 + state.conversation_history.append( + { + "type": "success", + "question_id": state.current_question_id, + "answer": state.user_answer, + "timestamp": time.time(), + "thread_id": state.thread_id, + } + ) + return state def _check_completion(self, state: ChatAgentState) -> ChatAgentState: """완료 여부 확인""" - # TODO: 완료 여부 확인 로직 구현 + if state.current_question_id >= len(self.questions): + state.is_completed = True + state.current_status = "completed" + + # 대화 내역에 완료 기록 추가 + state.conversation_history.append( + { + "type": "completion", + "message": "모든 질문이 완료되었습니다.", + "timestamp": time.time(), + "thread_id": state.thread_id, + } + ) + else: + # 다음 질문으로 이동 + state.current_question_id += 1 + state.retry_count = 0 # 재시도 횟수 초기화 + state.current_status = "continue" + return state def _route_after_validation(self, state: ChatAgentState) -> str: """검증 후 라우팅""" - # TODO: 검증 후 라우팅 로직 구현 - return "valid" + if state.current_status == "completed": + return "completed" + elif state.current_status == "valid": + return "valid" + elif state.current_status == "invalid": + return "invalid" + elif state.current_status == "waiting_answer": + return "waiting" + else: + return "invalid" + + def _route_after_wait(self, state: ChatAgentState) -> str: + """답변 대기 상태 확인 후 라우팅""" + if state.current_status == "answer_received": + return "answer_received" # 답변이 수신됨 → 검증으로 + elif state.current_status == "waiting_answer": + return "waiting" # 아직 답변 대기 중 → 계속 대기 + elif state.current_status == "timeout": + return "timeout" # 타임아웃 → 에러 처리 + else: + return "waiting" # 기본적으로 대기 상태 def _route_after_save(self, state: ChatAgentState) -> str: """저장 후 라우팅""" - # TODO: 저장 후 라우팅 로직 구현 - return "continue" + if state.is_completed: + return "completed" + else: + return "continue" - def start_conversation(self, questions: list[dict[str, Any]]) -> ChatAgentState: + def start_conversation(self) -> ChatAgentState: """대화 시작""" + thread_id = str(uuid.uuid4()) initial_state = ChatAgentState( + thread_id=thread_id, current_question_id=1, - questions=questions, - user_answers={}, - validation_results={}, - conversation_history=[], + retry_count=0, current_status="starting", - error_message="", - next_question={}, - is_completed=False, - retry_count={}, + chatbot_message="", + user_answer="", chatbot_response="", - requires_retry=False, + conversation_history=[], + is_completed=False, ) + # 대화 상태를 저장소에 저장 + self.conversation_states[thread_id] = initial_state + return initial_state - def submit_answer(self, state: ChatAgentState, answer: str) -> ChatAgentState: - """답변 제출""" - current_id = state.current_question_id - state.user_answers[current_id] = answer + def submit_answer(self, thread_id: str, answer: str) -> dict: + """API에서 답변 제출""" + if thread_id not in self.conversation_states: + return {"error": "Conversation not found"} + + # 답변을 외부 저장소에 저장 + current_state = self.conversation_states[thread_id] + self.external_answers[thread_id] = { + "answer": answer, + "timestamp": time.time(), + "question_id": current_state.current_question_id, + } + + return {"status": "answer_received", "thread_id": thread_id} + + def register_question_callback(self, thread_id: str, callback_func): + """질문 전송을 위한 콜백 함수 등록""" + self.question_callbacks[thread_id] = callback_func + + def get_conversation_state(self, thread_id: str) -> ChatAgentState: + """특정 대화의 상태 반환""" + return self.conversation_states.get(thread_id) - # 그래프 실행 + def get_conversation_history(self, thread_id: str) -> list[dict[str, Any]]: + """특정 대화의 내역 반환""" + if thread_id in self.conversation_states: + return self.conversation_states[thread_id].conversation_history + return [] + + def run_conversation(self, thread_id: str) -> ChatAgentState: + """대화 실행""" + if thread_id not in self.conversation_states: + raise ValueError("Conversation not found") + + state = self.conversation_states[thread_id] result = self.graph.invoke(state) + + # 결과를 저장소에 업데이트 + self.conversation_states[thread_id] = result + return result - def get_current_status(self, state: ChatAgentState) -> dict[str, Any]: + def get_current_status(self, thread_id: str) -> dict[str, Any]: """현재 상태 반환""" + if thread_id not in self.conversation_states: + return {"error": "Conversation not found"} + + state = self.conversation_states[thread_id] return { + "thread_id": state.thread_id, "current_question_id": state.current_question_id, - "total_questions": len(state.questions), - "progress": f"{state.current_question_id}/{len(state.questions)}", + "total_questions": len(self.questions), + "progress": f"{state.current_question_id}/{len(self.questions)}", "current_status": state.current_status, - "next_question": state.next_question, - "error_message": state.error_message, - "is_completed": state.is_completed, + "chatbot_message": state.chatbot_message, "chatbot_response": state.chatbot_response, - "requires_retry": state.requires_retry, - "answers": state.user_answers, + "is_completed": state.is_completed, + "retry_count": state.retry_count, } diff --git a/chatbot/utils/config_handler.py b/chatbot/utils/config_handler.py index d1e4565..aca687c 100644 --- a/chatbot/utils/config_handler.py +++ b/chatbot/utils/config_handler.py @@ -1,10 +1,16 @@ +import json from typing import Any import yaml -def load_config(path: str) -> dict[str, Any]: +def load_yaml(path: str) -> dict[str, Any]: config = {} with open(path, encoding="utf-8") as file: config = yaml.load(file, Loader=yaml.FullLoader) return config + + +def load_json(path: str) -> dict[str, Any]: + with open(path, encoding="utf-8") as file: + return json.load(file)