commit bbea033cdc670fc90c5b4eed94a041861e04891a
Author: fbdeme <90471819+fbdeme@users.noreply.github.com>
Date: Fri Jun 13 18:51:48 2025 +0900
Initial commit - Q-Table 협상 전략 강화학습 데모 프로젝트
diff --git a/.env b/.env
new file mode 100644
index 0000000..a7e529c
--- /dev/null
+++ b/.env
@@ -0,0 +1,19 @@
+# 개발 환경 설정
+API_HOST=localhost
+API_PORT=8000
+FRONTEND_HOST=localhost
+FRONTEND_PORT=8501
+
+# 강화학습 하이퍼파라미터
+DEFAULT_LEARNING_RATE=0.1
+DEFAULT_DISCOUNT_FACTOR=0.9
+DEFAULT_EPSILON=0.1
+
+# 협상 환경 설정
+DEFAULT_ANCHOR_PRICE=100
+MAX_EPISODES=1000
+MAX_STEPS_PER_EPISODE=10
+
+# 로깅 설정
+LOG_LEVEL=INFO
+LOG_FILE=app.log
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..72bda0c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,36 @@
+# Python 3.9 기반 이미지
+FROM python:3.9-slim
+
+# 작업 디렉토리 설정
+WORKDIR /app
+
+# 시스템 의존성 설치
+RUN apt-get update && apt-get install -y \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+# Poetry 설치
+RUN pip install poetry
+
+# Poetry 설정 (가상환경을 컨테이너 내부에 생성하지 않음)
+RUN poetry config virtualenvs.create false
+
+# 의존성 파일 복사
+COPY pyproject.toml poetry.lock* ./
+
+# 의존성 설치
+RUN poetry install --no-dev
+
+# 애플리케이션 코드 복사
+COPY . .
+
+# 포트 노출
+EXPOSE 8000 8501
+
+# 환경 변수 설정
+ENV PYTHONPATH=/app
+ENV API_HOST=0.0.0.0
+ENV FRONTEND_HOST=0.0.0.0
+
+# 기본 명령어 (API 서버 실행)
+CMD ["python", "run_api.py"]
diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md
new file mode 100644
index 0000000..63d6a44
--- /dev/null
+++ b/GETTING_STARTED.md
@@ -0,0 +1,168 @@
+# 🚀 Q-Table 협상 전략 데모 시작 가이드
+
+## 빠른 시작
+
+### 1. 환경 설정
+
+#### Poetry 사용 (권장)
+```bash
+# Poetry 설치 (미설치시)
+curl -sSL https://install.python-poetry.org | python3 -
+
+# 의존성 설치
+poetry install
+
+# 가상환경 활성화
+poetry shell
+```
+
+#### pip 사용
+```bash
+# 가상환경 생성 (권장)
+python -m venv venv
+source venv/bin/activate # Linux/Mac
+# venv\Scripts\activate # Windows
+
+# 의존성 설치
+pip install -r requirements.txt
+```
+
+### 2. 환경 변수 설정
+```bash
+# .env 파일 생성
+cp .env.example .env
+
+# 필요에 따라 .env 파일 수정
+nano .env
+```
+
+### 3. 시스템 실행
+
+#### 방법 1: 전체 시스템 동시 실행
+```bash
+python run_both.py
+```
+
+#### 방법 2: 개별 실행
+```bash
+# 터미널 1 - API 서버
+python run_api.py
+
+# 터미널 2 - 프론트엔드
+python run_frontend.py
+```
+
+#### 방법 3: Make 사용
+```bash
+make run-both
+```
+
+### 4. 접속
+- **프론트엔드**: http://localhost:8501
+- **API 문서**: http://localhost:8000/docs
+
+## 주요 기능 사용법
+
+### 1. 콜드 스타트 확인
+- 프론트엔드의 "콜드 스타트" 탭에서 초기 Q-Table 상태 확인
+- 모든 Q값이 0인 상태에서 시작
+
+### 2. 데이터 수집
+- "데이터 수집" 탭에서 에피소드 생성
+- 탐험율과 에피소드 수를 조정하여 경험 데이터 수집
+
+### 3. Q-Learning 학습
+- "Q-Learning" 탭에서 수집된 데이터로 Q-Table 업데이트
+- 학습률, 할인율 등 하이퍼파라미터 조정 가능
+
+### 4. FQI+CQL 학습
+- "FQI+CQL" 탭에서 오프라인 학습 수행
+- 보수적 Q-Learning으로 안전한 정책 학습
+
+### 5. 정책 비교
+- "학습된 정책" 탭에서 Q-Learning과 FQI+CQL 정책 비교
+- 실제 협상 시나리오에서 행동 추천 받기
+
+## Docker 사용
+
+### Docker Compose로 실행
+```bash
+# 이미지 빌드
+docker-compose build
+
+# 시스템 실행
+docker-compose up
+
+# 백그라운드 실행
+docker-compose up -d
+
+# 중지
+docker-compose down
+```
+
+## 개발 환경 설정
+
+### 코드 품질 도구
+```bash
+# 코드 포맷팅
+make format
+
+# 린팅
+make lint
+
+# 테스트
+make test
+```
+
+### 개발 서버 실행
+```bash
+# API 서버 (Hot Reload)
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+
+# 프론트엔드 (Hot Reload)
+streamlit run frontend/app.py --server.port 8501
+```
+
+## 문제 해결
+
+### 포트 충돌
+- 8000번 포트나 8501번 포트가 사용 중인 경우 .env 파일에서 포트 변경
+
+### 의존성 오류
+```bash
+# 의존성 재설치
+pip install --force-reinstall -r requirements.txt
+
+# 또는 Poetry 사용시
+poetry install --no-cache
+```
+
+### API 연결 오류
+- API 서버가 실행 중인지 확인
+- http://localhost:8000/api/v1/health 에서 상태 확인
+
+## 추가 정보
+
+### 프로젝트 구조
+```
+qtable_negotiation_demo/
+├── app/ # FastAPI 백엔드
+│ ├── api/ # API 엔드포인트
+│ ├── core/ # 설정 및 유틸리티
+│ ├── models/ # 데이터 모델
+│ └── services/ # 비즈니스 로직
+├── frontend/ # Streamlit 프론트엔드
+├── tests/ # 테스트 코드
+├── run_*.py # 실행 스크립트
+└── docker-compose.yml # Docker 설정
+```
+
+### 핵심 개념
+- **보상함수**: R(s,a) = W × (A/P) + (1-W) × End
+- **상태공간**: (카드, 시나리오, 가격구간) 조합
+- **Q-Learning**: 온라인 강화학습
+- **FQI+CQL**: 오프라인 보수적 강화학습
+
+### 지원 및 문의
+- 이슈 발생시 API 문서 참조: http://localhost:8000/docs
+- 로그 파일 확인: app.log
diff --git a/KC-R(s,a)설계보고서-120625-051540.pdf b/KC-R(s,a)설계보고서-120625-051540.pdf
new file mode 100644
index 0000000..86c55aa
Binary files /dev/null and b/KC-R(s,a)설계보고서-120625-051540.pdf differ
diff --git a/KC-협상전략생성Q-Table-120625-051456.pdf b/KC-협상전략생성Q-Table-120625-051456.pdf
new file mode 100644
index 0000000..96f9c50
Binary files /dev/null and b/KC-협상전략생성Q-Table-120625-051456.pdf differ
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ed1e2f7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,89 @@
+# Q-Table 협상 전략 데모 Makefile
+
+.PHONY: help install run-api run-frontend run-both test clean docker-build docker-run
+
+help: ## 도움말 표시
+ @echo "사용 가능한 명령어:"
+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+install: ## 의존성 설치
+ @echo "🔧 의존성을 설치합니다..."
+ @if command -v poetry >/dev/null 2>&1; then \
+ poetry install; \
+ else \
+ pip install -r requirements.txt; \
+ fi
+
+run-api: ## API 서버 실행
+ @echo "🚀 API 서버를 시작합니다..."
+ python run_api.py
+
+run-frontend: ## 프론트엔드 실행
+ @echo "🎯 프론트엔드를 시작합니다..."
+ python run_frontend.py
+
+run-both: ## API와 프론트엔드 동시 실행
+ @echo "🚀 전체 시스템을 시작합니다..."
+ python run_both.py
+
+test: ## 테스트 실행
+ @echo "🧪 테스트를 실행합니다..."
+ @if command -v poetry >/dev/null 2>&1; then \
+ poetry run pytest tests/ -v; \
+ else \
+ pytest tests/ -v; \
+ fi
+
+clean: ## 캐시 및 임시 파일 정리
+ @echo "🧹 정리 작업을 수행합니다..."
+ find . -type f -name "*.pyc" -delete
+ find . -type d -name "__pycache__" -delete
+ find . -type d -name ".pytest_cache" -exec rm -rf {} +
+ find . -type d -name "*.egg-info" -exec rm -rf {} +
+
+docker-build: ## Docker 이미지 빌드
+ @echo "🐳 Docker 이미지를 빌드합니다..."
+ docker-compose build
+
+docker-run: ## Docker로 실행
+ @echo "🐳 Docker로 시스템을 시작합니다..."
+ docker-compose up
+
+docker-stop: ## Docker 컨테이너 중지
+ @echo "🛑 Docker 컨테이너를 중지합니다..."
+ docker-compose down
+
+format: ## 코드 포맷팅
+ @echo "✨ 코드를 포맷팅합니다..."
+ @if command -v poetry >/dev/null 2>&1; then \
+ poetry run black app/ frontend/ tests/; \
+ else \
+ black app/ frontend/ tests/; \
+ fi
+
+lint: ## 코드 린팅
+ @echo "🔍 코드를 검사합니다..."
+ @if command -v poetry >/dev/null 2>&1; then \
+ poetry run flake8 app/ frontend/ tests/; \
+ else \
+ flake8 app/ frontend/ tests/; \
+ fi
+
+setup-dev: ## 개발 환경 설정
+ @echo "🔧 개발 환경을 설정합니다..."
+ cp .env.example .env
+ @echo "✅ .env 파일이 생성되었습니다. 필요에 따라 수정해주세요."
+
+demo: ## 데모 데이터 생성
+ @echo "🎲 데모 데이터를 생성합니다..."
+ @echo "API 서버가 실행 중이어야 합니다."
+ curl -X POST "http://localhost:8000/api/v1/episodes/generate" \
+ -H "Content-Type: application/json" \
+ -d '{"num_episodes": 10, "max_steps": 8, "anchor_price": 100, "exploration_rate": 0.4}'
+
+status: ## 시스템 상태 확인
+ @echo "📊 시스템 상태를 확인합니다..."
+ @echo "API 서버 상태:"
+ @curl -s http://localhost:8000/api/v1/health || echo "API 서버가 실행되지 않습니다."
+ @echo "\n프론트엔드 상태:"
+ @curl -s http://localhost:8501 >/dev/null && echo "프론트엔드가 실행 중입니다." || echo "프론트엔드가 실행되지 않습니다."
diff --git a/README(2).md b/README(2).md
new file mode 100644
index 0000000..e4ff557
--- /dev/null
+++ b/README(2).md
@@ -0,0 +1,134 @@
+# Q-Table 협상 전략 강화학습 데모
+
+기업 간 협상 시뮬레이션을 위한 강화학습 에이전트의 Q-Table 기반 전략 학습 데모입니다.
+
+## 주요 기능
+
+- ✅ Q-Table 기반 협상 전략 시뮬레이션
+- ✅ 보상함수 R(s,a) = W × (A/P) + (1-W) × End 정확한 구현
+- ✅ FQI (Fitted Q-Iteration) + CQL (Conservative Q-Learning) 시뮬레이션
+- ✅ 실시간 경험 데이터 수집 및 축적
+- ✅ Q-Table 학습 과정 시각화
+- ✅ 콜드 스타트 문제부터 학습된 정책까지 전체 여정 관찰
+
+## 시스템 구성
+
+### 백엔드 (FastAPI)
+- RESTful API 서버
+- Q-Table 학습 엔진
+- 협상 환경 시뮬레이터
+- 경험 데이터 관리
+
+### 프론트엔드 (Streamlit)
+- 대화형 웹 인터페이스
+- 실시간 시각화
+- 단계별 학습 과정 관찰
+
+## 설치 및 실행
+
+### 1. 의존성 설치
+```bash
+# Poetry를 사용한 설치
+poetry install
+
+# 또는 pip 사용
+pip install -r requirements.txt
+```
+
+### 2. 환경 설정
+```bash
+# .env 파일 복사 및 수정
+cp .env.example .env
+```
+
+### 3. 백엔드 서버 실행
+```bash
+# Poetry 사용
+poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+
+# 또는 직접 실행
+uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+```
+
+### 4. 프론트엔드 실행
+```bash
+# Poetry 사용
+poetry run streamlit run frontend/app.py --server.port 8501
+
+# 또는 직접 실행
+streamlit run frontend/app.py --server.port 8501
+```
+
+### 5. 데모 접속
+- 프론트엔드: http://localhost:8501
+- API 문서: http://localhost:8000/docs
+
+## 핵심 개념
+
+### 상태 공간 (State Space)
+상태는 (현재 카드, 시나리오, 가격 구간)의 조합으로 구성됩니다:
+- **카드**: C1, C2, C3, C4 (협상 전략 카드)
+- **시나리오**: A, B, C, D (협상 상황별 가중치)
+- **가격 구간**: PZ1, PZ2, PZ3 (목표가 대비 제안가 구간)
+
+### 보상함수 (Reward Function)
+```
+R(s,a) = W × (A/P) + (1-W) × End
+```
+- **A**: 앵커링 목표가 (구매자 목표 가격)
+- **P**: 상대방 제안가 (판매자 제시 가격)
+- **End**: 협상 종료 여부 (0 또는 1)
+- **W**: 가중치 = (S_n + PZ_n) / 2
+
+### 가중치 시스템
+- **시나리오별 가중치**: S_1=A, S_2=D, S_3=C, S_4=B
+- **가격 구간별 가중치**: 목표가와 제안가 차이에 따른 영향도
+
+## 학습 알고리즘
+
+### 1. Q-Learning
+전통적인 온라인 강화학습 알고리즘으로 실시간 Q-Table 업데이트
+
+### 2. FQI (Fitted Q-Iteration)
+배치 기반 오프라인 강화학습으로 수집된 경험 데이터 활용
+
+### 3. CQL (Conservative Q-Learning)
+분포 이동 문제를 해결하기 위한 보수적 Q-Learning
+
+## 데모 흐름
+
+1. **콜드 스타트**: 초기 Q-Table 상태 (모든 값 0)
+2. **데이터 수집**: 무작위 탐험을 통한 경험 데이터 축적
+3. **Q-Learning**: 수집된 데이터로 Q-Table 실시간 업데이트
+4. **FQI+CQL**: 오프라인 배치 학습 시뮬레이션
+5. **학습된 정책**: 최적화된 협상 전략 활용
+
+## 기술 스택
+
+- **Backend**: FastAPI, Uvicorn
+- **Frontend**: Streamlit
+- **Data**: Pandas, NumPy
+- **Visualization**: Matplotlib, Seaborn, Plotly
+- **Environment**: Poetry, python-dotenv
+- **Testing**: Pytest
+
+## 프로젝트 구조
+
+```
+qtable_negotiation_demo/
+├── app/ # FastAPI 백엔드
+│ ├── api/ # API 라우터
+│ ├── core/ # 핵심 설정
+│ ├── models/ # 데이터 모델
+│ └── services/ # 비즈니스 로직
+├── frontend/ # Streamlit 프론트엔드
+├── tests/ # 테스트 코드
+├── data/ # 데이터 파일
+├── pyproject.toml # Poetry 설정
+├── .env # 환경 변수
+└── README.md # 이 파일
+```
+
+## 라이선스
+
+이 프로젝트는 교육 및 데모 목적으로 제작되었습니다.
diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md
new file mode 100644
index 0000000..d2e405e
--- /dev/null
+++ b/REVIEW_REPORT.md
@@ -0,0 +1,161 @@
+# Q-Table 데모 프로젝트 검토 및 수정 보고서
+
+## 검토 개요
+Q-Table 기반 협상 전략 강화학습 데모 프로젝트를 검토하고 실행 가능한 상태로 수정하였습니다.
+
+## 발견된 주요 문제점
+
+### 1. 프로젝트 구조 불일치
+**문제**: README.md에서 언급된 디렉토리 구조와 실제 파일 구조가 일치하지 않음
+- 모든 파일이 루트 디렉토리에 평면적으로 배치됨
+- app/, frontend/ 등의 디렉토리가 존재하지 않음
+
+**해결**: 올바른 디렉토리 구조로 재구성
+```
+qtable_project/
+├── app/
+│ ├── __init__.py
+│ ├── main.py
+│ ├── models/
+│ │ ├── __init__.py
+│ │ └── schemas.py
+│ ├── services/
+│ │ ├── __init__.py
+│ │ ├── demo_service.py
+│ │ ├── negotiation_env.py
+│ │ ├── qtable_learner.py
+│ │ └── fqi_cql.py
+│ ├── api/
+│ │ ├── __init__.py
+│ │ └── endpoints.py
+│ └── core/
+│ ├── __init__.py
+│ └── config.py
+├── frontend/
+│ ├── __init__.py
+│ └── app.py
+├── run_api.py
+├── run_frontend.py
+├── run_both.py
+├── test_basic.py
+├── requirements.txt
+├── pyproject.toml
+├── .env
+└── README.md
+```
+
+### 2. Import 경로 오류
+**문제**: 모든 파일에서 `from app.models.schemas import` 등의 import 경로가 실제 파일 구조와 맞지 않음
+
+**해결**: 모든 파일의 import 경로를 올바르게 수정
+- `app/services/demo_service.py`: import 경로 수정
+- `app/services/qtable_learner.py`: import 경로 수정
+- `app/services/negotiation_env.py`: import 경로 수정
+- `app/api/endpoints.py`: import 경로 수정
+- `run_api.py`, `run_frontend.py`, `run_both.py`: import 경로 수정
+
+### 3. 파일명 중복 문제
+**문제**: `main(1).py`, `__init__(1).py` 등 중복된 파일명 존재
+
+**해결**: 파일명을 올바르게 정리
+- `main(1).py` → `app/main.py`
+- `__init__(1).py` → `app/__init__.py`
+- `__init__(2).py` → `app/models/__init__.py`
+- 기타 __init__ 파일들을 적절한 위치로 이동
+
+### 4. 환경 설정 파일 누락
+**문제**: `.env` 파일이 없어 환경 변수 로드 실패 가능성
+
+**해결**: `env` 파일을 `.env`로 복사하여 환경 변수 설정 완료
+
+## 수정 완료 사항
+
+### ✅ 의존성 설치
+- `requirements.txt`의 모든 패키지 설치 완료
+- FastAPI, Streamlit, pandas, numpy 등 필수 패키지 정상 설치
+
+### ✅ 프로젝트 구조 정리
+- 올바른 디렉토리 구조로 재구성
+- 모든 파일을 적절한 위치로 이동
+
+### ✅ Import 경로 수정
+- 모든 Python 파일의 import 경로 수정 완료
+- 모듈 간 의존성 문제 해결
+
+### ✅ 실행 테스트 성공
+- 모든 모듈 import 테스트 통과
+- FastAPI 서버 시작 테스트 성공
+- 기본 테스트 스크립트 실행 성공
+
+## 실행 방법
+
+### 1. API 서버 실행
+```bash
+cd qtable_project
+python3 run_api.py
+```
+- 주소: http://localhost:8000
+- API 문서: http://localhost:8000/docs
+
+### 2. 프론트엔드 실행
+```bash
+cd qtable_project
+python3 run_frontend.py
+```
+- 주소: http://localhost:8501
+
+### 3. 통합 실행 (API + 프론트엔드)
+```bash
+cd qtable_project
+python3 run_both.py
+```
+
+## 핵심 기능 확인
+
+### ✅ Q-Table 학습 엔진
+- `QTableLearner` 클래스 정상 작동
+- 경험 데이터 수집 및 관리 기능
+
+### ✅ 협상 환경 시뮬레이터
+- `NegotiationEnvironment` 클래스 정상 작동
+- 보상함수 R(s,a) = W × (A/P) + (1-W) × End 구현
+
+### ✅ FQI+CQL 학습
+- `FQICQLLearner` 클래스 정상 작동
+- 오프라인 강화학습 기능
+
+### ✅ FastAPI 백엔드
+- RESTful API 서버 정상 시작
+- 모든 엔드포인트 정상 로드
+
+### ✅ Streamlit 프론트엔드
+- 웹 인터페이스 모듈 정상 로드
+- 실시간 시각화 기능
+
+## 테스트 결과
+
+### Import 테스트
+- ✅ schemas 모듈 import 성공
+- ✅ config 모듈 import 성공
+- ✅ negotiation_env 모듈 import 성공
+- ✅ demo_service 모듈 import 성공
+- ✅ FastAPI app import 성공
+
+### 서버 실행 테스트
+- ✅ API 서버 정상 시작 (10초 테스트)
+- ✅ Streamlit import 성공
+- ✅ 기본 테스트 스크립트 실행 성공
+
+## 결론
+
+**프로젝트가 성공적으로 수정되어 실행 가능한 상태가 되었습니다.**
+
+주요 수정 사항:
+1. 프로젝트 구조를 README에 명시된 대로 정리
+2. 모든 import 경로 오류 수정
+3. 파일명 중복 문제 해결
+4. 환경 설정 파일 정리
+5. 의존성 설치 완료
+
+이제 Q-Table 기반 협상 전략 강화학습 데모를 정상적으로 실행할 수 있습니다.
+
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..d475aa5
Binary files /dev/null and b/app/__pycache__/__init__.cpython-311.pyc differ
diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc
new file mode 100644
index 0000000..0142a70
Binary files /dev/null and b/app/__pycache__/main.cpython-311.pyc differ
diff --git a/app/api/__init__.py b/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/api/__pycache__/__init__.cpython-311.pyc b/app/api/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..42b893a
Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-311.pyc differ
diff --git a/app/api/__pycache__/endpoints.cpython-311.pyc b/app/api/__pycache__/endpoints.cpython-311.pyc
new file mode 100644
index 0000000..b81e5d0
Binary files /dev/null and b/app/api/__pycache__/endpoints.cpython-311.pyc differ
diff --git a/app/api/endpoints.py b/app/api/endpoints.py
new file mode 100644
index 0000000..b769465
--- /dev/null
+++ b/app/api/endpoints.py
@@ -0,0 +1,172 @@
+"""
+FastAPI 엔드포인트 정의
+"""
+from fastapi import APIRouter, HTTPException, BackgroundTasks
+from typing import Dict, Any
+
+from app.models.schemas import (
+ RewardCalculationRequest, RewardCalculationResponse,
+ EpisodeGenerationRequest, LearningUpdateRequest,
+ FQICQLRequest, ActionRecommendationRequest,
+ ActionRecommendationResponse, SystemStatus
+)
+from app.services.demo_service import demo_service
+
+router = APIRouter()
+
+
+@router.get("/health")
+async def health_check():
+ """헬스 체크"""
+ return {"status": "healthy", "service": "Q-Table Negotiation Demo"}
+
+
+@router.post("/reward/calculate", response_model=RewardCalculationResponse)
+async def calculate_reward(request: RewardCalculationRequest):
+ """보상 계산"""
+ try:
+ return demo_service.calculate_reward(request)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/episodes/generate")
+async def generate_episodes(request: EpisodeGenerationRequest):
+ """에피소드 생성"""
+ try:
+ result = demo_service.generate_episodes(request)
+ return {"success": True, "data": result}
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/learning/q-learning")
+async def update_q_learning(request: LearningUpdateRequest):
+ """Q-Learning 업데이트"""
+ try:
+ result = demo_service.update_q_learning(
+ learning_rate=request.learning_rate,
+ discount_factor=request.discount_factor,
+ batch_size=request.batch_size
+ )
+ return {"success": True, "data": result}
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/learning/fqi-cql")
+async def run_fqi_cql(request: FQICQLRequest):
+ """FQI+CQL 학습 실행"""
+ try:
+ result = demo_service.run_fqi_cql(
+ alpha=request.alpha,
+ gamma=request.gamma,
+ batch_size=request.batch_size,
+ num_iterations=request.num_iterations
+ )
+ return {"success": True, "data": result}
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/action/recommend", response_model=ActionRecommendationResponse)
+async def recommend_action(request: ActionRecommendationRequest):
+ """행동 추천"""
+ try:
+ return demo_service.get_action_recommendation(request)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/status", response_model=SystemStatus)
+async def get_system_status():
+ """시스템 상태 조회"""
+ try:
+ return demo_service.get_system_status()
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/qtable")
+async def get_q_table():
+ """Q-Table 데이터 조회"""
+ try:
+ result = demo_service.get_q_table()
+ return {"success": True, "data": result}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fqi-cql")
+async def get_fqi_cql_results():
+ """FQI+CQL 결과 조회"""
+ try:
+ result = demo_service.get_fqi_cql_results()
+ return {"success": True, "data": result}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/experiences")
+async def get_experience_data():
+ """경험 데이터 조회"""
+ try:
+ result = demo_service.get_experience_data()
+ return {"success": True, "data": result}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/compare/{state}")
+async def compare_policies(state: str):
+ """정책 비교"""
+ try:
+ result = demo_service.compare_policies(state)
+ return {"success": True, "data": result}
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/reset")
+async def reset_system():
+ """시스템 초기화"""
+ try:
+ demo_service.reset_all()
+ return {"success": True, "message": "System reset completed"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/states")
+async def get_all_states():
+ """모든 상태 목록 조회"""
+ try:
+ states = demo_service.env.get_all_states()
+ return {"success": True, "data": {"states": states, "count": len(states)}}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/actions")
+async def get_all_actions():
+ """모든 행동 목록 조회"""
+ try:
+ actions = [action.value for action in demo_service.env.get_all_actions()]
+ return {"success": True, "data": {"actions": actions, "count": len(actions)}}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/config")
+async def get_configuration():
+ """환경 설정 조회"""
+ try:
+ config = {
+ "scenario_weights": {k.value: v for k, v in demo_service.env.scenario_weights.items()},
+ "price_zone_weights": {k.value: v for k, v in demo_service.env.price_zone_weights.items()},
+ "card_effects": {k.value: v for k, v in demo_service.env.card_effects.items()},
+ "scenario_difficulty": {k.value: v for k, v in demo_service.env.scenario_difficulty.items()}
+ }
+ return {"success": True, "data": config}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000..1974446
--- /dev/null
+++ b/app/core/__init__.py
@@ -0,0 +1 @@
+"""Q-Table 협상 전략 데모 애플리케이션"""
\ No newline at end of file
diff --git a/app/core/__pycache__/__init__.cpython-311.pyc b/app/core/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..15c52a4
Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-311.pyc differ
diff --git a/app/core/__pycache__/config.cpython-311.pyc b/app/core/__pycache__/config.cpython-311.pyc
new file mode 100644
index 0000000..9388327
Binary files /dev/null and b/app/core/__pycache__/config.cpython-311.pyc differ
diff --git a/app/core/config.py b/app/core/config.py
new file mode 100644
index 0000000..6d2ea7c
--- /dev/null
+++ b/app/core/config.py
@@ -0,0 +1,62 @@
+"""
+애플리케이션 설정 관리
+"""
+import os
+from typing import List
+from pydantic import BaseSettings
+from dotenv import load_dotenv
+
+# .env 파일 로드
+load_dotenv()
+
+
+class Settings(BaseSettings):
+ """애플리케이션 설정"""
+
+ # API 서버 설정
+ api_host: str = "localhost"
+ api_port: int = 8000
+
+ # 프론트엔드 설정
+ frontend_host: str = "localhost"
+ frontend_port: int = 8501
+
+ # 강화학습 하이퍼파라미터
+ default_learning_rate: float = 0.1
+ default_discount_factor: float = 0.9
+ default_epsilon: float = 0.1
+
+ # 협상 환경 설정
+ default_anchor_price: int = 100
+ max_episodes: int = 1000
+ max_steps_per_episode: int = 10
+
+ # 로깅 설정
+ log_level: str = "INFO"
+ log_file: str = "app.log"
+
+ # CORS 설정
+ allowed_origins: List[str] = ["http://localhost:8501", "http://127.0.0.1:8501"]
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ # 환경 변수로부터 값 로드
+ self.api_host = os.getenv("API_HOST", self.api_host)
+ self.api_port = int(os.getenv("API_PORT", str(self.api_port)))
+ self.frontend_host = os.getenv("FRONTEND_HOST", self.frontend_host)
+ self.frontend_port = int(os.getenv("FRONTEND_PORT", str(self.frontend_port)))
+ self.default_learning_rate = float(os.getenv("DEFAULT_LEARNING_RATE", str(self.default_learning_rate)))
+ self.default_discount_factor = float(os.getenv("DEFAULT_DISCOUNT_FACTOR", str(self.default_discount_factor)))
+ self.default_epsilon = float(os.getenv("DEFAULT_EPSILON", str(self.default_epsilon)))
+ self.default_anchor_price = int(os.getenv("DEFAULT_ANCHOR_PRICE", str(self.default_anchor_price)))
+ self.max_episodes = int(os.getenv("MAX_EPISODES", str(self.max_episodes)))
+ self.max_steps_per_episode = int(os.getenv("MAX_STEPS_PER_EPISODE", str(self.max_steps_per_episode)))
+ self.log_level = os.getenv("LOG_LEVEL", self.log_level)
+ self.log_file = os.getenv("LOG_FILE", self.log_file)
+
+ class Config:
+ env_file = ".env"
+
+
+# 전역 설정 인스턴스
+settings = Settings()
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..bd10b9d
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,130 @@
+"""
+FastAPI 메인 애플리케이션
+"""
+import uvicorn
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import HTMLResponse
+
+from app.core.config import settings
+from app.api.endpoints import router
+
+# FastAPI 앱 생성
+app = FastAPI(
+ title="Q-Table 협상 전략 데모 API",
+ description="기업 간 협상 시뮬레이션을 위한 강화학습 Q-Table 데모 API",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc"
+)
+
+# CORS 설정
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.allowed_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# API 라우터 등록
+app.include_router(router, prefix="/api/v1")
+
+
+@app.get("/", response_class=HTMLResponse)
+async def root():
+ """루트 페이지"""
+ html_content = """
+
+
+
+ Q-Table 협상 전략 데모 API
+
+
+
+
+
+
+
+
+
🚀 주요 기능
+
+ - 보상 계산: POST /api/v1/reward/calculate
+ - 에피소드 생성: POST /api/v1/episodes/generate
+ - Q-Learning 업데이트: POST /api/v1/learning/q-learning
+ - FQI+CQL 학습: POST /api/v1/learning/fqi-cql
+ - 행동 추천: POST /api/v1/action/recommend
+ - 시스템 상태: GET /api/v1/status
+
+
+
+
+
📊 데이터 조회
+
+ - Q-Table: GET /api/v1/qtable
+ - 경험 데이터: GET /api/v1/experiences
+ - FQI+CQL 결과: GET /api/v1/fqi-cql
+ - 정책 비교: GET /api/v1/compare/{state}
+
+
+
+
+
🔧 유틸리티
+
+ - 헬스 체크: GET /api/v1/health
+ - 시스템 초기화: POST /api/v1/reset
+ - 설정 조회: GET /api/v1/config
+
+
+
+
+
+
+
📝 보상함수
+
R(s,a) = W × (A/P) + (1-W) × End
+
+ - W: 가중치 = (S_n + PZ_n) / 2
+ - A: 앵커링 목표가
+ - P: 상대방 제안가
+ - End: 협상 종료 여부
+
+
+
+
+ """
+ return html_content
+
+
+def start_api():
+ """API 서버 시작 (Poetry 스크립트용)"""
+ uvicorn.run(
+ "app.main:app",
+ host=settings.api_host,
+ port=settings.api_port,
+ reload=True
+ )
+
+
+if __name__ == "__main__":
+ uvicorn.run(
+ "app.main:app",
+ host=settings.api_host,
+ port=settings.api_port,
+ reload=True
+ )
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/__pycache__/__init__.cpython-311.pyc b/app/models/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..b28b5b8
Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-311.pyc differ
diff --git a/app/models/__pycache__/schemas.cpython-311.pyc b/app/models/__pycache__/schemas.cpython-311.pyc
new file mode 100644
index 0000000..3df36cc
Binary files /dev/null and b/app/models/__pycache__/schemas.cpython-311.pyc differ
diff --git a/app/models/schemas.py b/app/models/schemas.py
new file mode 100644
index 0000000..57b0caa
--- /dev/null
+++ b/app/models/schemas.py
@@ -0,0 +1,127 @@
+"""
+API 요청/응답 데이터 모델
+"""
+from typing import Optional, List, Dict, Any
+from pydantic import BaseModel, Field
+from enum import Enum
+
+
+class CardType(str, Enum):
+ """협상 카드 타입"""
+ C1 = "C1"
+ C2 = "C2"
+ C3 = "C3"
+ C4 = "C4"
+
+
+class ScenarioType(str, Enum):
+ """시나리오 타입"""
+ A = "A" # S_1
+ B = "B" # S_4
+ C = "C" # S_3
+ D = "D" # S_2
+
+
+class PriceZoneType(str, Enum):
+ """가격 구간 타입"""
+ PZ1 = "PZ1" # P < A
+ PZ2 = "PZ2" # A < P < T
+ PZ3 = "PZ3" # T < P
+
+
+class ExperienceData(BaseModel):
+ """경험 데이터 모델"""
+ state: str = Field(..., description="현재 상태")
+ action: CardType = Field(..., description="선택한 행동")
+ reward: float = Field(..., description="받은 보상")
+ next_state: str = Field(..., description="다음 상태")
+ done: bool = Field(..., description="에피소드 종료 여부")
+ timestamp: float = Field(..., description="타임스탬프")
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="추가 메타데이터")
+
+
+class NegotiationState(BaseModel):
+ """협상 상태 모델"""
+ current_card: CardType = Field(..., description="현재 카드")
+ scenario: ScenarioType = Field(..., description="시나리오")
+ price_zone: PriceZoneType = Field(..., description="가격 구간")
+
+ @property
+ def state_id(self) -> str:
+ """상태 ID 생성"""
+ return f"{self.current_card.value}{self.scenario.value}{self.price_zone.value}"
+
+
+class RewardCalculationRequest(BaseModel):
+ """보상 계산 요청 모델"""
+ scenario: ScenarioType = Field(..., description="시나리오")
+ price_zone: PriceZoneType = Field(..., description="가격 구간")
+ anchor_price: float = Field(..., gt=0, description="목표가 (A)")
+ proposed_price: float = Field(..., gt=0, description="제안가 (P)")
+ is_end: bool = Field(..., description="협상 종료 여부")
+
+
+class RewardCalculationResponse(BaseModel):
+ """보상 계산 응답 모델"""
+ reward: float = Field(..., description="계산된 보상")
+ weight: float = Field(..., description="가중치 W")
+ scenario_weight: float = Field(..., description="시나리오 가중치 S_n")
+ price_zone_weight: float = Field(..., description="가격구간 가중치 PZ_n")
+ price_ratio: float = Field(..., description="가격 비율 A/P")
+ formula_breakdown: str = Field(..., description="공식 분해")
+
+
+class QTableState(BaseModel):
+ """Q-Table 상태 모델"""
+ q_table: Dict[str, Dict[str, float]] = Field(..., description="Q-Table 데이터")
+ update_count: int = Field(..., description="업데이트 횟수")
+ learning_rate: float = Field(..., description="학습률")
+ discount_factor: float = Field(..., description="할인율")
+
+
+class EpisodeGenerationRequest(BaseModel):
+ """에피소드 생성 요청 모델"""
+ num_episodes: int = Field(..., ge=1, le=100, description="생성할 에피소드 수")
+ max_steps: int = Field(..., ge=1, le=20, description="에피소드당 최대 스텝")
+ anchor_price: float = Field(..., gt=0, description="목표가")
+ exploration_rate: float = Field(default=0.4, ge=0, le=1, description="탐험율")
+
+
+class LearningUpdateRequest(BaseModel):
+ """학습 업데이트 요청 모델"""
+ learning_rate: float = Field(..., ge=0.001, le=1.0, description="학습률")
+ discount_factor: float = Field(..., ge=0.1, le=0.99, description="할인율")
+ batch_size: int = Field(default=32, ge=1, le=1000, description="배치 크기")
+
+
+class FQICQLRequest(BaseModel):
+ """FQI+CQL 학습 요청 모델"""
+ alpha: float = Field(default=1.0, ge=0, description="CQL 보수성 파라미터")
+ gamma: float = Field(default=0.95, ge=0.1, le=0.99, description="할인율")
+ batch_size: int = Field(default=32, ge=1, le=1000, description="배치 크기")
+ num_iterations: int = Field(default=10, ge=1, le=100, description="반복 횟수")
+
+
+class ActionRecommendationRequest(BaseModel):
+ """행동 추천 요청 모델"""
+ current_state: str = Field(..., description="현재 상태")
+ use_epsilon_greedy: bool = Field(default=False, description="엡실론 그리디 사용")
+ epsilon: float = Field(default=0.1, ge=0, le=1, description="엡실론 값")
+
+
+class ActionRecommendationResponse(BaseModel):
+ """행동 추천 응답 모델"""
+ recommended_action: CardType = Field(..., description="추천 행동")
+ q_values: Dict[str, float] = Field(..., description="현재 상태의 Q값들")
+ confidence: float = Field(..., description="추천 신뢰도")
+ exploration: bool = Field(..., description="탐험 행동 여부")
+
+
+class SystemStatus(BaseModel):
+ """시스템 상태 모델"""
+ total_experiences: int = Field(..., description="총 경험 데이터 수")
+ q_table_updates: int = Field(..., description="Q-Table 업데이트 횟수")
+ unique_states: int = Field(..., description="고유 상태 수")
+ average_reward: float = Field(..., description="평균 보상")
+ success_rate: float = Field(..., description="성공률")
+ last_update: Optional[float] = Field(default=None, description="마지막 업데이트 시간")
diff --git a/app/services/__init__.py b/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/services/__pycache__/__init__.cpython-311.pyc b/app/services/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..a7b125d
Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-311.pyc differ
diff --git a/app/services/__pycache__/demo_service.cpython-311.pyc b/app/services/__pycache__/demo_service.cpython-311.pyc
new file mode 100644
index 0000000..9448e1f
Binary files /dev/null and b/app/services/__pycache__/demo_service.cpython-311.pyc differ
diff --git a/app/services/__pycache__/fqi_cql.cpython-311.pyc b/app/services/__pycache__/fqi_cql.cpython-311.pyc
new file mode 100644
index 0000000..cf0ecbd
Binary files /dev/null and b/app/services/__pycache__/fqi_cql.cpython-311.pyc differ
diff --git a/app/services/__pycache__/negotiation_env.cpython-311.pyc b/app/services/__pycache__/negotiation_env.cpython-311.pyc
new file mode 100644
index 0000000..50a8695
Binary files /dev/null and b/app/services/__pycache__/negotiation_env.cpython-311.pyc differ
diff --git a/app/services/__pycache__/qtable_learner.cpython-311.pyc b/app/services/__pycache__/qtable_learner.cpython-311.pyc
new file mode 100644
index 0000000..e11e9f9
Binary files /dev/null and b/app/services/__pycache__/qtable_learner.cpython-311.pyc differ
diff --git a/app/services/demo_service.py b/app/services/demo_service.py
new file mode 100644
index 0000000..73e61a5
--- /dev/null
+++ b/app/services/demo_service.py
@@ -0,0 +1,440 @@
+"""
+Q-Table 협상 전략 데모 메인 서비스
+"""
+import random
+import time
+from typing import Dict, List, Optional, Any, Tuple
+import pandas as pd
+import numpy as np
+
+from app.models.schemas import (
+ CardType, ScenarioType, PriceZoneType,
+ ExperienceData, EpisodeGenerationRequest,
+ RewardCalculationRequest, RewardCalculationResponse,
+ ActionRecommendationRequest, ActionRecommendationResponse,
+ SystemStatus
+)
+from app.services.negotiation_env import NegotiationEnvironment
+from app.services.qtable_learner import QTableLearner, ExperienceBuffer
+from app.services.fqi_cql import FQICQLLearner
+
+
+class DemoService:
+ """Q-Table 협상 전략 데모 메인 서비스"""
+
+ def __init__(self):
+ # 환경 초기화
+ self.env = NegotiationEnvironment()
+
+ # 상태 및 행동 공간
+ self.states = self.env.get_all_states()
+ self.actions = self.env.get_all_actions()
+
+ # 학습 엔진들
+ self.experience_buffer = ExperienceBuffer(max_size=10000)
+ self.q_learner = QTableLearner(
+ states=self.states,
+ actions=self.actions,
+ learning_rate=0.1,
+ discount_factor=0.9,
+ epsilon=0.1
+ )
+ self.fqi_cql_learner = FQICQLLearner(
+ states=self.states,
+ actions=self.actions,
+ alpha=1.0,
+ gamma=0.95
+ )
+
+ # 통계 정보
+ self.episode_count = 0
+ self.start_time = time.time()
+
+ def calculate_reward(self, request: RewardCalculationRequest) -> RewardCalculationResponse:
+ """보상 계산"""
+ reward, weight = self.env.calculate_reward(
+ scenario=request.scenario,
+ price_zone=request.price_zone,
+ anchor_price=request.anchor_price,
+ proposed_price=request.proposed_price,
+ is_end=request.is_end
+ )
+
+ # 시나리오 및 가격구간 가중치
+ scenario_weight = self.env.scenario_weights[request.scenario]
+ price_zone_weight = self.env.price_zone_weights[request.price_zone]
+
+ # 가격 비율
+ price_ratio = request.anchor_price / request.proposed_price if request.proposed_price > 0 else float('inf')
+
+ # 공식 분해
+ formula_breakdown = (
+ f"R(s,a) = W × (A/P) + (1-W) × End\n"
+ f"W = (S_n + PZ_n) / 2 = ({scenario_weight} + {price_zone_weight}) / 2 = {weight:.3f}\n"
+ f"A/P = {request.anchor_price}/{request.proposed_price} = {price_ratio:.3f}\n"
+ f"End = {1 if request.is_end else 0}\n"
+ f"R(s,a) = {weight:.3f} × {price_ratio:.3f} + {1-weight:.3f} × {1 if request.is_end else 0} = {reward:.3f}"
+ )
+
+ return RewardCalculationResponse(
+ reward=reward,
+ weight=weight,
+ scenario_weight=scenario_weight,
+ price_zone_weight=price_zone_weight,
+ price_ratio=price_ratio,
+ formula_breakdown=formula_breakdown
+ )
+
+ def generate_episodes(self, request: EpisodeGenerationRequest) -> Dict[str, Any]:
+ """에피소드 생성"""
+ new_experiences = 0
+ episode_results = []
+
+ for episode in range(request.num_episodes):
+ episode_result = self._generate_single_episode(
+ max_steps=request.max_steps,
+ anchor_price=request.anchor_price,
+ exploration_rate=request.exploration_rate,
+ episode_id=self.episode_count + episode
+ )
+ episode_results.append(episode_result)
+ new_experiences += episode_result['steps']
+
+ self.episode_count += request.num_episodes
+
+ return {
+ "episodes_generated": request.num_episodes,
+ "new_experiences": new_experiences,
+ "episode_results": episode_results,
+ "total_episodes": self.episode_count
+ }
+
+ def _generate_single_episode(
+ self,
+ max_steps: int,
+ anchor_price: float,
+ exploration_rate: float,
+ episode_id: int
+ ) -> Dict[str, Any]:
+ """단일 에피소드 생성"""
+ # 초기 상태
+ current_state = "C0S0P0"
+ scenario = random.choice(list(ScenarioType))
+
+ episode_reward = 0.0
+ steps = 0
+ success = False
+
+ for step in range(max_steps):
+ # 행동 선택 (epsilon-greedy)
+ if random.random() < exploration_rate:
+ action = random.choice(self.actions)
+ is_exploration = True
+ else:
+ action = self.q_learner.get_optimal_action(current_state)
+ is_exploration = False
+
+ # 환경 응답
+ proposed_price = self.env.simulate_opponent_response(
+ current_card=action,
+ scenario=scenario,
+ anchor_price=anchor_price,
+ step=step
+ )
+
+ # 가격 구간 결정
+ price_zone = self.env.get_price_zone(proposed_price, anchor_price)
+
+ # 다음 상태
+ next_state = f"{action.value}{scenario.value}{price_zone.value}"
+
+ # 종료 조건 확인
+ is_done = self.env.is_negotiation_successful(proposed_price, anchor_price) or (step >= max_steps - 1)
+ if self.env.is_negotiation_successful(proposed_price, anchor_price):
+ success = True
+
+ # 보상 계산
+ reward, weight = self.env.calculate_reward(
+ scenario=scenario,
+ price_zone=price_zone,
+ anchor_price=anchor_price,
+ proposed_price=proposed_price,
+ is_end=is_done
+ )
+
+ # 경험 저장
+ metadata = {
+ 'episode': episode_id,
+ 'step': step,
+ 'scenario': scenario.value,
+ 'proposed_price': proposed_price,
+ 'weight': weight,
+ 'is_exploration': is_exploration,
+ 'anchor_price': anchor_price
+ }
+
+ self.experience_buffer.add_experience(
+ state=current_state,
+ action=action,
+ reward=reward,
+ next_state=next_state,
+ done=is_done,
+ metadata=metadata
+ )
+
+ episode_reward += reward
+ steps += 1
+ current_state = next_state
+
+ if is_done:
+ break
+
+ return {
+ 'episode_id': episode_id,
+ 'steps': steps,
+ 'total_reward': episode_reward,
+ 'success': success,
+ 'final_price': proposed_price if 'proposed_price' in locals() else anchor_price,
+ 'scenario': scenario.value
+ }
+
+ def update_q_learning(self, learning_rate: float, discount_factor: float, batch_size: int) -> Dict[str, Any]:
+ """Q-Learning 업데이트"""
+ # 하이퍼파라미터 설정
+ self.q_learner.set_hyperparameters(
+ learning_rate=learning_rate,
+ discount_factor=discount_factor
+ )
+
+ # 경험 데이터 가져오기
+ experiences = self.experience_buffer.get_experiences()
+ if not experiences:
+ return {"message": "No experience data available", "updates": 0}
+
+ # 배치 샘플링
+ if len(experiences) > batch_size:
+ batch = self.experience_buffer.sample_batch(batch_size)
+ else:
+ batch = experiences
+
+ # 배치 업데이트
+ result = self.q_learner.batch_update(batch)
+
+ return {
+ "message": "Q-Learning update completed",
+ "batch_size": len(batch),
+ "updates": result["updates"],
+ "avg_td_error": result["avg_td_error"],
+ "total_updates": self.q_learner.update_count
+ }
+
+ def run_fqi_cql(self, alpha: float, gamma: float, batch_size: int, num_iterations: int) -> Dict[str, Any]:
+ """FQI+CQL 실행"""
+ # 하이퍼파라미터 설정
+ self.fqi_cql_learner.set_hyperparameters(
+ alpha=alpha,
+ gamma=gamma
+ )
+
+ # 경험 데이터 가져오기
+ experiences = self.experience_buffer.get_experiences()
+ if not experiences:
+ return {"message": "No experience data available", "iterations": 0}
+
+ # 배치 샘플링
+ if len(experiences) > batch_size:
+ batch = self.experience_buffer.sample_batch(batch_size)
+ else:
+ batch = experiences
+
+ # FQI+CQL 학습
+ result = self.fqi_cql_learner.train_multiple_iterations(
+ experience_batch=batch,
+ num_iterations=num_iterations
+ )
+
+ # 정책 비교
+ policy_comparison = self.fqi_cql_learner.compare_with_behavior_policy(batch)
+
+ return {
+ "message": "FQI+CQL training completed",
+ "training_result": result,
+ "policy_comparison": policy_comparison,
+ "batch_size": len(batch)
+ }
+
+ def get_action_recommendation(self, request: ActionRecommendationRequest) -> ActionRecommendationResponse:
+ """행동 추천"""
+ # Q값들 가져오기
+ q_values = self.q_learner.get_state_q_values(request.current_state)
+
+ # 행동 선택
+ if request.use_epsilon_greedy:
+ action, is_exploration = self.q_learner.select_action(
+ state=request.current_state,
+ use_epsilon_greedy=True
+ )
+ # 임시로 epsilon 설정
+ original_epsilon = self.q_learner.epsilon
+ self.q_learner.epsilon = request.epsilon
+ action, is_exploration = self.q_learner.select_action(
+ state=request.current_state,
+ use_epsilon_greedy=True
+ )
+ self.q_learner.epsilon = original_epsilon
+ else:
+ action = self.q_learner.get_optimal_action(request.current_state)
+ is_exploration = False
+
+ # 신뢰도 계산 (Q값 분산 기반)
+ if q_values and len(q_values) > 1:
+ q_vals = list(q_values.values())
+ max_q = max(q_vals)
+ q_range = max(q_vals) - min(q_vals)
+ confidence = max_q / (q_range + 1e-8) if q_range > 0 else 1.0
+ confidence = min(confidence, 1.0)
+ else:
+ confidence = 0.0
+
+ return ActionRecommendationResponse(
+ recommended_action=action,
+ q_values=q_values,
+ confidence=confidence,
+ exploration=is_exploration
+ )
+
+ def get_system_status(self) -> SystemStatus:
+ """시스템 상태 조회"""
+ exp_df = self.experience_buffer.get_dataframe()
+
+ if not exp_df.empty:
+ avg_reward = exp_df['reward'].mean()
+ success_count = exp_df['done'].sum()
+ success_rate = success_count / len(exp_df) if len(exp_df) > 0 else 0.0
+ unique_states = exp_df['state'].nunique()
+ else:
+ avg_reward = 0.0
+ success_rate = 0.0
+ unique_states = 0
+
+ return SystemStatus(
+ total_experiences=self.experience_buffer.size(),
+ q_table_updates=self.q_learner.update_count,
+ unique_states=unique_states,
+ average_reward=avg_reward,
+ success_rate=success_rate,
+ last_update=time.time()
+ )
+
+ def get_q_table(self) -> Dict[str, Any]:
+ """Q-Table 데이터 반환"""
+ q_table_df = self.q_learner.get_q_table_copy()
+ stats = self.q_learner.get_learning_statistics()
+
+ return {
+ "q_table": q_table_df.to_dict(),
+ "statistics": stats,
+ "update_count": self.q_learner.update_count,
+ "hyperparameters": {
+ "learning_rate": self.q_learner.learning_rate,
+ "discount_factor": self.q_learner.discount_factor,
+ "epsilon": self.q_learner.epsilon
+ }
+ }
+
+ def get_fqi_cql_results(self) -> Dict[str, Any]:
+ """FQI+CQL 결과 반환"""
+ q_network_df = self.fqi_cql_learner.get_q_network_copy()
+ stats = self.fqi_cql_learner.get_training_statistics()
+
+ return {
+ "q_network": q_network_df.to_dict(),
+ "statistics": stats,
+ "batch_count": self.fqi_cql_learner.batch_count,
+ "hyperparameters": {
+ "alpha": self.fqi_cql_learner.alpha,
+ "gamma": self.fqi_cql_learner.gamma,
+ "learning_rate": self.fqi_cql_learner.learning_rate
+ }
+ }
+
+ def get_experience_data(self) -> Dict[str, Any]:
+ """경험 데이터 반환"""
+ exp_df = self.experience_buffer.get_dataframe()
+
+ if not exp_df.empty:
+ # 기본 통계
+ stats = {
+ "total_count": len(exp_df),
+ "avg_reward": exp_df['reward'].mean(),
+ "reward_std": exp_df['reward'].std(),
+ "success_rate": exp_df['done'].sum() / len(exp_df),
+ "unique_states": exp_df['state'].nunique(),
+ "unique_actions": exp_df['action'].nunique()
+ }
+
+ # 최근 데이터
+ recent_data = exp_df.tail(20).to_dict('records')
+ else:
+ stats = {
+ "total_count": 0,
+ "avg_reward": 0.0,
+ "reward_std": 0.0,
+ "success_rate": 0.0,
+ "unique_states": 0,
+ "unique_actions": 0
+ }
+ recent_data = []
+
+ return {
+ "statistics": stats,
+ "recent_data": recent_data,
+ "buffer_size": self.experience_buffer.size(),
+ "max_size": self.experience_buffer.max_size
+ }
+
+ def reset_all(self):
+ """모든 학습 상태 초기화"""
+ self.experience_buffer.clear()
+ self.q_learner.reset()
+ self.fqi_cql_learner.reset()
+ self.episode_count = 0
+ self.start_time = time.time()
+
+ def compare_policies(self, state: str) -> Dict[str, Any]:
+ """Q-Learning과 FQI+CQL 정책 비교"""
+ # Q-Learning 정책
+ q_learning_action = self.q_learner.get_optimal_action(state)
+ q_learning_values = self.q_learner.get_state_q_values(state)
+
+ # FQI+CQL 정책
+ fqi_cql_action = self.fqi_cql_learner.get_optimal_action(state)
+ fqi_cql_values = self.fqi_cql_learner.get_state_q_values(state)
+
+ # 정책 일치 여부
+ policy_agreement = (q_learning_action == fqi_cql_action)
+
+ # Q값 차이
+ q_value_differences = {}
+ for action_name in q_learning_values:
+ diff = abs(q_learning_values[action_name] - fqi_cql_values.get(action_name, 0.0))
+ q_value_differences[action_name] = diff
+
+ return {
+ "state": state,
+ "q_learning": {
+ "action": q_learning_action.value,
+ "q_values": q_learning_values
+ },
+ "fqi_cql": {
+ "action": fqi_cql_action.value,
+ "q_values": fqi_cql_values
+ },
+ "policy_agreement": policy_agreement,
+ "q_value_differences": q_value_differences,
+ "max_difference": max(q_value_differences.values()) if q_value_differences else 0.0
+ }
+
+
+# 전역 서비스 인스턴스
+demo_service = DemoService()
diff --git a/app/services/fqi_cql.py b/app/services/fqi_cql.py
new file mode 100644
index 0000000..7274181
--- /dev/null
+++ b/app/services/fqi_cql.py
@@ -0,0 +1,313 @@
+"""
+FQI (Fitted Q-Iteration) + CQL (Conservative Q-Learning) 서비스
+"""
+import pandas as pd
+import numpy as np
+import time
+from typing import Dict, List, Any, Optional
+from app.models.schemas import CardType, ExperienceData
+
+
+class FQICQLLearner:
+ """FQI + CQL 학습 엔진"""
+
+ def __init__(
+ self,
+ states: List[str],
+ actions: List[CardType],
+ alpha: float = 1.0, # CQL 보수성 파라미터
+ gamma: float = 0.95, # 할인율
+ learning_rate: float = 0.01
+ ):
+ self.states = states
+ self.actions = actions
+ self.alpha = alpha
+ self.gamma = gamma
+ self.learning_rate = learning_rate
+
+ # Q-네트워크 시뮬레이션 (실제로는 신경망이지만 여기서는 테이블로 구현)
+ action_names = [action.value for action in actions]
+ self.q_network = pd.DataFrame(
+ np.random.uniform(0, 0.1, (len(states), len(actions))),
+ index=states,
+ columns=action_names
+ )
+
+ # 학습 기록
+ self.training_history = []
+ self.batch_count = 0
+
+ def fitted_q_iteration(self, experience_batch: List[ExperienceData]) -> Dict[str, float]:
+ """
+ FQI 배치 학습 수행
+
+ Args:
+ experience_batch: 경험 데이터 배치
+
+ Returns:
+ 학습 결과 통계
+ """
+ if not experience_batch:
+ return {"bellman_loss": 0.0, "cql_penalty": 0.0, "batch_size": 0}
+
+ bellman_losses = []
+ cql_penalties = []
+
+ for exp in experience_batch:
+ state = exp.state
+ action = exp.action
+ reward = exp.reward
+ next_state = exp.next_state
+ done = exp.done
+
+ if state not in self.q_network.index:
+ continue
+
+ # Bellman Target 계산
+ if done or next_state not in self.q_network.index:
+ target = reward
+ else:
+ target = reward + self.gamma * self.q_network.loc[next_state].max()
+
+ current_q = self.q_network.loc[state, action.value]
+
+ # Bellman Error
+ bellman_error = (current_q - target) ** 2
+ bellman_losses.append(bellman_error)
+
+ # CQL Conservative Penalty 계산
+ # 데이터셋에 있는 행동 vs 모든 가능한 행동의 Q값 차이
+ all_q_values = self.q_network.loc[state]
+ dataset_q = current_q
+
+ # 보수적 추정: 데이터에 없는 행동의 Q값을 낮게 유지
+ ood_q_values = [] # Out-of-Distribution Q값들
+ for other_action in self.actions:
+ if other_action != action: # 현재 행동이 아닌 다른 행동들
+ ood_q_values.append(all_q_values[other_action.value])
+
+ if ood_q_values:
+ max_ood_q = max(ood_q_values)
+ cql_penalty = self.alpha * max(0, max_ood_q - dataset_q)
+ else:
+ cql_penalty = 0.0
+
+ cql_penalties.append(cql_penalty)
+
+ # 네트워크 업데이트 (간단한 그래디언트 스텝)
+ # 실제로는 신경망 역전파이지만, 여기서는 직접 업데이트
+ gradient = self.learning_rate * (target - current_q)
+ conservative_gradient = self.learning_rate * cql_penalty
+
+ # 벨만 오차 최소화 + CQL 페널티 적용
+ update = gradient - conservative_gradient
+ self.q_network.loc[state, action.value] += update
+
+ # 학습 기록 저장
+ avg_bellman_loss = np.mean(bellman_losses) if bellman_losses else 0.0
+ avg_cql_penalty = np.mean(cql_penalties) if cql_penalties else 0.0
+
+ self.training_history.append({
+ 'batch': self.batch_count,
+ 'avg_bellman_loss': avg_bellman_loss,
+ 'avg_cql_penalty': avg_cql_penalty,
+ 'batch_size': len(experience_batch),
+ 'timestamp': time.time()
+ })
+
+ self.batch_count += 1
+
+ return {
+ "bellman_loss": avg_bellman_loss,
+ "cql_penalty": avg_cql_penalty,
+ "batch_size": len(experience_batch)
+ }
+
+ def train_multiple_iterations(
+ self,
+ experience_batch: List[ExperienceData],
+ num_iterations: int = 10
+ ) -> Dict[str, Any]:
+ """
+ 여러 번의 FQI 반복 수행
+
+ Args:
+ experience_batch: 경험 데이터 배치
+ num_iterations: 반복 횟수
+
+ Returns:
+ 전체 학습 결과 통계
+ """
+ iteration_results = []
+
+ for i in range(num_iterations):
+ # 각 반복에서 배치를 셔플
+ shuffled_batch = np.random.permutation(experience_batch).tolist()
+ result = self.fitted_q_iteration(shuffled_batch)
+
+ iteration_results.append({
+ 'iteration': i,
+ **result
+ })
+
+ # 전체 통계 계산
+ return {
+ 'total_iterations': num_iterations,
+ 'avg_bellman_loss': np.mean([r['bellman_loss'] for r in iteration_results]),
+ 'avg_cql_penalty': np.mean([r['cql_penalty'] for r in iteration_results]),
+ 'final_bellman_loss': iteration_results[-1]['bellman_loss'] if iteration_results else 0.0,
+ 'final_cql_penalty': iteration_results[-1]['cql_penalty'] if iteration_results else 0.0,
+ 'iteration_details': iteration_results
+ }
+
+ def get_q_value(self, state: str, action: CardType) -> float:
+ """Q값 조회"""
+ if state in self.q_network.index:
+ return self.q_network.loc[state, action.value]
+ return 0.0
+
+ def get_optimal_action(self, state: str) -> CardType:
+ """현재 상태에서 최적 행동 선택"""
+ if state not in self.q_network.index:
+ import random
+ return random.choice(self.actions)
+
+ q_values = self.q_network.loc[state]
+ best_action_name = q_values.idxmax()
+
+ for action in self.actions:
+ if action.value == best_action_name:
+ return action
+
+ import random
+ return random.choice(self.actions)
+
+ def get_state_q_values(self, state: str) -> Dict[str, float]:
+ """특정 상태의 Q값들 반환"""
+ if state not in self.q_network.index:
+ return {action.value: 0.0 for action in self.actions}
+
+ return self.q_network.loc[state].to_dict()
+
+ def get_q_network_copy(self) -> pd.DataFrame:
+ """Q-네트워크 복사본 반환"""
+ return self.q_network.copy()
+
+ def compare_with_behavior_policy(
+ self,
+ experience_batch: List[ExperienceData]
+ ) -> Dict[str, Any]:
+ """
+ 학습된 정책과 행동 정책(데이터 수집 정책) 비교
+
+ Args:
+ experience_batch: 경험 데이터 배치
+
+ Returns:
+ 정책 비교 결과
+ """
+ if not experience_batch:
+ return {"policy_divergence": 0.0, "action_agreement": 0.0}
+
+ agreements = 0
+ total_comparisons = 0
+ q_value_differences = []
+
+ for exp in experience_batch:
+ state = exp.state
+ behavior_action = exp.action # 데이터 수집 시 선택된 행동
+
+ if state not in self.q_network.index:
+ continue
+
+ # 현재 학습된 정책의 최적 행동
+ learned_action = self.get_optimal_action(state)
+
+ # 행동 일치 여부
+ if behavior_action == learned_action:
+ agreements += 1
+
+ # Q값 차이 계산
+ behavior_q = self.get_q_value(state, behavior_action)
+ learned_q = self.get_q_value(state, learned_action)
+ q_value_differences.append(abs(learned_q - behavior_q))
+
+ total_comparisons += 1
+
+ if total_comparisons == 0:
+ return {"policy_divergence": 0.0, "action_agreement": 0.0}
+
+ action_agreement = agreements / total_comparisons
+ avg_q_difference = np.mean(q_value_differences)
+
+ return {
+ "policy_divergence": avg_q_difference,
+ "action_agreement": action_agreement,
+ "total_comparisons": total_comparisons,
+ "agreements": agreements
+ }
+
+ def get_training_statistics(self) -> Dict[str, Any]:
+ """학습 통계 반환"""
+ if not self.training_history:
+ return {
+ "total_batches": 0,
+ "avg_bellman_loss": 0.0,
+ "avg_cql_penalty": 0.0,
+ "convergence_trend": "unknown"
+ }
+
+ recent_history = self.training_history[-10:] # 최근 10개 배치
+
+ # 수렴 경향 분석
+ if len(self.training_history) >= 5:
+ recent_losses = [h['avg_bellman_loss'] for h in self.training_history[-5:]]
+ if all(recent_losses[i] >= recent_losses[i+1] for i in range(len(recent_losses)-1)):
+ convergence_trend = "improving"
+ elif all(recent_losses[i] <= recent_losses[i+1] for i in range(len(recent_losses)-1)):
+ convergence_trend = "deteriorating"
+ else:
+ convergence_trend = "fluctuating"
+ else:
+ convergence_trend = "insufficient_data"
+
+ return {
+ "total_batches": self.batch_count,
+ "avg_bellman_loss": np.mean([h['avg_bellman_loss'] for h in recent_history]),
+ "avg_cql_penalty": np.mean([h['avg_cql_penalty'] for h in recent_history]),
+ "convergence_trend": convergence_trend,
+ "q_network_stats": {
+ "min": float(self.q_network.min().min()),
+ "max": float(self.q_network.max().max()),
+ "mean": float(self.q_network.mean().mean()),
+ "std": float(self.q_network.std().mean())
+ }
+ }
+
+ def reset(self):
+ """학습 상태 초기화"""
+ # Q-네트워크 재초기화
+ action_names = [action.value for action in self.actions]
+ self.q_network = pd.DataFrame(
+ np.random.uniform(0, 0.1, (len(self.states), len(self.actions))),
+ index=self.states,
+ columns=action_names
+ )
+
+ # 기록 초기화
+ self.training_history.clear()
+ self.batch_count = 0
+
+ def set_hyperparameters(
+ self,
+ alpha: Optional[float] = None,
+ gamma: Optional[float] = None,
+ learning_rate: Optional[float] = None
+ ):
+ """하이퍼파라미터 설정"""
+ if alpha is not None:
+ self.alpha = alpha
+ if gamma is not None:
+ self.gamma = gamma
+ if learning_rate is not None:
+ self.learning_rate = learning_rate
diff --git a/app/services/negotiation_env.py b/app/services/negotiation_env.py
new file mode 100644
index 0000000..3b1e1b6
--- /dev/null
+++ b/app/services/negotiation_env.py
@@ -0,0 +1,222 @@
+"""
+협상 환경 시뮬레이터 서비스
+"""
+import random
+import numpy as np
+from typing import Dict, Tuple, Optional
+from app.models.schemas import ScenarioType, PriceZoneType, CardType
+
+
+class NegotiationEnvironment:
+ """협상 환경 시뮬레이터"""
+
+ def __init__(self):
+ # 문서 기준 가중치 설정
+ self.scenario_weights = {
+ ScenarioType.A: 1.0, # S_1 = A
+ ScenarioType.D: 0.75, # S_2 = D
+ ScenarioType.C: 0.5, # S_3 = C
+ ScenarioType.B: 0.25 # S_4 = B
+ }
+
+ self.price_zone_weights = {
+ PriceZoneType.PZ1: 0.1, # P < A (가장 좋은 구간)
+ PriceZoneType.PZ2: 0.5, # A < P < T (중간 구간)
+ PriceZoneType.PZ3: 1.0 # T < P (나쁜 구간)
+ }
+
+ # 카드별 협상 효과 (시뮬레이션용)
+ self.card_effects = {
+ CardType.C1: {"price_multiplier": 1.2, "success_rate": 0.3},
+ CardType.C2: {"price_multiplier": 1.1, "success_rate": 0.5},
+ CardType.C3: {"price_multiplier": 1.0, "success_rate": 0.7},
+ CardType.C4: {"price_multiplier": 0.9, "success_rate": 0.8}
+ }
+
+ # 시나리오별 협상 난이도
+ self.scenario_difficulty = {
+ ScenarioType.A: 1.3, # 가장 어려운 협상
+ ScenarioType.B: 1.1, # 보통 난이도
+ ScenarioType.C: 0.95, # 쉬운 협상
+ ScenarioType.D: 0.85 # 가장 쉬운 협상
+ }
+
+ def calculate_reward(
+ self,
+ scenario: ScenarioType,
+ price_zone: PriceZoneType,
+ anchor_price: float,
+ proposed_price: float,
+ is_end: bool
+ ) -> Tuple[float, float]:
+ """
+ 보상함수 계산: R(s,a) = W × (A/P) + (1-W) × End
+
+ Args:
+ scenario: 시나리오 타입
+ price_zone: 가격 구간
+ anchor_price: 목표가 (A)
+ proposed_price: 제안가 (P)
+ is_end: 협상 종료 여부
+
+ Returns:
+ (reward, weight): 보상값과 가중치
+ """
+ s_n = self.scenario_weights[scenario]
+ pz_n = self.price_zone_weights[price_zone]
+
+ # 가중치 계산: W = (S_n + PZ_n) / 2
+ w = (s_n + pz_n) / 2
+
+ # 가격 비율 계산 (0으로 나누기 방지)
+ if proposed_price == 0:
+ price_ratio = float('inf')
+ else:
+ price_ratio = anchor_price / proposed_price
+
+ # 보상 계산
+ reward = w * price_ratio + (1 - w) * (1 if is_end else 0)
+
+ return reward, w
+
+ def get_price_zone(
+ self,
+ price: float,
+ anchor_price: float,
+ threshold_multiplier: float = 1.2
+ ) -> PriceZoneType:
+ """
+ 가격에 따른 구간 결정
+
+ Args:
+ price: 제안 가격
+ anchor_price: 목표가
+ threshold_multiplier: 임계값 배수
+
+ Returns:
+ 가격 구간
+ """
+ threshold = anchor_price * threshold_multiplier
+
+ if price <= anchor_price:
+ return PriceZoneType.PZ1 # 목표가 이하 (좋음)
+ elif price <= threshold:
+ return PriceZoneType.PZ2 # 목표가와 임계값 사이 (보통)
+ else:
+ return PriceZoneType.PZ3 # 임계값 이상 (나쁨)
+
+ def simulate_opponent_response(
+ self,
+ current_card: CardType,
+ scenario: ScenarioType,
+ anchor_price: float,
+ step: int = 0
+ ) -> float:
+ """
+ 상대방 응답 시뮬레이션
+
+ Args:
+ current_card: 현재 사용한 카드
+ scenario: 현재 시나리오
+ anchor_price: 목표가
+ step: 현재 협상 단계
+
+ Returns:
+ 상대방 제안 가격
+ """
+ # 카드 효과
+ card_effect = self.card_effects[current_card]["price_multiplier"]
+
+ # 시나리오 난이도
+ scenario_difficulty = self.scenario_difficulty[scenario]
+
+ # 협상 진행에 따른 양보 (단계가 늘어날수록 가격 하락)
+ step_discount = 1.0 - (step * 0.05)
+ step_discount = max(step_discount, 0.7) # 최소 30% 할인
+
+ # 기본 가격 계산
+ base_multiplier = card_effect * scenario_difficulty * step_discount
+
+ # 랜덤 노이즈 추가 (현실적 변동성)
+ noise = np.random.uniform(0.85, 1.15)
+
+ # 최종 제안 가격
+ proposed_price = anchor_price * base_multiplier * noise
+
+ # 최소 가격 보장 (목표가의 70% 이상)
+ min_price = anchor_price * 0.7
+ proposed_price = max(proposed_price, min_price)
+
+ return round(proposed_price, 2)
+
+ def is_negotiation_successful(
+ self,
+ proposed_price: float,
+ anchor_price: float,
+ tolerance: float = 0.05
+ ) -> bool:
+ """
+ 협상 성공 여부 판단
+
+ Args:
+ proposed_price: 제안 가격
+ anchor_price: 목표가
+ tolerance: 허용 오차 (5%)
+
+ Returns:
+ 협상 성공 여부
+ """
+ success_threshold = anchor_price * (1 + tolerance)
+ return proposed_price <= success_threshold
+
+ def get_all_states(self) -> list[str]:
+ """모든 가능한 상태 목록 반환"""
+ states = ["C0S0P0"] # 초기 상태
+
+ for card in CardType:
+ for scenario in ScenarioType:
+ for price_zone in PriceZoneType:
+ state_id = f"{card.value}{scenario.value}{price_zone.value}"
+ states.append(state_id)
+
+ return states
+
+ def get_all_actions(self) -> list[CardType]:
+ """모든 가능한 행동 목록 반환"""
+ return list(CardType)
+
+ def parse_state(self, state_id: str) -> Optional[Dict[str, str]]:
+ """
+ 상태 ID를 파싱하여 구성 요소 반환
+
+ Args:
+ state_id: 상태 ID (예: "C1APZ1")
+
+ Returns:
+ 상태 구성 요소 딕셔너리 또는 None
+ """
+ if state_id == "C0S0P0":
+ return {"card": "C0", "scenario": "S0", "price_zone": "P0"}
+
+ if len(state_id) != 6: # 예: C1APZ1 (6글자)
+ return None
+
+ try:
+ card = state_id[:2] # C1
+ scenario = state_id[2] # A
+ price_zone = state_id[3:] # PZ1
+
+ # 유효성 검사
+ if (card in [c.value for c in CardType] and
+ scenario in [s.value for s in ScenarioType] and
+ price_zone in [pz.value for pz in PriceZoneType]):
+
+ return {
+ "card": card,
+ "scenario": scenario,
+ "price_zone": price_zone
+ }
+ except:
+ pass
+
+ return None
diff --git a/app/services/qtable_learner.py b/app/services/qtable_learner.py
new file mode 100644
index 0000000..c0f75ef
--- /dev/null
+++ b/app/services/qtable_learner.py
@@ -0,0 +1,293 @@
+"""
+Q-Table 학습 엔진 서비스
+"""
+import pandas as pd
+import numpy as np
+import random
+import time
+from typing import Dict, List, Optional, Tuple, Any
+from collections import deque
+from app.models.schemas import CardType, ExperienceData
+
+
+class ExperienceBuffer:
+ """경험 데이터 저장 및 관리"""
+
+ def __init__(self, max_size: int = 10000):
+ self.buffer = deque(maxlen=max_size)
+ self.max_size = max_size
+
+ def add_experience(
+ self,
+ state: str,
+ action: CardType,
+ reward: float,
+ next_state: str,
+ done: bool,
+ metadata: Optional[Dict[str, Any]] = None
+ ):
+ """경험 데이터 추가"""
+ experience = ExperienceData(
+ state=state,
+ action=action,
+ reward=reward,
+ next_state=next_state,
+ done=done,
+ timestamp=time.time(),
+ metadata=metadata or {}
+ )
+ self.buffer.append(experience)
+
+ def get_experiences(self) -> List[ExperienceData]:
+ """모든 경험 데이터 반환"""
+ return list(self.buffer)
+
+ def get_dataframe(self) -> pd.DataFrame:
+ """경험 데이터를 DataFrame으로 반환"""
+ if not self.buffer:
+ return pd.DataFrame()
+
+ data = []
+ for exp in self.buffer:
+ data.append({
+ 'state': exp.state,
+ 'action': exp.action.value,
+ 'reward': exp.reward,
+ 'next_state': exp.next_state,
+ 'done': exp.done,
+ 'timestamp': exp.timestamp,
+ **exp.metadata
+ })
+ return pd.DataFrame(data)
+
+ def sample_batch(self, batch_size: int = 32) -> List[ExperienceData]:
+ """배치 샘플링"""
+ if len(self.buffer) <= batch_size:
+ return list(self.buffer)
+
+ indices = np.random.choice(len(self.buffer), batch_size, replace=False)
+ return [self.buffer[i] for i in indices]
+
+ def clear(self):
+ """버퍼 초기화"""
+ self.buffer.clear()
+
+ def size(self) -> int:
+ """버퍼 크기 반환"""
+ return len(self.buffer)
+
+
+class QTableLearner:
+ """Q-Table 학습 엔진"""
+
+ def __init__(
+ self,
+ states: List[str],
+ actions: List[CardType],
+ learning_rate: float = 0.1,
+ discount_factor: float = 0.9,
+ epsilon: float = 0.1
+ ):
+ self.states = states
+ self.actions = actions
+ self.learning_rate = learning_rate
+ self.discount_factor = discount_factor
+ self.epsilon = epsilon
+
+ # Q-Table 초기화 (모든 값 0)
+ action_names = [action.value for action in actions]
+ self.q_table = pd.DataFrame(
+ 0.0,
+ index=states,
+ columns=action_names
+ )
+
+ # 학습 기록
+ self.learning_history = []
+ self.update_count = 0
+ self.total_reward = 0.0
+
+ def get_q_value(self, state: str, action: CardType) -> float:
+ """Q값 조회"""
+ if state in self.q_table.index:
+ return self.q_table.loc[state, action.value]
+ return 0.0
+
+ def set_q_value(self, state: str, action: CardType, value: float):
+ """Q값 설정"""
+ if state in self.q_table.index:
+ self.q_table.loc[state, action.value] = value
+
+ def get_optimal_action(self, state: str) -> CardType:
+ """현재 상태에서 최적 행동 선택 (그리디)"""
+ if state not in self.q_table.index:
+ return random.choice(self.actions)
+
+ q_values = self.q_table.loc[state]
+ best_action_name = q_values.idxmax()
+
+ # CardType으로 변환
+ for action in self.actions:
+ if action.value == best_action_name:
+ return action
+
+ return random.choice(self.actions)
+
+ def select_action(self, state: str, use_epsilon_greedy: bool = True) -> Tuple[CardType, bool]:
+ """
+ 행동 선택 (엡실론 그리디 또는 그리디)
+
+ Returns:
+ (action, is_exploration): 선택된 행동과 탐험 여부
+ """
+ if use_epsilon_greedy and random.random() < self.epsilon:
+ # 탐험: 무작위 행동
+ return random.choice(self.actions), True
+ else:
+ # 활용: 최적 행동
+ return self.get_optimal_action(state), False
+
+ def update_q_value(
+ self,
+ state: str,
+ action: CardType,
+ reward: float,
+ next_state: str,
+ done: bool
+ ) -> float:
+ """
+ Q-Learning 업데이트 규칙 적용
+ Q(s,a) ← Q(s,a) + α[r + γ max Q(s',a') - Q(s,a)]
+
+ Returns:
+ TD 오차
+ """
+ if state not in self.q_table.index:
+ return 0.0
+
+ current_q = self.get_q_value(state, action)
+
+ if done or next_state not in self.q_table.index:
+ target = reward
+ else:
+ max_next_q = self.q_table.loc[next_state].max()
+ target = reward + self.discount_factor * max_next_q
+
+ # TD 오차 계산
+ td_error = target - current_q
+
+ # Q값 업데이트
+ new_q = current_q + self.learning_rate * td_error
+ self.set_q_value(state, action, new_q)
+
+ # 학습 기록 저장
+ self.learning_history.append({
+ 'update': self.update_count,
+ 'state': state,
+ 'action': action.value,
+ 'old_q': current_q,
+ 'new_q': new_q,
+ 'reward': reward,
+ 'target': target,
+ 'td_error': abs(td_error),
+ 'timestamp': time.time()
+ })
+
+ self.update_count += 1
+ self.total_reward += reward
+
+ return td_error
+
+ def batch_update(self, experiences: List[ExperienceData]) -> Dict[str, float]:
+ """배치 업데이트"""
+ if not experiences:
+ return {"avg_td_error": 0.0, "updates": 0}
+
+ td_errors = []
+ updates = 0
+
+ for exp in experiences:
+ td_error = self.update_q_value(
+ exp.state,
+ exp.action,
+ exp.reward,
+ exp.next_state,
+ exp.done
+ )
+ if abs(td_error) > 1e-8: # 의미있는 업데이트만 카운트
+ td_errors.append(abs(td_error))
+ updates += 1
+
+ return {
+ "avg_td_error": np.mean(td_errors) if td_errors else 0.0,
+ "updates": updates,
+ "total_experiences": len(experiences)
+ }
+
+ def get_q_table_copy(self) -> pd.DataFrame:
+ """Q-Table 복사본 반환"""
+ return self.q_table.copy()
+
+ def get_state_q_values(self, state: str) -> Dict[str, float]:
+ """특정 상태의 Q값들 반환"""
+ if state not in self.q_table.index:
+ return {action.value: 0.0 for action in self.actions}
+
+ return self.q_table.loc[state].to_dict()
+
+ def get_learning_statistics(self) -> Dict[str, Any]:
+ """학습 통계 반환"""
+ if not self.learning_history:
+ return {
+ "total_updates": 0,
+ "avg_td_error": 0.0,
+ "avg_reward": 0.0,
+ "q_table_sparsity": 1.0
+ }
+
+ recent_history = self.learning_history[-100:] # 최근 100개
+
+ # Q-Table 희소성 계산 (0이 아닌 값의 비율)
+ non_zero_values = (self.q_table != 0).sum().sum()
+ total_values = self.q_table.size
+ sparsity = 1.0 - (non_zero_values / total_values)
+
+ return {
+ "total_updates": self.update_count,
+ "avg_td_error": np.mean([h['td_error'] for h in recent_history]),
+ "avg_reward": np.mean([h['reward'] for h in recent_history]),
+ "q_table_sparsity": sparsity,
+ "q_value_range": {
+ "min": float(self.q_table.min().min()),
+ "max": float(self.q_table.max().max()),
+ "mean": float(self.q_table.mean().mean())
+ }
+ }
+
+ def reset(self):
+ """학습 상태 초기화"""
+ # Q-Table을 0으로 초기화
+ self.q_table = pd.DataFrame(
+ 0.0,
+ index=self.states,
+ columns=[action.value for action in self.actions]
+ )
+
+ # 기록 초기화
+ self.learning_history.clear()
+ self.update_count = 0
+ self.total_reward = 0.0
+
+ def set_hyperparameters(
+ self,
+ learning_rate: Optional[float] = None,
+ discount_factor: Optional[float] = None,
+ epsilon: Optional[float] = None
+ ):
+ """하이퍼파라미터 설정"""
+ if learning_rate is not None:
+ self.learning_rate = learning_rate
+ if discount_factor is not None:
+ self.discount_factor = discount_factor
+ if epsilon is not None:
+ self.epsilon = epsilon
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..68808e2
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,35 @@
+version: '3.8'
+
+services:
+ api:
+ build: .
+ ports:
+ - "8000:8000"
+ environment:
+ - API_HOST=0.0.0.0
+ - API_PORT=8000
+ command: python run_api.py
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ frontend:
+ build: .
+ ports:
+ - "8501:8501"
+ environment:
+ - FRONTEND_HOST=0.0.0.0
+ - FRONTEND_PORT=8501
+ command: python run_frontend.py
+ depends_on:
+ - api
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8501"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+volumes:
+ data:
diff --git a/dockerignore b/dockerignore
new file mode 100644
index 0000000..4a8328b
--- /dev/null
+++ b/dockerignore
@@ -0,0 +1,42 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Poetry
+poetry.lock
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+logs/
+
+# Data
+data/
+*.sqlite
+*.db
+
+# Tests
+.pytest_cache/
+.coverage
+htmlcov/
+
+# Git
+.git/
+.gitignore
diff --git a/env b/env
new file mode 100644
index 0000000..a7e529c
--- /dev/null
+++ b/env
@@ -0,0 +1,19 @@
+# 개발 환경 설정
+API_HOST=localhost
+API_PORT=8000
+FRONTEND_HOST=localhost
+FRONTEND_PORT=8501
+
+# 강화학습 하이퍼파라미터
+DEFAULT_LEARNING_RATE=0.1
+DEFAULT_DISCOUNT_FACTOR=0.9
+DEFAULT_EPSILON=0.1
+
+# 협상 환경 설정
+DEFAULT_ANCHOR_PRICE=100
+MAX_EPISODES=1000
+MAX_STEPS_PER_EPISODE=10
+
+# 로깅 설정
+LOG_LEVEL=INFO
+LOG_FILE=app.log
diff --git a/env.example b/env.example
new file mode 100644
index 0000000..a7e529c
--- /dev/null
+++ b/env.example
@@ -0,0 +1,19 @@
+# 개발 환경 설정
+API_HOST=localhost
+API_PORT=8000
+FRONTEND_HOST=localhost
+FRONTEND_PORT=8501
+
+# 강화학습 하이퍼파라미터
+DEFAULT_LEARNING_RATE=0.1
+DEFAULT_DISCOUNT_FACTOR=0.9
+DEFAULT_EPSILON=0.1
+
+# 협상 환경 설정
+DEFAULT_ANCHOR_PRICE=100
+MAX_EPISODES=1000
+MAX_STEPS_PER_EPISODE=10
+
+# 로깅 설정
+LOG_LEVEL=INFO
+LOG_FILE=app.log
diff --git a/frontend/__init__.py b/frontend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/app.py b/frontend/app.py
new file mode 100644
index 0000000..fe3ad77
--- /dev/null
+++ b/frontend/app.py
@@ -0,0 +1,905 @@
+"""
+Streamlit 기반 Q-Table 협상 전략 데모 프론트엔드
+"""
+import streamlit as st
+import requests
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+import seaborn as sns
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+import time
+import json
+from typing import Dict, Any, Optional
+
+# 페이지 설정
+st.set_page_config(
+ page_title="Q-Table 협상 전략 데모",
+ page_icon="🎯",
+ layout="wide",
+ initial_sidebar_state="expanded"
+)
+
+# API 기본 URL
+API_BASE_URL = "http://localhost:8000/api/v1"
+
+# 세션 상태 초기화
+if 'current_state' not in st.session_state:
+ st.session_state.current_state = "C0S0P0"
+if 'anchor_price' not in st.session_state:
+ st.session_state.anchor_price = 100
+
+
+class APIClient:
+ """API 클라이언트"""
+
+ @staticmethod
+ def get(endpoint: str) -> Optional[Dict[str, Any]]:
+ """GET 요청"""
+ try:
+ response = requests.get(f"{API_BASE_URL}{endpoint}")
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ st.error(f"API 요청 오류: {e}")
+ return None
+
+ @staticmethod
+ def post(endpoint: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """POST 요청"""
+ try:
+ response = requests.post(f"{API_BASE_URL}{endpoint}", json=data)
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ st.error(f"API 요청 오류: {e}")
+ return None
+
+
+def display_header():
+ """헤더 제목 표시"""
+ st.title("🎯 Q-Table 기반 협상 전략 데모")
+ st.markdown("""
+ ### 강화학습으로 배우는 협상 전략의 진화
+
+ 이 데모는 **콜드 스타트 문제**부터 **학습된 정책**까지 Q-Learning의 전체 여정을 보여줍니다.
+
+ **핵심 보상함수:** `R(s,a) = W × (A/P) + (1-W) × End`
+ """)
+ st.markdown("---")
+
+
+def display_sidebar():
+ """사이드바 설정"""
+ st.sidebar.header("⚙️ 데모 설정")
+
+ # 시스템 상태 조회
+ status_response = APIClient.get("/status")
+ if status_response and status_response.get("success") is not False:
+ status = status_response
+ st.sidebar.metric("총 경험 데이터", status.get("total_experiences", 0))
+ st.sidebar.metric("Q-Table 업데이트", status.get("q_table_updates", 0))
+ st.sidebar.metric("고유 상태", status.get("unique_states", 0))
+ st.sidebar.metric("평균 보상", f"{status.get('average_reward', 0):.3f}")
+ st.sidebar.metric("성공률", f"{status.get('success_rate', 0)*100:.1f}%")
+
+ st.sidebar.markdown("---")
+
+ # 글로벌 설정
+ st.session_state.anchor_price = st.sidebar.number_input(
+ "목표가 (A)",
+ value=st.session_state.anchor_price,
+ min_value=50,
+ max_value=300
+ )
+
+ # 시스템 초기화
+ if st.sidebar.button("🔄 시스템 초기화", type="secondary"):
+ with st.spinner("시스템 초기화 중..."):
+ reset_response = APIClient.post("/reset", {})
+ if reset_response and reset_response.get("success"):
+ st.sidebar.success("시스템이 초기화되었습니다!")
+ st.rerun()
+ else:
+ st.sidebar.error("초기화 실패")
+
+ return status_response
+
+
+def tab_cold_start():
+ """콜드 스타트 탭"""
+ st.header("🏁 콜드 스타트 문제")
+
+ st.markdown("""
+ ### 강화학습의 첫 번째 난관
+
+ 새로운 강화학습 에이전트가 직면하는 가장 큰 문제는 **"아무것도 모른다"**는 것입니다.
+ 모든 Q값이 0으로 초기화되어 있어, 어떤 행동이 좋은지 전혀 알 수 없습니다.
+ """)
+
+ # Q-Table 현재 상태 조회
+ qtable_response = APIClient.get("/qtable")
+ if qtable_response and qtable_response.get("success"):
+ qtable_data = qtable_response["data"]
+ q_table_dict = qtable_data["q_table"]
+
+ # DataFrame으로 변환
+ q_table_df = pd.DataFrame(q_table_dict)
+
+ st.subheader("📋 현재 Q-Table 상태")
+
+ # 통계 표시
+ col1, col2, col3, col4 = st.columns(4)
+ with col1:
+ non_zero_count = (q_table_df != 0).sum().sum()
+ st.metric("비어있지 않은 Q값", non_zero_count)
+ with col2:
+ total_count = q_table_df.size
+ st.metric("전체 Q값", total_count)
+ with col3:
+ sparsity = (1 - non_zero_count / total_count) * 100
+ st.metric("희소성", f"{sparsity:.1f}%")
+ with col4:
+ st.metric("업데이트 횟수", qtable_data["update_count"])
+
+ # Q-Table 표시 (상위 20개 상태만)
+ display_rows = min(20, len(q_table_df))
+ st.dataframe(
+ q_table_df.head(display_rows).style.format("{:.3f}").highlight_max(axis=1),
+ use_container_width=True
+ )
+
+ if len(q_table_df) > 20:
+ st.info(f"전체 {len(q_table_df)}개 상태 중 상위 20개만 표시됩니다.")
+
+ # 문제점과 해결방법
+ col1, col2 = st.columns(2)
+
+ with col1:
+ st.subheader("❌ 핵심 문제점")
+ st.markdown("""
+ - **무지상태**: 모든 Q값이 동일 (보통 0)
+ - **행동 선택 불가**: 어떤 행동이 좋은지 모름
+ - **무작위 탐험**: 비효율적인 학습 초기 단계
+ - **데이터 부족**: 학습할 경험이 없음
+ """)
+
+ with col2:
+ st.subheader("✅ 해결 방법")
+ st.markdown("""
+ - **탐험 전략**: Epsilon-greedy로 무작위 탐험
+ - **경험 수집**: (상태, 행동, 보상, 다음상태) 튜플 저장
+ - **점진적 학습**: 수집된 경험으로 Q값 업데이트
+ - **정책 개선**: 학습을 통한 점진적 성능 향상
+ """)
+
+ # Q-Learning 공식 설명
+ st.subheader("🧮 Q-Learning 업데이트 공식")
+ st.latex(r"Q(s,a) \leftarrow Q(s,a) + lpha [r + \gamma \max_{a'} Q(s',a') - Q(s,a)]")
+
+ st.markdown("""
+ **공식 설명:**
+ - **Q(s,a)**: 상태 s에서 행동 a의 Q값
+ - **α (알파)**: 학습률 (0 < α ≤ 1)
+ - **r**: 즉시 보상
+ - **γ (감마)**: 할인율 (0 ≤ γ < 1)
+ - **max Q(s',a')**: 다음 상태에서의 최대 Q값
+ """)
+
+
+def tab_data_collection():
+ """데이터 수집 탭"""
+ st.header("📊 경험 데이터 수집")
+
+ st.markdown("""
+ ### 학습의 연료: 경험 데이터
+
+ 강화학습 에이전트는 환경과 상호작용하면서 **경험 튜플**을 수집합니다.
+ 각 경험은 `(상태, 행동, 보상, 다음상태, 종료여부)` 형태로 저장됩니다.
+ """)
+
+ # 설정 섹션
+ col1, col2 = st.columns([1, 2])
+
+ with col1:
+ st.subheader("⚙️ 에피소드 생성 설정")
+
+ num_episodes = st.slider("생성할 에피소드 수", 1, 50, 10)
+ max_steps = st.slider("에피소드당 최대 스텝", 3, 15, 8)
+ exploration_rate = st.slider("탐험율 (Epsilon)", 0.0, 1.0, 0.4, 0.1)
+
+ st.markdown(f"""
+ **현재 설정:**
+ - 목표가: {st.session_state.anchor_price}
+ - 탐험율: {exploration_rate*100:.0f}%
+ - 총 예상 경험: ~{num_episodes * max_steps}개
+ """)
+
+ if st.button("🎲 자동 에피소드 생성", type="primary"):
+ with st.spinner("에피소드 생성 중..."):
+ request_data = {
+ "num_episodes": num_episodes,
+ "max_steps": max_steps,
+ "anchor_price": st.session_state.anchor_price,
+ "exploration_rate": exploration_rate
+ }
+
+ response = APIClient.post("/episodes/generate", request_data)
+ if response and response.get("success"):
+ result = response["data"]
+ st.success(f"✅ {result['new_experiences']}개의 새로운 경험 데이터 생성!")
+
+ # 에피소드 결과 표시
+ episode_results = result["episode_results"]
+ success_count = sum(1 for ep in episode_results if ep["success"])
+
+ col_a, col_b, col_c = st.columns(3)
+ with col_a:
+ st.metric("생성된 에피소드", result["episodes_generated"])
+ with col_b:
+ st.metric("성공한 협상", success_count)
+ with col_c:
+ success_rate = (success_count / len(episode_results)) * 100
+ st.metric("성공률", f"{success_rate:.1f}%")
+
+ time.sleep(1) # UI 업데이트를 위한 잠시 대기
+ st.rerun()
+ else:
+ st.error("에피소드 생성 실패")
+
+ with col2:
+ st.subheader("📈 수집된 데이터 현황")
+
+ # 경험 데이터 조회
+ exp_response = APIClient.get("/experiences")
+ if exp_response and exp_response.get("success"):
+ exp_data = exp_response["data"]
+ stats = exp_data["statistics"]
+ recent_data = exp_data["recent_data"]
+
+ # 통계 표시
+ col_a, col_b, col_c, col_d = st.columns(4)
+ with col_a:
+ st.metric("총 경험 수", stats["total_count"])
+ with col_b:
+ st.metric("평균 보상", f"{stats['avg_reward']:.3f}")
+ with col_c:
+ st.metric("성공률", f"{stats['success_rate']*100:.1f}%")
+ with col_d:
+ st.metric("고유 상태", stats["unique_states"])
+
+ # 최근 경험 데이터 표시
+ if recent_data:
+ st.subheader("🔍 최근 경험 데이터")
+ recent_df = pd.DataFrame(recent_data)
+
+ # 필요한 컬럼만 선택
+ display_columns = ['state', 'action', 'reward', 'next_state', 'done']
+ available_columns = [col for col in display_columns if col in recent_df.columns]
+
+ if available_columns:
+ display_df = recent_df[available_columns].tail(10)
+ st.dataframe(
+ display_df.style.format({'reward': '{:.3f}'}),
+ use_container_width=True
+ )
+
+ # 데이터 분포 시각화
+ if len(recent_df) > 5:
+ st.subheader("📊 데이터 분포 분석")
+
+ fig, axes = plt.subplots(2, 2, figsize=(12, 8))
+
+ # 보상 분포
+ axes[0,0].hist(recent_df['reward'], bins=15, alpha=0.7, color='skyblue', edgecolor='black')
+ axes[0,0].set_title('보상 분포')
+ axes[0,0].set_xlabel('보상값')
+ axes[0,0].set_ylabel('빈도')
+
+ # 행동 분포
+ if 'action' in recent_df.columns:
+ action_counts = recent_df['action'].value_counts()
+ axes[0,1].bar(action_counts.index, action_counts.values, color='lightgreen', edgecolor='black')
+ axes[0,1].set_title('행동 선택 빈도')
+ axes[0,1].set_xlabel('행동')
+ axes[0,1].set_ylabel('빈도')
+
+ # 상태 분포 (상위 10개)
+ if 'state' in recent_df.columns:
+ state_counts = recent_df['state'].value_counts().head(10)
+ axes[1,0].bar(range(len(state_counts)), state_counts.values, color='orange', edgecolor='black')
+ axes[1,0].set_title('상위 상태 빈도')
+ axes[1,0].set_xlabel('상태 순위')
+ axes[1,0].set_ylabel('빈도')
+ axes[1,0].set_xticks(range(len(state_counts)))
+ axes[1,0].set_xticklabels([f"{i+1}" for i in range(len(state_counts))])
+
+ # 성공/실패 분포
+ if 'done' in recent_df.columns:
+ done_counts = recent_df['done'].value_counts()
+ labels = ['진행중' if not k else '완료' for k in done_counts.index]
+ axes[1,1].pie(done_counts.values, labels=labels, autopct='%1.1f%%', colors=['lightcoral', 'lightblue'])
+ axes[1,1].set_title('협상 완료 비율')
+
+ plt.tight_layout()
+ st.pyplot(fig)
+ else:
+ st.info("아직 수집된 데이터가 없습니다. 왼쪽에서 에피소드를 생성해보세요!")
+ else:
+ st.warning("경험 데이터를 불러올 수 없습니다.")
+
+ # 경험 데이터 구조 설명
+ st.subheader("📋 경험 데이터 구조")
+ st.markdown("""
+ 각 경험 튜플은 다음 정보를 포함합니다:
+
+ | 항목 | 설명 | 예시 |
+ |------|------|------|
+ | **상태 (State)** | 현재 협상 상황 | "C1APZ2" (카드1, 시나리오A, 가격구간2) |
+ | **행동 (Action)** | 선택한 협상 카드 | "C3" |
+ | **보상 (Reward)** | 행동에 대한 평가 | 0.85 |
+ | **다음상태 (Next State)** | 행동 후 새로운 상황 | "C3APZ1" |
+ | **종료 (Done)** | 협상 완료 여부 | true/false |
+ """)
+
+
+# 더 많은 탭 함수들을 다음 파트에서 계속...
+
+
+def tab_q_learning():
+ """Q-Learning 탭"""
+ st.header("🔄 Q-Learning 실시간 학습")
+
+ st.markdown("""
+ ### 경험으로부터 학습하기
+
+ 수집된 경험 데이터를 사용하여 Q-Table을 업데이트합니다.
+ 각 경험에서 **TD(Temporal Difference) 오차**를 계산하고 Q값을 조정합니다.
+ """)
+
+ col1, col2 = st.columns([1, 2])
+
+ with col1:
+ st.subheader("⚙️ 학습 설정")
+
+ learning_rate = st.slider("학습률 (α)", 0.01, 0.5, 0.1, 0.01)
+ discount_factor = st.slider("할인율 (γ)", 0.8, 0.99, 0.9, 0.01)
+ batch_size = st.slider("배치 크기", 16, 256, 32, 16)
+
+ st.markdown(f"""
+ **하이퍼파라미터:**
+ - **학습률 (α)**: {learning_rate} - 새로운 정보의 반영 정도
+ - **할인율 (γ)**: {discount_factor} - 미래 보상의 중요도
+ - **배치 크기**: {batch_size} - 한 번에 학습할 경험 수
+ """)
+
+ if st.button("🚀 Q-Learning 실행", type="primary"):
+ with st.spinner("Q-Learning 업데이트 중..."):
+ request_data = {
+ "learning_rate": learning_rate,
+ "discount_factor": discount_factor,
+ "batch_size": batch_size
+ }
+
+ response = APIClient.post("/learning/q-learning", request_data)
+ if response and response.get("success"):
+ result = response["data"]
+ st.success(f"✅ {result['updates']}개 Q값 업데이트 완료!")
+
+ col_a, col_b = st.columns(2)
+ with col_a:
+ st.metric("배치 크기", result["batch_size"])
+ with col_b:
+ st.metric("평균 TD 오차", f"{result.get('avg_td_error', 0):.4f}")
+
+ time.sleep(1)
+ st.rerun()
+ else:
+ st.error("Q-Learning 업데이트 실패")
+
+ with col2:
+ st.subheader("📊 Q-Table 현황")
+
+ # Q-Table 데이터 조회
+ qtable_response = APIClient.get("/qtable")
+ if qtable_response and qtable_response.get("success"):
+ qtable_data = qtable_response["data"]
+ statistics = qtable_data["statistics"]
+
+ # 통계 표시
+ col_a, col_b, col_c, col_d = st.columns(4)
+ with col_a:
+ st.metric("총 업데이트", statistics.get("total_updates", 0))
+ with col_b:
+ st.metric("평균 TD 오차", f"{statistics.get('avg_td_error', 0):.4f}")
+ with col_c:
+ st.metric("평균 보상", f"{statistics.get('avg_reward', 0):.3f}")
+ with col_d:
+ sparsity = statistics.get('q_table_sparsity', 1.0) * 100
+ st.metric("Q-Table 희소성", f"{sparsity:.1f}%")
+
+ # Q값 범위 표시
+ q_range = statistics.get('q_value_range', {})
+ if q_range:
+ st.subheader("📈 Q값 분포")
+ col_a, col_b, col_c = st.columns(3)
+ with col_a:
+ st.metric("최솟값", f"{q_range.get('min', 0):.3f}")
+ with col_b:
+ st.metric("평균값", f"{q_range.get('mean', 0):.3f}")
+ with col_c:
+ st.metric("최댓값", f"{q_range.get('max', 0):.3f}")
+
+ # Q-Table 표시 (비어있지 않은 상태들만)
+ q_table_dict = qtable_data["q_table"]
+ q_table_df = pd.DataFrame(q_table_dict)
+
+ # 0이 아닌 값이 있는 행만 필터링
+ non_zero_rows = (q_table_df != 0).any(axis=1)
+ if non_zero_rows.any():
+ st.subheader("🎯 학습된 Q값들")
+ learned_qtable = q_table_df[non_zero_rows].head(15)
+ st.dataframe(
+ learned_qtable.style.format("{:.3f}").highlight_max(axis=1),
+ use_container_width=True
+ )
+
+ learned_count = non_zero_rows.sum()
+ total_count = len(q_table_df)
+ st.info(f"전체 {total_count}개 상태 중 {learned_count}개 상태가 학습되었습니다.")
+ else:
+ st.info("아직 학습된 Q값이 없습니다. 위에서 Q-Learning을 실행해보세요!")
+
+ # TD 오차 설명
+ st.subheader("🧮 TD(Temporal Difference) 오차")
+ st.markdown("""
+ **TD 오차**는 현재 Q값과 목표값의 차이입니다:
+
+ `TD 오차 = [r + γ max Q(s',a')] - Q(s,a)`
+
+ - **양수**: 현재 Q값이 너무 낮음 → Q값 증가
+ - **음수**: 현재 Q값이 너무 높음 → Q값 감소
+ - **0에 가까움**: Q값이 적절함 → 학습 수렴
+ """)
+
+
+def tab_fqi_cql():
+ """FQI+CQL 탭"""
+ st.header("🧠 FQI + CQL 오프라인 학습")
+
+ st.markdown("""
+ ### 오프라인 강화학습의 핵심
+
+ **FQI (Fitted Q-Iteration)**와 **CQL (Conservative Q-Learning)**을 결합한
+ 오프라인 강화학습 방법입니다. 수집된 데이터만으로 안전하고 보수적인 정책을 학습합니다.
+ """)
+
+ col1, col2 = st.columns([1, 2])
+
+ with col1:
+ st.subheader("⚙️ FQI+CQL 설정")
+
+ alpha = st.slider("CQL 보수성 파라미터 (α)", 0.0, 3.0, 1.0, 0.1)
+ gamma = st.slider("할인율 (γ)", 0.8, 0.99, 0.95, 0.01)
+ batch_size = st.slider("배치 크기", 16, 256, 32, 16)
+ num_iterations = st.slider("반복 횟수", 1, 50, 10, 1)
+
+ st.markdown(f"""
+ **설정값:**
+ - **α (Alpha)**: {alpha} - 보수성 강도
+ - **γ (Gamma)**: {gamma} - 미래 보상 할인
+ - **배치 크기**: {batch_size}
+ - **반복 횟수**: {num_iterations}
+ """)
+
+ st.markdown("""
+ **CQL 특징:**
+ - 🛡️ **보수적 추정**: 불확실한 행동의 Q값을 낮게 유지
+ - 📊 **데이터 기반**: 수집된 경험만 활용
+ - 🎯 **안전한 정책**: 분포 이동 문제 해결
+ """)
+
+ if st.button("🚀 FQI+CQL 실행", type="primary"):
+ with st.spinner("FQI+CQL 학습 중..."):
+ request_data = {
+ "alpha": alpha,
+ "gamma": gamma,
+ "batch_size": batch_size,
+ "num_iterations": num_iterations
+ }
+
+ response = APIClient.post("/learning/fqi-cql", request_data)
+ if response and response.get("success"):
+ result = response["data"]
+ training_result = result["training_result"]
+ policy_comparison = result["policy_comparison"]
+
+ st.success(f"✅ {training_result['total_iterations']}회 반복 학습 완료!")
+
+ # 학습 결과 표시
+ col_a, col_b = st.columns(2)
+ with col_a:
+ st.metric("평균 벨만 손실", f"{training_result['avg_bellman_loss']:.4f}")
+ with col_b:
+ st.metric("평균 CQL 페널티", f"{training_result['avg_cql_penalty']:.4f}")
+
+ # 정책 비교
+ st.metric("행동 정책과의 일치율", f"{policy_comparison['action_agreement']*100:.1f}%")
+
+ time.sleep(1)
+ st.rerun()
+ else:
+ st.error("FQI+CQL 학습 실패")
+
+ with col2:
+ st.subheader("📊 FQI+CQL 결과")
+
+ # FQI+CQL 결과 조회
+ fqi_response = APIClient.get("/fqi-cql")
+ if fqi_response and fqi_response.get("success"):
+ fqi_data = fqi_response["data"]
+ statistics = fqi_data["statistics"]
+
+ # 통계 표시
+ col_a, col_b, col_c = st.columns(3)
+ with col_a:
+ st.metric("학습 배치", statistics.get("total_batches", 0))
+ with col_b:
+ st.metric("벨만 손실", f"{statistics.get('avg_bellman_loss', 0):.4f}")
+ with col_c:
+ st.metric("CQL 페널티", f"{statistics.get('avg_cql_penalty', 0):.4f}")
+
+ # 수렴 경향
+ convergence = statistics.get("convergence_trend", "unknown")
+ convergence_color = {
+ "improving": "🟢",
+ "deteriorating": "🔴",
+ "fluctuating": "🟡",
+ "insufficient_data": "⚪"
+ }
+ st.info(f"수렴 경향: {convergence_color.get(convergence, '❓')} {convergence}")
+
+ # Q-Network 통계
+ q_stats = statistics.get("q_network_stats", {})
+ if q_stats:
+ st.subheader("📈 Q-Network 분포")
+ col_a, col_b, col_c, col_d = st.columns(4)
+ with col_a:
+ st.metric("최솟값", f"{q_stats.get('min', 0):.3f}")
+ with col_b:
+ st.metric("평균값", f"{q_stats.get('mean', 0):.3f}")
+ with col_c:
+ st.metric("최댓값", f"{q_stats.get('max', 0):.3f}")
+ with col_d:
+ st.metric("표준편차", f"{q_stats.get('std', 0):.3f}")
+
+ # Q-Network 표시 (상위 15개 상태)
+ q_network_dict = fqi_data["q_network"]
+ q_network_df = pd.DataFrame(q_network_dict)
+
+ st.subheader("🎯 학습된 Q-Network")
+ display_df = q_network_df.head(15)
+ st.dataframe(
+ display_df.style.format("{:.3f}").highlight_max(axis=1),
+ use_container_width=True
+ )
+ else:
+ st.info("FQI+CQL을 먼저 실행해주세요!")
+
+ # FQI+CQL 알고리즘 설명
+ st.subheader("🔬 FQI + CQL 알고리즘")
+
+ col1, col2 = st.columns(2)
+
+ with col1:
+ st.markdown("""
+ **FQI (Fitted Q-Iteration)**
+ - 배치 기반 Q-Learning
+ - 전체 데이터셋을 한 번에 활용
+ - 함수 근사 (신경망) 사용
+ - 안정적인 학습 과정
+ """)
+
+ with col2:
+ st.markdown("""
+ **CQL (Conservative Q-Learning)**
+ - 보수적 Q값 추정
+ - Out-of-Distribution 행동 억제
+ - 데이터에 없는 행동의 Q값 하향 조정
+ - 안전한 정책 학습
+ """)
+
+
+def tab_learned_policy():
+ """학습된 정책 탭"""
+ st.header("🎯 학습된 정책 비교 및 활용")
+
+ st.markdown("""
+ ### 학습 완료: 정책의 진화
+
+ Q-Learning과 FQI+CQL로 학습된 정책을 비교하고,
+ 실제 협상 상황에서 어떤 행동을 추천하는지 확인해보세요.
+ """)
+
+ # 상태 선택
+ col1, col2 = st.columns([1, 2])
+
+ with col1:
+ st.subheader("🎮 협상 시뮬레이션")
+
+ # 상태 구성 요소 선택
+ current_card = st.selectbox("현재 카드", ["C1", "C2", "C3", "C4"])
+ scenario = st.selectbox("시나리오", ["A", "B", "C", "D"])
+ price_zone = st.selectbox("가격 구간", ["PZ1", "PZ2", "PZ3"])
+
+ # 상태 ID 생성
+ selected_state = f"{current_card}{scenario}{price_zone}"
+ st.session_state.current_state = selected_state
+
+ st.info(f"선택된 상태: **{selected_state}**")
+
+ # 상태 해석
+ state_interpretation = {
+ "A": "어려운 협상 (높은 가중치)",
+ "B": "쉬운 협상 (낮은 가중치)",
+ "C": "보통 협상 (중간 가중치)",
+ "D": "매우 쉬운 협상 (낮은 가중치)"
+ }
+
+ price_interpretation = {
+ "PZ1": "목표가 이하 (좋은 구간)",
+ "PZ2": "목표가~임계값 (보통 구간)",
+ "PZ3": "임계값 이상 (나쁜 구간)"
+ }
+
+ st.markdown(f"""
+ **상태 해석:**
+ - **카드**: {current_card}
+ - **시나리오**: {scenario} - {state_interpretation.get(scenario, "알 수 없음")}
+ - **가격구간**: {price_zone} - {price_interpretation.get(price_zone, "알 수 없음")}
+ """)
+
+ # 행동 추천 요청
+ use_epsilon = st.checkbox("엡실론 그리디 사용", value=False)
+ epsilon = 0.1
+ if use_epsilon:
+ epsilon = st.slider("엡실론 값", 0.0, 0.5, 0.1, 0.05)
+
+ if st.button("🎯 행동 추천 받기", type="primary"):
+ request_data = {
+ "current_state": selected_state,
+ "use_epsilon_greedy": use_epsilon,
+ "epsilon": epsilon
+ }
+
+ response = APIClient.post("/action/recommend", request_data)
+ if response and response.get("success") is not False:
+ # response가 직접 ActionRecommendationResponse 형태인 경우
+ recommendation = response
+
+ st.success(f"🎯 추천 행동: **{recommendation.get('recommended_action', 'N/A')}**")
+
+ if recommendation.get('exploration', False):
+ st.warning("🎲 탐험 행동 (무작위 선택)")
+ else:
+ confidence = recommendation.get('confidence', 0) * 100
+ st.info(f"🎯 활용 행동 (신뢰도: {confidence:.1f}%)")
+
+ # Q값들 표시
+ q_values = recommendation.get('q_values', {})
+ if q_values:
+ st.subheader("📊 현재 상태의 Q값들")
+ q_df = pd.DataFrame([q_values]).T
+ q_df.columns = ['Q값']
+ q_df = q_df.sort_values('Q값', ascending=False)
+
+ # 추천 행동 하이라이트
+ def highlight_recommended(s):
+ return ['background-color: lightgreen' if x == recommendation.get('recommended_action')
+ else '' for x in s.index]
+
+ st.dataframe(
+ q_df.style.format({'Q값': '{:.3f}'}).apply(highlight_recommended, axis=0),
+ use_container_width=True
+ )
+ else:
+ st.error("행동 추천 실패")
+
+ with col2:
+ st.subheader("⚖️ 정책 비교")
+
+ # 정책 비교 요청
+ compare_response = APIClient.get(f"/compare/{selected_state}")
+ if compare_response and compare_response.get("success"):
+ comparison = compare_response["data"]
+
+ # 정책 일치 여부
+ agreement = comparison["policy_agreement"]
+ if agreement:
+ st.success("✅ Q-Learning과 FQI+CQL 정책이 일치합니다!")
+ else:
+ st.warning("⚠️ Q-Learning과 FQI+CQL 정책이 다릅니다.")
+
+ # 각 정책의 추천 행동
+ col_a, col_b = st.columns(2)
+
+ with col_a:
+ st.subheader("🔄 Q-Learning 정책")
+ q_learning = comparison["q_learning"]
+ st.metric("추천 행동", q_learning["action"])
+
+ # Q값들
+ q_values_ql = q_learning["q_values"]
+ if q_values_ql:
+ q_df_ql = pd.DataFrame([q_values_ql]).T
+ q_df_ql.columns = ['Q값']
+ st.dataframe(q_df_ql.style.format({'Q값': '{:.3f}'}))
+
+ with col_b:
+ st.subheader("🧠 FQI+CQL 정책")
+ fqi_cql = comparison["fqi_cql"]
+ st.metric("추천 행동", fqi_cql["action"])
+
+ # Q값들
+ q_values_fqi = fqi_cql["q_values"]
+ if q_values_fqi:
+ q_df_fqi = pd.DataFrame([q_values_fqi]).T
+ q_df_fqi.columns = ['Q값']
+ st.dataframe(q_df_fqi.style.format({'Q값': '{:.3f}'}))
+
+ # Q값 차이 분석
+ differences = comparison["q_value_differences"]
+ max_diff = comparison["max_difference"]
+
+ st.subheader("📊 Q값 차이 분석")
+ st.metric("최대 차이", f"{max_diff:.3f}")
+
+ if differences:
+ diff_df = pd.DataFrame([differences]).T
+ diff_df.columns = ['차이']
+ st.dataframe(diff_df.style.format({'차이': '{:.3f}'}))
+
+ else:
+ st.info("정책 비교를 위해 상태를 선택하고 학습을 진행해주세요.")
+
+ # 보상 계산 시뮬레이션
+ st.subheader("🧮 보상 계산 시뮬레이션")
+
+ col1, col2 = st.columns(2)
+
+ with col1:
+ st.subheader("📋 시뮬레이션 설정")
+ proposed_price = st.number_input("상대방 제안가", value=120.0, min_value=50.0, max_value=500.0)
+ is_negotiation_end = st.checkbox("협상 종료", value=False)
+
+ if st.button("💰 보상 계산", type="secondary"):
+ request_data = {
+ "scenario": scenario,
+ "price_zone": price_zone,
+ "anchor_price": st.session_state.anchor_price,
+ "proposed_price": proposed_price,
+ "is_end": is_negotiation_end
+ }
+
+ response = APIClient.post("/reward/calculate", request_data)
+ if response and response.get("success") is not False:
+ # response가 직접 RewardCalculationResponse 형태인 경우
+ reward_result = response
+
+ col_a, col_b, col_c = st.columns(3)
+ with col_a:
+ st.metric("보상", f"{reward_result.get('reward', 0):.3f}")
+ with col_b:
+ st.metric("가중치 (W)", f"{reward_result.get('weight', 0):.3f}")
+ with col_c:
+ st.metric("가격 비율 (A/P)", f"{reward_result.get('price_ratio', 0):.3f}")
+
+ # 공식 분해 표시
+ formula = reward_result.get('formula_breakdown', '')
+ if formula:
+ st.subheader('📝 계산 과정')
+ st.text(formula)
+ else:
+ st.error("보상 계산 실패")
+
+ with col2:
+ st.subheader("📈 학습 진행 상황")
+
+ # 시스템 상태 조회
+ status_response = APIClient.get("/status")
+ if status_response and status_response.get("success") is not False:
+ status = status_response
+
+ # 진행 상황 메트릭
+ total_exp = status.get("total_experiences", 0)
+ updates = status.get("q_table_updates", 0)
+ success_rate = status.get("success_rate", 0) * 100
+
+ progress_metrics = [
+ ("데이터 수집", total_exp, 1000, "개"),
+ ("Q-Table 업데이트", updates, 500, "회"),
+ ("협상 성공률", success_rate, 100, "%")
+ ]
+
+ for name, value, target, unit in progress_metrics:
+ progress = min(value / target, 1.0)
+ st.metric(
+ name,
+ f"{value}{unit}",
+ delta=f"목표: {target}{unit}"
+ )
+ st.progress(progress)
+
+ st.subheader("🎓 학습 완성도")
+
+ # Q-Table 완성도
+ qtable_response = APIClient.get("/qtable")
+ if qtable_response and qtable_response.get("success"):
+ qtable_data = qtable_response["data"]
+ statistics = qtable_data["statistics"]
+
+ sparsity = statistics.get('q_table_sparsity', 1.0)
+ completeness = (1 - sparsity) * 100
+
+ st.metric("Q-Table 완성도", f"{completeness:.1f}%")
+ st.progress(completeness / 100)
+
+ if completeness > 80:
+ st.success("🎉 충분히 학습되었습니다!")
+ elif completeness > 50:
+ st.info("📖 적당히 학습되었습니다.")
+ else:
+ st.warning("📚 더 많은 학습이 필요합니다.")
+
+
+def main():
+ """메인 함수"""
+ # 헤더 표시
+ display_header()
+
+ # 사이드바 표시
+ sidebar_status = display_sidebar()
+
+ # 탭 생성
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
+ "🏁 1. 콜드 스타트",
+ "📊 2. 데이터 수집",
+ "🔄 3. Q-Learning",
+ "🧠 4. FQI+CQL",
+ "🎯 5. 학습된 정책"
+ ])
+
+ with tab1:
+ tab_cold_start()
+
+ with tab2:
+ tab_data_collection()
+
+ with tab3:
+ tab_q_learning()
+
+ with tab4:
+ tab_fqi_cql()
+
+ with tab5:
+ tab_learned_policy()
+
+ # 푸터
+ st.markdown("---")
+ st.markdown("""
+
+ """, unsafe_allow_html=True)
+
+
+def start_frontend():
+ """프론트엔드 시작 (Poetry 스크립트용)"""
+ import subprocess
+ subprocess.run(["streamlit", "run", "frontend/app.py", "--server.port", "8501"])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/gitignore b/gitignore
new file mode 100644
index 0000000..4a2df41
--- /dev/null
+++ b/gitignore
@@ -0,0 +1,162 @@
+# Byte-compiled / optimized / DLL files
+__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
+
+# PyInstaller
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+Pipfile.lock
+
+# poetry
+poetry.lock
+
+# pdm
+.pdm.toml
+
+# PEP 582
+__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
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+.idea/
+
+# VSCode
+.vscode/
+
+# OS
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Application specific
+data/
+logs/
+*.sqlite
+*.db
+temp/
+cache/
+
+# Streamlit
+.streamlit/
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5f72405
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,35 @@
+[tool.poetry]
+name = "qtable-negotiation-demo"
+version = "0.1.0"
+description = "Q-Table 기반 협상 전략 강화학습 데모"
+authors = ["Demo Author "]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.8"
+fastapi = "^0.104.1"
+uvicorn = {extras = ["standard"], version = "^0.24.0"}
+streamlit = "^1.28.0"
+pandas = "^2.1.0"
+numpy = "^1.24.0"
+matplotlib = "^3.7.0"
+seaborn = "^0.12.0"
+plotly = "^5.17.0"
+python-dotenv = "^1.0.0"
+pydantic = "^1.10.12"
+requests = "^2.31.0"
+scikit-learn = "^1.3.0"
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^7.4.0"
+black = "^23.9.0"
+flake8 = "^6.1.0"
+mypy = "^1.6.0"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.poetry.scripts]
+start-api = "app.main:start_api"
+start-frontend = "frontend.app:start_frontend"
diff --git a/requirements(4).txt b/requirements(4).txt
new file mode 100644
index 0000000..b52e7c0
--- /dev/null
+++ b/requirements(4).txt
@@ -0,0 +1,13 @@
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+streamlit==1.28.0
+pandas==2.1.0
+numpy==1.24.0
+matplotlib==3.7.0
+seaborn==0.12.0
+plotly==5.17.0
+python-dotenv==1.0.0
+pydantic==1.10.12
+requests==2.31.0
+scikit-learn==1.3.0
+pytest==7.4.0
diff --git a/run_api.py b/run_api.py
new file mode 100644
index 0000000..91502db
--- /dev/null
+++ b/run_api.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""
+API 서버 실행 스크립트
+"""
+import uvicorn
+from app.main import app
+from app.core.config import settings
+
+if __name__ == "__main__":
+ print("🚀 Q-Table 협상 전략 데모 API 서버를 시작합니다...")
+ print(f"📍 주소: http://{settings.api_host}:{settings.api_port}")
+ print(f"📚 API 문서: http://{settings.api_host}:{settings.api_port}/docs")
+ print("🛑 종료하려면 Ctrl+C를 누르세요")
+
+ uvicorn.run(
+ "app.main:app",
+ host=settings.api_host,
+ port=settings.api_port,
+ reload=True,
+ log_level="info"
+ )
diff --git a/run_both.py b/run_both.py
new file mode 100644
index 0000000..dcbf7cf
--- /dev/null
+++ b/run_both.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""
+API와 프론트엔드 동시 실행 스크립트
+"""
+import subprocess
+import sys
+import time
+from threading import Thread
+from app.core.config import settings
+
+def run_api():
+ """API 서버 실행"""
+ subprocess.run([
+ sys.executable, "run_api.py"
+ ])
+
+def run_frontend():
+ """프론트엔드 실행"""
+ # API 서버가 시작될 시간을 기다림
+ time.sleep(3)
+ subprocess.run([
+ sys.executable, "run_frontend.py"
+ ])
+
+if __name__ == "__main__":
+ print("🚀 Q-Table 협상 전략 데모 전체 시스템을 시작합니다...")
+ print(f"🔧 API 서버: http://{settings.api_host}:{settings.api_port}")
+ print(f"🎯 프론트엔드: http://{settings.frontend_host}:{settings.frontend_port}")
+ print("🛑 종료하려면 Ctrl+C를 누르세요")
+
+ try:
+ # API 서버를 별도 스레드에서 실행
+ api_thread = Thread(target=run_api, daemon=True)
+ api_thread.start()
+
+ # 프론트엔드 실행 (메인 스레드)
+ run_frontend()
+
+ except KeyboardInterrupt:
+ print("\n👋 시스템을 종료합니다.")
diff --git a/run_frontend.py b/run_frontend.py
new file mode 100644
index 0000000..3bc10d5
--- /dev/null
+++ b/run_frontend.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+"""
+프론트엔드 실행 스크립트
+"""
+import subprocess
+import sys
+from app.core.config import settings
+
+if __name__ == "__main__":
+ print("🎯 Q-Table 협상 전략 데모 프론트엔드를 시작합니다...")
+ print(f"📍 주소: http://{settings.frontend_host}:{settings.frontend_port}")
+ print("🛑 종료하려면 Ctrl+C를 누르세요")
+
+ try:
+ subprocess.run([
+ sys.executable, "-m", "streamlit", "run",
+ "frontend/app.py",
+ "--server.port", str(settings.frontend_port),
+ "--server.address", settings.frontend_host
+ ])
+ except KeyboardInterrupt:
+ print("\n👋 프론트엔드를 종료합니다.")
diff --git a/test_basic.py b/test_basic.py
new file mode 100644
index 0000000..5496046
--- /dev/null
+++ b/test_basic.py
@@ -0,0 +1,180 @@
+"""
+기본 테스트 모듈
+"""
+import pytest
+import requests
+from app.services.negotiation_env import NegotiationEnvironment
+from app.services.qtable_learner import QTableLearner, ExperienceBuffer
+from app.models.schemas import CardType, ScenarioType, PriceZoneType
+
+
+class TestNegotiationEnvironment:
+ """협상 환경 테스트"""
+
+ def setup_method(self):
+ self.env = NegotiationEnvironment()
+
+ def test_reward_calculation(self):
+ """보상 계산 테스트"""
+ reward, weight = self.env.calculate_reward(
+ scenario=ScenarioType.A,
+ price_zone=PriceZoneType.PZ1,
+ anchor_price=100,
+ proposed_price=95,
+ is_end=True
+ )
+
+ assert reward > 0
+ assert 0 <= weight <= 1
+
+ def test_price_zone_determination(self):
+ """가격 구간 결정 테스트"""
+ # 목표가 이하
+ zone = self.env.get_price_zone(90, 100)
+ assert zone == PriceZoneType.PZ1
+
+ # 중간 구간
+ zone = self.env.get_price_zone(110, 100)
+ assert zone == PriceZoneType.PZ2
+
+ # 높은 구간
+ zone = self.env.get_price_zone(150, 100)
+ assert zone == PriceZoneType.PZ3
+
+ def test_opponent_response_simulation(self):
+ """상대방 응답 시뮬레이션 테스트"""
+ price = self.env.simulate_opponent_response(
+ current_card=CardType.C1,
+ scenario=ScenarioType.A,
+ anchor_price=100,
+ step=0
+ )
+
+ assert price > 0
+ assert isinstance(price, float)
+
+
+class TestQTableLearner:
+ """Q-Table 학습 테스트"""
+
+ def setup_method(self):
+ states = ["S1", "S2", "S3"]
+ actions = [CardType.C1, CardType.C2]
+ self.learner = QTableLearner(states, actions)
+
+ def test_initialization(self):
+ """초기화 테스트"""
+ assert self.learner.q_table.shape == (3, 2)
+ assert (self.learner.q_table == 0).all().all()
+
+ def test_q_value_update(self):
+ """Q값 업데이트 테스트"""
+ td_error = self.learner.update_q_value(
+ state="S1",
+ action=CardType.C1,
+ reward=1.0,
+ next_state="S2",
+ done=False
+ )
+
+ assert td_error != 0
+ assert self.learner.get_q_value("S1", CardType.C1) != 0
+
+ def test_action_selection(self):
+ """행동 선택 테스트"""
+ # 초기 상태에서는 무작위 선택
+ action, is_exploration = self.learner.select_action("S1")
+ assert action in [CardType.C1, CardType.C2]
+
+ # Q값 설정 후 최적 행동 선택
+ self.learner.set_q_value("S1", CardType.C2, 1.0)
+ optimal_action = self.learner.get_optimal_action("S1")
+ assert optimal_action == CardType.C2
+
+
+class TestExperienceBuffer:
+ """경험 버퍼 테스트"""
+
+ def setup_method(self):
+ self.buffer = ExperienceBuffer(max_size=10)
+
+ def test_add_experience(self):
+ """경험 추가 테스트"""
+ self.buffer.add_experience(
+ state="S1",
+ action=CardType.C1,
+ reward=1.0,
+ next_state="S2",
+ done=False
+ )
+
+ assert self.buffer.size() == 1
+
+ def test_buffer_overflow(self):
+ """버퍼 오버플로우 테스트"""
+ # 최대 크기보다 많이 추가
+ for i in range(15):
+ self.buffer.add_experience(
+ state=f"S{i}",
+ action=CardType.C1,
+ reward=1.0,
+ next_state=f"S{i+1}",
+ done=False
+ )
+
+ # 최대 크기 유지
+ assert self.buffer.size() == 10
+
+ def test_sampling(self):
+ """샘플링 테스트"""
+ # 경험 추가
+ for i in range(5):
+ self.buffer.add_experience(
+ state=f"S{i}",
+ action=CardType.C1,
+ reward=1.0,
+ next_state=f"S{i+1}",
+ done=False
+ )
+
+ # 배치 샘플링
+ batch = self.buffer.sample_batch(3)
+ assert len(batch) == 3
+
+
+# API 통합 테스트 (선택사항)
+class TestAPIIntegration:
+ """API 통합 테스트"""
+
+ def test_health_check(self):
+ """헬스 체크 테스트"""
+ try:
+ response = requests.get("http://localhost:8000/api/v1/health")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "healthy"
+ except requests.exceptions.ConnectionError:
+ pytest.skip("API 서버가 실행되지 않음")
+
+ def test_reward_calculation_endpoint(self):
+ """보상 계산 엔드포인트 테스트"""
+ try:
+ payload = {
+ "scenario": "A",
+ "price_zone": "PZ1",
+ "anchor_price": 100,
+ "proposed_price": 95,
+ "is_end": True
+ }
+
+ response = requests.post(
+ "http://localhost:8000/api/v1/reward/calculate",
+ json=payload
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "reward" in data
+ assert "weight" in data
+ except requests.exceptions.ConnectionError:
+ pytest.skip("API 서버가 실행되지 않음")
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..83b5ca7
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,46 @@
+# Q-Table 프로젝트 검토 진행 상황
+
+## Phase 1: 프로젝트 파일 분석 및 구조 파악 ✅
+- [x] 프로젝트 파일 복사 및 구조 확인
+- [x] README.md 분석 - 프로젝트 개요 파악
+- [x] requirements.txt 및 pyproject.toml 확인
+- [x] 주요 코드 파일들 구조 분석
+- [x] 프로젝트 디렉토리 구조 정리
+
+## Phase 2: 핵심 코드 검토 및 문제점 식별 ✅
+- [x] qtable_learner.py 검토 - import 문제 발견
+- [x] negotiation_env.py 검토 - import 문제 발견
+- [x] app.py (Streamlit 프론트엔드) 검토
+- [x] schemas.py 검토 - 정상
+- [x] main(1).py (FastAPI 백엔드) 검토 - import 문제 발견
+- [x] config.py 검토 - 정상
+- [x] endpoints.py 검토 - import 문제 발견
+- [x] demo_service.py 검토 - import 문제 발견
+- [x] 실행 스크립트들 검토 - import 문제 발견
+- [x] 문제점 정리
+
+## 발견된 주요 문제점:
+1. **파일 구조 불일치**: README에서 언급된 app/ 디렉토리 구조가 실제와 다름 ✅ 해결
+2. **Import 경로 오류**: `from app.models.schemas import` 등의 경로가 잘못됨 ✅ 해결
+3. **파일명 중복**: main(1).py, __init__(1).py 등 중복된 파일명 ✅ 해결
+
+## Phase 3: 의존성 및 환경 설정 검증 ✅
+- [x] 의존성 설치 테스트 - 성공
+- [x] 환경 변수 설정 확인 - .env 파일 생성
+- [x] 프로젝트 구조 수정 필요 여부 확인 - 수정 완료
+
+## Phase 4: 코드 실행 테스트 및 오류 수정 ✅
+- [x] 프로젝트 구조 재구성
+- [x] Import 경로 수정
+- [x] 모듈 import 테스트 - 모두 성공
+- [x] API 서버 실행 테스트 - 성공
+- [x] 기본 테스트 실행 - 성공
+
+## Phase 5: 수정된 프로젝트 결과 보고 ✅
+- [x] 수정 사항 정리
+- [x] 검토 보고서 작성
+- [x] 최종 실행 가능한 프로젝트 제공
+
+## 🎉 프로젝트 수정 완료!
+모든 문제점이 해결되어 Q-Table 데모 프로젝트가 정상적으로 실행 가능한 상태가 되었습니다.
+