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..19eb58e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,165 +1,77 @@ -### 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/ - +# Config files +chatbot/config/ 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..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 -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 -└── 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/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 f52e29f..0000000 --- a/app/main.py +++ /dev/null @@ -1,24 +0,0 @@ -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware - -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): - 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/chat.py b/app/schemas/chat.py deleted file mode 100644 index ddc9c85..0000000 --- a/app/schemas/chat.py +++ /dev/null @@ -1,32 +0,0 @@ -from pydantic import Field, BaseModel, ConfigDict - - -class ChatRequest(BaseModel): - question: str = Field(..., description="질문"), - answer: str = Field(..., description="응답") - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "question": "1. 전체적인 골격의 인상은 어떠한가요?", - "answer": "두께감이 있고, 육감적입니다.", - } - } - ) - - -class ChatResponse(BaseModel): - isSuccess: bool - selected: str - message: str - nextQuestion: str - - class Config: - schema_extra = { - "example": { - "isSuccess": "스트레이트", - "selected": "어깨와 엉덩이 폭이 비슷한...", - "message": "...", - "nextQuestion": "...", - } - } diff --git a/app/schemas/content.py b/app/schemas/content.py deleted file mode 100644 index 8f3faac..0000000 --- a/app/schemas/content.py +++ /dev/null @@ -1,31 +0,0 @@ -from pydantic import Field, BaseModel, ConfigDict - - -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 da0b0f2..0000000 --- a/app/services/assistant_service.py +++ /dev/null @@ -1,509 +0,0 @@ -import re -import json -import time -import os -from openai import OpenAI -from typing import Any, Dict, Optional - -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().lstrip("```").rstrip("```").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): - 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): - 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): - 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이 있다면 최신 정렬, 없다면 그대로 첫 번째 사용 - try: - 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')}") - - 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}") - - -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"}: - raise RuntimeError( - f"Run ended with status={status.status}, last_error={getattr(status, 'last_error', None)}") - 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}") - - 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/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/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 90% rename from app/api/assistant.py rename to backend/api/assistant.py index e226e99..e6f3e40 100644 --- a/app/api/assistant.py +++ b/backend/api/assistant.py @@ -1,11 +1,16 @@ -from fastapi import APIRouter, HTTPException -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, +) +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse router = APIRouter() @@ -32,7 +37,7 @@ 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, ) @@ -50,6 +55,7 @@ def chat(request: ChatRequest): # gender=request.gender, # ) + @router.post("/body-result") def post_body_result(request: DiagnoseRequest): try: @@ -60,7 +66,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 +75,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 +91,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 +99,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/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d0cf665 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,47 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +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() +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") + +# Include conversation router +app.include_router(conversation_router, prefix="/api") + +handler = Mangum(app, api_gateway_base_path="/prod") diff --git a/backend/schemas/chat.py b/backend/schemas/chat.py new file mode 100644 index 0000000..6148b97 --- /dev/null +++ b/backend/schemas/chat.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/backend/schemas/content.py b/backend/schemas/content.py new file mode 100644 index 0000000..3cf2656 --- /dev/null +++ b/backend/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/backend/schemas/diagnosis.py similarity index 64% rename from app/schemas/diagnosis.py rename to backend/schemas/diagnosis.py index 80cc550..dcb4abe 100644 --- a/app/schemas/diagnosis.py +++ b/backend/schemas/diagnosis.py @@ -1,14 +1,14 @@ -from typing import List -from pydantic import BaseModel, Field, ConfigDict +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": [ "두께감이 있고 육감적이다", @@ -27,25 +27,24 @@ class DiagnoseRequest(BaseModel): "쇄골이 거의 보이지 않는다", "둥근 얼굴이며, 볼이 통통한 편이다", "상체가 발달한 느낌이며 허리가 짧고 탄탄한 인상을 준다", - "팔, 가슴, 배 등 상체 위주로 찐다" + "팔, 가슴, 배 등 상체 위주로 찐다", ], "height": 164.5, "weight": 55.2, - "gender": "여성" + "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 = { @@ -57,6 +56,6 @@ class Config: "recommended_styles": "...", "avoid_styles": "...", "styling_fixes": "...", - "styling_tips": "..." + "styling_tips": "...", } } diff --git a/chatbot/__init__.py b/chatbot/__init__.py new file mode 100644 index 0000000..f2699d8 --- /dev/null +++ b/chatbot/__init__.py @@ -0,0 +1,68 @@ +# 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_agent.py b/chatbot/agents/chat_agent.py new file mode 100644 index 0000000..89baf6e --- /dev/null +++ b/chatbot/agents/chat_agent.py @@ -0,0 +1,369 @@ +import time +import uuid +from typing import Any + +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, StateGraph +from pydantic import BaseModel, Field + +from chatbot.utils.config_handler import load_json, load_yaml +from chatbot.utils.path import BODY_DIAGNOSIS_QUESTIONS, CHATBOT_CONFIG + + +class ChatAgentState(BaseModel): + thread_id: str = Field( + description="Unique conversation thread ID", default_factory=lambda: str(uuid.uuid4()) + ) + current_question_id: int = Field( + description="Current Question ID", + default=1, + ) + retry_count: int = Field( + description="Retry count for question", + default=0, + ) + current_status: str = Field(description="Current conversation status", default="starting") + chatbot_message: str = Field( + description="Chatbot's message", + default="", + ) + 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 + ) + is_completed: bool = Field(default=False, description="Whether the conversation is completed") + + +class ChatAgent: + def __init__(self) -> None: + 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", "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, + { + "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: + 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: + """답변 유효성 검증""" + 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: + """유효하지 않은 답변 처리 및 재질문 가이드""" + # 재시도 횟수 증가 + 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: + """유효한 답변에 대한 챗봇 응답 생성""" + # 챗봇 응답 생성 (예시) + 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: + """유효한 답변 저장""" + # 대화 내역에 성공 기록 추가 + 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: + """완료 여부 확인""" + 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: + """검증 후 라우팅""" + 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: + """저장 후 라우팅""" + if state.is_completed: + return "completed" + else: + return "continue" + + def start_conversation(self) -> ChatAgentState: + """대화 시작""" + thread_id = str(uuid.uuid4()) + initial_state = ChatAgentState( + thread_id=thread_id, + current_question_id=1, + retry_count=0, + current_status="starting", + chatbot_message="", + user_answer="", + chatbot_response="", + conversation_history=[], + is_completed=False, + ) + + # 대화 상태를 저장소에 저장 + self.conversation_states[thread_id] = initial_state + + return initial_state + + 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, 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(self.questions), + "progress": f"{state.current_question_id}/{len(self.questions)}", + "current_status": state.current_status, + "chatbot_message": state.chatbot_message, + "chatbot_response": state.chatbot_response, + "is_completed": state.is_completed, + "retry_count": state.retry_count, + } 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/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/assistant.py b/chatbot/assistant.py new file mode 100644 index 0000000..7fbc57b --- /dev/null +++ b/chatbot/assistant.py @@ -0,0 +1,125 @@ +from fastapi import APIRouter, HTTPException + +from chatbot.agents import BodyDiagnosisAgent, ChatAssistantAgent, StyleContentAgent +from chatbot.schemas import ChatRequest, ChatResponse + +router = APIRouter() + +# 에이전트 인스턴스 생성 +body_diagnosis_agent = BodyDiagnosisAgent() +style_content_agent = StyleContentAgent() +chat_assistant_agent = ChatAssistantAgent() + + +@router.post("/chat", response_model=ChatResponse) +async def chat_with_assistant(request: ChatRequest): + """ + 챗봇 어시스턴트와 대화 + """ + try: + 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 + + +@router.get("/status") +async def get_assistant_status(): + """ + 어시스턴트 상태 확인 + """ + return {"status": "active", "service": "your-mode-chatbot"} diff --git a/chatbot/utils/config_handler.py b/chatbot/utils/config_handler.py new file mode 100644 index 0000000..aca687c --- /dev/null +++ b/chatbot/utils/config_handler.py @@ -0,0 +1,16 @@ +import json +from typing import Any + +import yaml + + +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) 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 new file mode 100644 index 0000000..bd67908 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,94 @@ +[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", + "pyyaml>=6.0", + "langgraph>=0.2.0", + "langchain>=0.2.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 = 100 +fix = true # 자동 수정 활성화 + +[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 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=== 모든 테스트 완료 ===")