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=== 모든 테스트 완료 ===")