Spaces:
Sleeping
Sleeping
ashfortune commited on
Commit ·
aa53da8
0
Parent(s):
first commit
Browse files- .gitignore +39 -0
- Dockerfile +24 -0
- README.md +76 -0
- backend/main.py +147 -0
- backend/requirements.txt +14 -0
- backend/schemas.py +58 -0
- backend/services/classifier.py +105 -0
- backend/services/llm_service.py +291 -0
- frontend/.gitignore +41 -0
- frontend/AGENTS.md +5 -0
- frontend/CLAUDE.md +1 -0
- frontend/README.md +36 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next.config.ts +7 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +30 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/next.svg +1 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/globals.css +118 -0
- frontend/src/app/layout.tsx +33 -0
- frontend/src/app/page.tsx +432 -0
- frontend/src/components/MbtiChart.tsx +62 -0
- frontend/src/lib/api.ts +32 -0
- frontend/tsconfig.json +34 -0
.gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Python ---
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
.venv/
|
| 6 |
+
env/
|
| 7 |
+
venv/
|
| 8 |
+
ENV/
|
| 9 |
+
*.log
|
| 10 |
+
|
| 11 |
+
# --- Node / Next.js ---
|
| 12 |
+
node_modules/
|
| 13 |
+
.next/
|
| 14 |
+
out/
|
| 15 |
+
build/
|
| 16 |
+
dist/
|
| 17 |
+
npm-debug.log*
|
| 18 |
+
yarn-debug.log*
|
| 19 |
+
yarn-error.log*
|
| 20 |
+
.pnpm-debug.log*
|
| 21 |
+
|
| 22 |
+
# --- Environment Secrets ---
|
| 23 |
+
.env
|
| 24 |
+
.env.local
|
| 25 |
+
.env.development.local
|
| 26 |
+
.env.test.local
|
| 27 |
+
.env.production.local
|
| 28 |
+
*.pem
|
| 29 |
+
|
| 30 |
+
# --- OS Specific ---
|
| 31 |
+
.DS_Store
|
| 32 |
+
.DS_Store?
|
| 33 |
+
._*
|
| 34 |
+
Thumbs.db
|
| 35 |
+
|
| 36 |
+
# --- Model Artifacts (Large Files) ---
|
| 37 |
+
backend/models/
|
| 38 |
+
./backend/models/
|
| 39 |
+
mbti_final.ipynb
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1. Base 이미지 설정 (Python 3.10 slim 버전 사용)
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# 2. 필수 시스템 패키지 설치
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
build-essential \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
# 3. 작업 디렉토리 설정
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# 4. 의존성 파일 복사 및 설치
|
| 13 |
+
COPY backend/requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# 5. 소스 코드 및 모델 공유 파일 복사
|
| 17 |
+
COPY backend/ .
|
| 18 |
+
|
| 19 |
+
# 6. Hugging Face Spaces 포트 설정 (기본 7860)
|
| 20 |
+
ENV PORT=7860
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
# 7. FastAPI 서버 실행
|
| 24 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CommuniKate - MBTI AI Assistant
|
| 3 |
+
emoji: 💬
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# 💬 CommuniKate: MBTI AI 소통 전문가
|
| 11 |
+
|
| 12 |
+

|
| 13 |
+
|
| 14 |
+
> **"상대방의 마음을 읽고, 최적의 대화를 제안합니다."**
|
| 15 |
+
> CommuniKate는 정밀한 딥러닝 분석과 최신 LLM 기술을 결합하여 개인화된 MBTI 소통 전략을 제공하는 AI 어시스턴트입니다.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 🚀 주요 기능
|
| 20 |
+
|
| 21 |
+
- **🎯 메시지 분석:** 상대방의 메시지 속 언어적 습관을 분석하여 가장 유력한 MBTI 성향을 도출합니다.
|
| 22 |
+
- **💡 맞춤형 조언:** 나의 성향과 상대방의 성향을 고려하여 갈등을 피하고 호감을 얻을 수 있는 답변 레시피를 제안합니다.
|
| 23 |
+
- **🎭 실전 대화 시뮬레이션:** 특정 MBTI 성향을 가진 AI와 실시간으로 대화하며 커뮤니케이션 스킬을 연습합니다.
|
| 24 |
+
- **🛡️ 실시간 AI 가이드:** 대화 도중 AI 코치가 상대방의 예상 반응과 최적의 말투를 실시간으로 조언합니다.
|
| 25 |
+
|
| 26 |
+
## 🛠️ 기술 스택
|
| 27 |
+
|
| 28 |
+
### **Backend (Analysis Engine)**
|
| 29 |
+
- **Framework:** FastAPI
|
| 30 |
+
- **ML Models:** 4x BERT Axis Ensembles (local), Google Gemma 4 (LLM)
|
| 31 |
+
- **OCR:** Google Vision AI Integration
|
| 32 |
+
|
| 33 |
+
### **Frontend (Modern Web)**
|
| 34 |
+
- **Framework:** Next.js (App Router)
|
| 35 |
+
- **Styling:** Premium Design System (Vanilla CSS / Tailwind)
|
| 36 |
+
- **Visuals:** Recharts for dynamic personality mapping
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## 📂 프로젝트 구조
|
| 41 |
+
|
| 42 |
+
```text
|
| 43 |
+
MBTI/
|
| 44 |
+
├── backend/ # FastAPI 분석 엔진 및 모델 서비스
|
| 45 |
+
├── frontend/ # Next.js 반응형 웹 대시보드
|
| 46 |
+
├── Dockerfile # Hugging Face 배포를 위한 이미지 설정
|
| 47 |
+
└── README.md # 통합 가이드 문서
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## ⚙️ 시작하기
|
| 51 |
+
|
| 52 |
+
### **로컬 환경 실행**
|
| 53 |
+
|
| 54 |
+
**1. 백엔드 설정**
|
| 55 |
+
```bash
|
| 56 |
+
cd backend
|
| 57 |
+
pip install -r requirements.txt
|
| 58 |
+
python main.py
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
**2. 프론트엔드 설정**
|
| 62 |
+
```bash
|
| 63 |
+
cd frontend
|
| 64 |
+
npm install
|
| 65 |
+
npm run dev
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## 🌐 배포 정보
|
| 71 |
+
|
| 72 |
+
- **Frontend:** Vercel (Next.js Optimized)
|
| 73 |
+
- **Backend:** Hugging Face Spaces (Dockerized ML Env)
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
© 2026 CommuniKate Project. All rights reserved.
|
backend/main.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from typing import List, Dict, Optional
|
| 5 |
+
import uvicorn
|
| 6 |
+
import PIL.Image
|
| 7 |
+
import io
|
| 8 |
+
|
| 9 |
+
from services.classifier import MBTIClassifier
|
| 10 |
+
from services.llm_service import LLMService
|
| 11 |
+
from schemas import (
|
| 12 |
+
AnalyzeRequest, AnalyzeResponse,
|
| 13 |
+
OCRResponse,
|
| 14 |
+
ChatStartRequest, ChatStartResponse,
|
| 15 |
+
ChatRequest, ChatResponse,
|
| 16 |
+
SimulateRequest, SimulateResponse
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
app = FastAPI(title="CommuniKate API")
|
| 20 |
+
|
| 21 |
+
# CORS 설정 (Next.js 연동을 위해 필요)
|
| 22 |
+
app.add_middleware(
|
| 23 |
+
CORSMiddleware,
|
| 24 |
+
allow_origins=["*"], # 개발 중에는 모두 허용, 운영 시에는 프론트엔드 도메인으로 제한 권장
|
| 25 |
+
allow_credentials=True,
|
| 26 |
+
allow_methods=["*"],
|
| 27 |
+
allow_headers=["*"],
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# 모델 경로 설정
|
| 31 |
+
BERT_MODEL_DIR = "models/bert_mbti_ver2"
|
| 32 |
+
|
| 33 |
+
# 싱글톤 패턴으로 서비스 초기화
|
| 34 |
+
classifier = MBTIClassifier(BERT_MODEL_DIR)
|
| 35 |
+
llm_service = LLMService(model_name="gemma4:latest")
|
| 36 |
+
|
| 37 |
+
@app.post("/api/analyze", response_model=AnalyzeResponse)
|
| 38 |
+
async def analyze(request: AnalyzeRequest):
|
| 39 |
+
if not request.target_text.strip():
|
| 40 |
+
raise HTTPException(status_code=400, detail="메시지를 입력해주세요.")
|
| 41 |
+
|
| 42 |
+
probs = {}
|
| 43 |
+
axis_scores = {'I/E': 0, 'S/N': 0, 'T/F': 0, 'P/J': 0}
|
| 44 |
+
analysis_summary = ""
|
| 45 |
+
target_mbti = ""
|
| 46 |
+
|
| 47 |
+
if request.target_mbti_input == "자동 분석 (AI)":
|
| 48 |
+
if request.context_detail and request.context_detail.strip():
|
| 49 |
+
reasoning_result = await llm_service.analyze_mbti_with_reasoning(request.context_detail, request.target_text)
|
| 50 |
+
target_mbti = "심도 있는 분석 중"
|
| 51 |
+
analysis_summary = f"### 🧠 AI 정밀 상황 분석 리포트\n{reasoning_result}"
|
| 52 |
+
else:
|
| 53 |
+
analysis = classifier.predict(request.target_text)
|
| 54 |
+
target_mbti = analysis["mbti"]
|
| 55 |
+
confidence = analysis["confidence"]
|
| 56 |
+
probs = analysis["probabilities"]
|
| 57 |
+
analysis_summary = f"### 🎯 메시지 분석 결과: {target_mbti}\n**신뢰도: {confidence*100:.1f}%**"
|
| 58 |
+
|
| 59 |
+
# 축 점수 계산
|
| 60 |
+
for mbti, p in probs.items():
|
| 61 |
+
if mbti[0] == 'I': axis_scores['I/E'] += p
|
| 62 |
+
if mbti[1] == 'S': axis_scores['S/N'] += p
|
| 63 |
+
if mbti[2] == 'T': axis_scores['T/F'] += p
|
| 64 |
+
if mbti[3] == 'P': axis_scores['P/J'] += p
|
| 65 |
+
else:
|
| 66 |
+
target_mbti = request.target_mbti_input
|
| 67 |
+
analysis_summary = f"### 👤 지정된 MBTI: {target_mbti}\n**사용자 직접 설정**"
|
| 68 |
+
|
| 69 |
+
# 답변 제안 생성
|
| 70 |
+
advice = await llm_service.generate_response(
|
| 71 |
+
request.my_mbti, target_mbti, request.situation,
|
| 72 |
+
request.relationship, request.vibe, request.target_text
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# 데이터 기반 분석 근거 추가
|
| 76 |
+
if request.target_mbti_input == "자동 분석 (AI)":
|
| 77 |
+
axis_data = ", ".join([f"{k}: {v*100:.1f}%" for k, v in axis_scores.items()])
|
| 78 |
+
reasoning_result = await llm_service.analyze_mbti_with_reasoning(
|
| 79 |
+
f"상황: {request.situation}, 관계: {request.relationship}, 분위기: {request.vibe}\n[메시지 분석 데이터] {axis_data}",
|
| 80 |
+
request.target_text
|
| 81 |
+
)
|
| 82 |
+
analysis_summary += f"\n\n--- \n#### 🛡️ AI 전문가의 성향 분석 가이드\n{reasoning_result}"
|
| 83 |
+
|
| 84 |
+
return AnalyzeResponse(
|
| 85 |
+
analysis_summary=analysis_summary,
|
| 86 |
+
probabilities=probs,
|
| 87 |
+
axis_scores=axis_scores,
|
| 88 |
+
advice=advice
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
@app.post("/api/ocr", response_model=OCRResponse)
|
| 92 |
+
async def ocr(file: UploadFile = File(...)):
|
| 93 |
+
contents = await file.read()
|
| 94 |
+
image = PIL.Image.open(io.BytesIO(contents))
|
| 95 |
+
text = await llm_service.extract_text_from_image(image)
|
| 96 |
+
return OCRResponse(text=text)
|
| 97 |
+
|
| 98 |
+
@app.post("/api/chat/start", response_model=ChatStartResponse)
|
| 99 |
+
async def chat_start(request: ChatStartRequest):
|
| 100 |
+
history = []
|
| 101 |
+
coaching_tip = "대화를 시작했습니다. 메시지를 보내시면 AI 코칭이 시작됩니다."
|
| 102 |
+
|
| 103 |
+
if request.ai_first:
|
| 104 |
+
greeting = await llm_service.generate_initial_greeting(
|
| 105 |
+
request.target_mbti, request.relationship, request.situation
|
| 106 |
+
)
|
| 107 |
+
history.append({"role": "assistant", "content": greeting})
|
| 108 |
+
coaching_tip = "AI가 먼저 인사를 건넸습니다. 대화를 이어가 보세요!"
|
| 109 |
+
|
| 110 |
+
return ChatStartResponse(history=history, coaching_tip=coaching_tip)
|
| 111 |
+
|
| 112 |
+
@app.post("/api/chat", response_model=ChatResponse)
|
| 113 |
+
async def chat(request: ChatRequest):
|
| 114 |
+
if not request.user_input.strip():
|
| 115 |
+
return ChatResponse(history=[h.dict() for h in request.history], coaching_tip="메시지를 입력해 주세요.")
|
| 116 |
+
|
| 117 |
+
import asyncio
|
| 118 |
+
history_dicts = [h.dict() for h in request.history]
|
| 119 |
+
|
| 120 |
+
tasks = [
|
| 121 |
+
llm_service.chat_with_persona(
|
| 122 |
+
history_dicts, request.user_input, request.user_mbti,
|
| 123 |
+
request.target_mbti, request.relationship, request.situation
|
| 124 |
+
),
|
| 125 |
+
llm_service.get_coaching_tip(request.user_input, request.target_mbti, request.relationship)
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
response, coaching_tip = await asyncio.gather(*tasks)
|
| 129 |
+
|
| 130 |
+
new_history = history_dicts + [
|
| 131 |
+
{"role": "user", "content": request.user_input},
|
| 132 |
+
{"role": "assistant", "content": response}
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
return ChatResponse(history=new_history, coaching_tip=coaching_tip)
|
| 136 |
+
|
| 137 |
+
@app.post("/api/simulate", response_model=SimulateResponse)
|
| 138 |
+
async def simulate(request: SimulateRequest):
|
| 139 |
+
reaction = await llm_service.simulate_reaction(
|
| 140 |
+
request.my_mbti, request.target_mbti_input,
|
| 141 |
+
request.situation, request.relationship, request.advice_text
|
| 142 |
+
)
|
| 143 |
+
return SimulateResponse(reaction=reaction)
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
port = int(os.environ.get("PORT", 8000))
|
| 147 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
python-multipart
|
| 4 |
+
pydantic
|
| 5 |
+
httpx
|
| 6 |
+
pillow
|
| 7 |
+
tensorflow
|
| 8 |
+
transformers==4.48.3
|
| 9 |
+
tf-keras
|
| 10 |
+
torch
|
| 11 |
+
numpy<2
|
| 12 |
+
google-genai
|
| 13 |
+
python-dotenv
|
| 14 |
+
scikit-learn
|
backend/schemas.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Dict, Optional, Union
|
| 3 |
+
|
| 4 |
+
class AnalyzeRequest(BaseModel):
|
| 5 |
+
my_mbti: str
|
| 6 |
+
target_mbti_input: str
|
| 7 |
+
situation: str
|
| 8 |
+
relationship: str
|
| 9 |
+
vibe: str
|
| 10 |
+
context_detail: Optional[str] = ""
|
| 11 |
+
target_text: str
|
| 12 |
+
|
| 13 |
+
class AnalyzeResponse(BaseModel):
|
| 14 |
+
analysis_summary: str
|
| 15 |
+
probabilities: Dict[str, float]
|
| 16 |
+
# plot_data: Optional[Dict] = None # Plotly data can be complex to send as JSON, better to regenerate in frontend or send as simplified list
|
| 17 |
+
axis_scores: Dict[str, float]
|
| 18 |
+
advice: str
|
| 19 |
+
|
| 20 |
+
class OCRResponse(BaseModel):
|
| 21 |
+
text: str
|
| 22 |
+
|
| 23 |
+
class ChatStartRequest(BaseModel):
|
| 24 |
+
ai_first: bool
|
| 25 |
+
user_mbti: str
|
| 26 |
+
target_mbti: str
|
| 27 |
+
relationship: str
|
| 28 |
+
situation: str
|
| 29 |
+
|
| 30 |
+
class ChatStartResponse(BaseModel):
|
| 31 |
+
history: List[Dict[str, str]]
|
| 32 |
+
coaching_tip: str
|
| 33 |
+
|
| 34 |
+
class ChatMessage(BaseModel):
|
| 35 |
+
role: str
|
| 36 |
+
content: str
|
| 37 |
+
|
| 38 |
+
class ChatRequest(BaseModel):
|
| 39 |
+
history: List[ChatMessage]
|
| 40 |
+
user_input: str
|
| 41 |
+
user_mbti: str
|
| 42 |
+
target_mbti: str
|
| 43 |
+
relationship: str
|
| 44 |
+
situation: str
|
| 45 |
+
|
| 46 |
+
class ChatResponse(BaseModel):
|
| 47 |
+
history: List[ChatMessage]
|
| 48 |
+
coaching_tip: str
|
| 49 |
+
|
| 50 |
+
class SimulateRequest(BaseModel):
|
| 51 |
+
my_mbti: str
|
| 52 |
+
target_mbti_input: str
|
| 53 |
+
situation: str
|
| 54 |
+
relationship: str
|
| 55 |
+
advice_text: str
|
| 56 |
+
|
| 57 |
+
class SimulateResponse(BaseModel):
|
| 58 |
+
reaction: str
|
backend/services/classifier.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tensorflow as tf
|
| 2 |
+
import re
|
| 3 |
+
import numpy as np
|
| 4 |
+
import os
|
| 5 |
+
from transformers import AutoTokenizer, TFDistilBertForSequenceClassification
|
| 6 |
+
|
| 7 |
+
class MBTIClassifier:
|
| 8 |
+
def __init__(self, base_model_dir):
|
| 9 |
+
"""
|
| 10 |
+
4개 바이너리 모델을 통합한 하이브리드 MBTI 분류기
|
| 11 |
+
:param base_model_dir: 4개 모델 폴더(ie, ns, tf, jp)가 들어있는 최상위 디렉토리
|
| 12 |
+
"""
|
| 13 |
+
print(f"DEBUG: 하이브리드 엔진 통합 가동 중... ({base_model_dir})")
|
| 14 |
+
|
| 15 |
+
# 4개 지표 모델 폴더명 정의
|
| 16 |
+
self.axis_map = {
|
| 17 |
+
'ie': 'mbti_model_ie',
|
| 18 |
+
'ns': 'mbti_model_ns',
|
| 19 |
+
'tf': 'mbti_model_tf',
|
| 20 |
+
'jp': 'mbti_model_jp'
|
| 21 |
+
}
|
| 22 |
+
self.model_names = list(self.axis_map.keys())
|
| 23 |
+
self.models = {}
|
| 24 |
+
|
| 25 |
+
# 1. 토크나이저는 하나만 로드하여 공유 (메모리 최적화)
|
| 26 |
+
# 첫 번째 모델 폴더에서 토크나이저 로드
|
| 27 |
+
first_model_path = os.path.join(base_model_dir, self.axis_map[self.model_names[0]])
|
| 28 |
+
self.tokenizer = AutoTokenizer.from_pretrained(first_model_path)
|
| 29 |
+
|
| 30 |
+
# 2. 4개의 독립 모델 로드
|
| 31 |
+
for name, folder in self.axis_map.items():
|
| 32 |
+
model_path = os.path.join(base_model_dir, folder)
|
| 33 |
+
print(f"DEBUG: '{name.upper()}' 전문 모델 로딩... ({model_path})")
|
| 34 |
+
# Safetensors(PyTorch) 모델을 TF로 로드하기 위해 from_pt=True 적용
|
| 35 |
+
self.models[name] = TFDistilBertForSequenceClassification.from_pretrained(model_path, from_pt=True)
|
| 36 |
+
|
| 37 |
+
# 3. 지표별 라벨 매핑 (알파벳 순서: 0=Primary, 1=Secondary)
|
| 38 |
+
# E:0, I:1 / N:0, S:1 / F:0, T:1 / J:0, P:1
|
| 39 |
+
self.labels = {
|
| 40 |
+
'ie': ['E', 'I'],
|
| 41 |
+
'ns': ['N', 'S'],
|
| 42 |
+
'tf': ['F', 'T'],
|
| 43 |
+
'jp': ['J', 'P']
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# 4. 전체 16가지 유형 리스트 (합성용)
|
| 47 |
+
self.all_types = [
|
| 48 |
+
'ENFJ', 'ENFP', 'ENTJ', 'ENTP', 'ESFJ', 'ESFP', 'ESTJ', 'ESTP',
|
| 49 |
+
'INFJ', 'INFP', 'INTJ', 'INTP', 'ISFJ', 'ISFP', 'ISTJ', 'ISTP'
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
def _clean_text(self, text):
|
| 53 |
+
# 텍스트 전처리: 소문자화 및 URL 제거 등
|
| 54 |
+
text = text.lower()
|
| 55 |
+
text = re.sub(r'http\S+|www.\S+', '', text)
|
| 56 |
+
return text
|
| 57 |
+
|
| 58 |
+
def predict(self, text):
|
| 59 |
+
cleaned = self._clean_text(text)
|
| 60 |
+
inputs = self.tokenizer(
|
| 61 |
+
[cleaned],
|
| 62 |
+
truncation=True,
|
| 63 |
+
padding=True,
|
| 64 |
+
max_length=256,
|
| 65 |
+
return_tensors="tf"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# 1. 4개 모델 각각 예측 수행 및 확률 추출
|
| 69 |
+
axis_probs = {}
|
| 70 |
+
result_mbti = ""
|
| 71 |
+
|
| 72 |
+
for name in self.model_names:
|
| 73 |
+
# DistilBERT는 token_type_ids를 지원하지 않으므로 필터링 후 전달
|
| 74 |
+
filtered_inputs = {k: v for k, v in inputs.items() if k != 'token_type_ids'}
|
| 75 |
+
outputs = self.models[name](filtered_inputs)
|
| 76 |
+
# Softmax를 통해 해당 축의 확률 계산 (예: [P(E), P(I)])
|
| 77 |
+
# [[logit_0, logit_1]] -> softmax -> [[p_0, p_1]]
|
| 78 |
+
probs = tf.nn.softmax(outputs.logits, axis=-1).numpy()[0]
|
| 79 |
+
axis_probs[name] = probs
|
| 80 |
+
|
| 81 |
+
# 더 높은 확률의 문자를 결과에 추가
|
| 82 |
+
best_idx = np.argmax(probs)
|
| 83 |
+
result_mbti += self.labels[name][best_idx]
|
| 84 |
+
|
| 85 |
+
# 2. 16유형 확률 합성 (Probabilistic Synthesis)
|
| 86 |
+
# 개별 축의 확률이 독립적이라고 가정하고 곱함
|
| 87 |
+
# P(MBTI) = P(Axis1) * P(Axis2) * P(Axis3) * P(Axis4)
|
| 88 |
+
full_probabilities = {}
|
| 89 |
+
for mbti in self.all_types:
|
| 90 |
+
# 각 자리 문자에 해당하는 확률을 찾아 곱함
|
| 91 |
+
p_ie = axis_probs['ie'][0 if mbti[0]=='E' else 1]
|
| 92 |
+
p_ns = axis_probs['ns'][0 if mbti[1]=='N' else 1]
|
| 93 |
+
p_tf = axis_probs['tf'][0 if mbti[2]=='F' else 1]
|
| 94 |
+
p_jp = axis_probs['jp'][0 if mbti[3]=='J' else 1]
|
| 95 |
+
|
| 96 |
+
full_probabilities[mbti] = float(p_ie * p_ns * p_tf * p_jp)
|
| 97 |
+
|
| 98 |
+
# 3. 최종 신뢰도 (조합된 결과의 확률)
|
| 99 |
+
confidence = full_probabilities[result_mbti]
|
| 100 |
+
|
| 101 |
+
return {
|
| 102 |
+
"mbti": result_mbti,
|
| 103 |
+
"confidence": confidence,
|
| 104 |
+
"probabilities": full_probabilities
|
| 105 |
+
}
|
backend/services/llm_service.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import asyncio
|
| 6 |
+
import httpx
|
| 7 |
+
import PIL.Image
|
| 8 |
+
from typing import Optional, List, Union
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from google import genai
|
| 11 |
+
from google.genai import types
|
| 12 |
+
|
| 13 |
+
# 환경 변수 로드
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
# 로깅 설정
|
| 17 |
+
logging.basicConfig(
|
| 18 |
+
level=logging.INFO,
|
| 19 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 20 |
+
datefmt="%Y-%m-%d %H:%M:%S"
|
| 21 |
+
)
|
| 22 |
+
logger = logging.getLogger("LLMService")
|
| 23 |
+
|
| 24 |
+
class LLMService:
|
| 25 |
+
def __init__(self, model_name: str = "gemma4:latest"):
|
| 26 |
+
self.provider = os.getenv("LLM_PROVIDER", "ollama").lower()
|
| 27 |
+
|
| 28 |
+
# Ollama 설정
|
| 29 |
+
self.ollama_url = "http://localhost:11434/api/generate"
|
| 30 |
+
self.ollama_model = model_name
|
| 31 |
+
|
| 32 |
+
# Google AI 설정
|
| 33 |
+
self.google_api_key = os.getenv("GOOGLE_API_KEY")
|
| 34 |
+
self.google_model = os.getenv("GOOGLE_MODEL_NAME", "models/gemma-4-31b")
|
| 35 |
+
|
| 36 |
+
self.client = None
|
| 37 |
+
if self.provider == "google" and self.google_api_key:
|
| 38 |
+
try:
|
| 39 |
+
self.client = genai.Client(api_key=self.google_api_key)
|
| 40 |
+
logger.info(f"Gemma 4 엔진 초기화 완료 ({self.google_model})")
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Gemma 4 엔진 초기화 실패: {e}")
|
| 43 |
+
self.provider = "ollama"
|
| 44 |
+
|
| 45 |
+
if self.provider == "ollama":
|
| 46 |
+
logger.info(f"Ollama 모드로 작동 중 ({self.ollama_model})")
|
| 47 |
+
|
| 48 |
+
async def generate_response(self, user_mbti, target_mbti, situation, relationship, vibe, user_input):
|
| 49 |
+
"""상대방의 말에 대한 최적의 답변과 반응 예측 생성"""
|
| 50 |
+
system_instruction = "너는 MBTI 전문가이자 심리 상담가야. 제공된 상황에 맞춰 상대방과 원활한 대화를 이끌어 나갈 수 있는 전략적 답변을 제안해."
|
| 51 |
+
|
| 52 |
+
prompt = f"""
|
| 53 |
+
[상황 정보]
|
| 54 |
+
- 나의 MBTI: {user_mbti}
|
| 55 |
+
- 상대방의 예상 MBTI: {target_mbti}
|
| 56 |
+
- 대화 상황: {situation}
|
| 57 |
+
- 관계 및 호감도: {relationship} (호감도: {vibe})
|
| 58 |
+
|
| 59 |
+
[상대방의 메시지]
|
| 60 |
+
"{user_input}"
|
| 61 |
+
|
| 62 |
+
[지침]
|
| 63 |
+
1. 수식 기호나 LaTeX 형식($...$)을 절대 사용하지 마십시오.
|
| 64 |
+
2. 단계 구분 시 유니코드 기호(→, ➜, ✔)를 사용하십시오.
|
| 65 |
+
3. {target_mbti} 성향 맞춤형 대화 리드법을 제시하십시오.
|
| 66 |
+
4. 상대방이 보일 수 있는 구체적인 예상 반응(Reaction) 3가지를 예측하십시오.
|
| 67 |
+
5. {user_mbti}로서 가장 자연스럽게 호감을 얻을 수 있는 조언을 포함하십시오.
|
| 68 |
+
6. 바로 사용 가능한 답변 예시를 2-3개 작성하십시오.
|
| 69 |
+
7. 답변은 마크다운 형식을 활용하되, 강조 기호(예: 강조)는 가독성을 해치므로 절대 사용하지 말고 줄바꿈과 유니코드 기호로만 문단을 구분하십시오.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
return await self._call_llm(system_instruction, prompt)
|
| 73 |
+
|
| 74 |
+
async def analyze_mbti_with_reasoning(self, context, user_input):
|
| 75 |
+
"""상황과 메시지를 분석하여 MBTI 추론"""
|
| 76 |
+
system_instruction = "너는 예리한 심리학자이자 MBTI 분석 전문가야. 텍스트 단서를 바탕으로 성격 유형을 논리적으로 추론해."
|
| 77 |
+
|
| 78 |
+
prompt = f"""
|
| 79 |
+
[상세 상황 및 데이터]
|
| 80 |
+
{context}
|
| 81 |
+
|
| 82 |
+
[상대방의 메시지]
|
| 83 |
+
"{user_input}"
|
| 84 |
+
|
| 85 |
+
[지침]
|
| 86 |
+
1. 제공된 [딥러닝 모델 측정값]이 있다면, 해당 수치를 심리학적으로 해석하십시오. (예: I 성향 80%라면 고립된 사고가 아닌 깊은 성찰의 특징으로 해석 등)
|
| 87 |
+
2. {user_input} 문장에서 해당 MBTI와 일치하는 구체적인 언어적 단서(어미, 단어, 뉘앙스)를 찾아 분석하십시오.
|
| 88 |
+
3. 최종적으로 가장 확률이 높은 단 하나의 MBTI 유형을 결론으로 내십시오.
|
| 89 |
+
4. "데이터 분석 결과"와 "언어적 특징 근거" 섹션을 나누어 작성하십시오.
|
| 90 |
+
5. 마크다운 형식을 사용하되, 강조를 위한 별표(**)는 절대 사용하지 마십시오.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
return await self._call_llm(system_instruction, prompt)
|
| 94 |
+
|
| 95 |
+
async def extract_text_from_image(self, image_input: Union[str, PIL.Image.Image]) -> str:
|
| 96 |
+
"""이미지(캡처본)에서 대화 텍스트 추출 (Gemma 4 Vision 활용)"""
|
| 97 |
+
if self.provider != "google" or not self.client:
|
| 98 |
+
return "OCR 기능은 Gemma 4 API 설정이 필요합니다."
|
| 99 |
+
|
| 100 |
+
system_instruction = "너는 텍스트 추출 및 OCR 전문가야. 대화 캡처 이미지에서 대화 내용만 정확하게 추출해."
|
| 101 |
+
prompt = "이미지 속의 대화 텍스트를 모두 추출해줘. 화자와 메시지 내용을 구분해서 출력해줘. 별도의 설명은 하지 말고 텍스트만 출력해."
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
# 이미지 로드 (경로일 경�� PIL로 오픈)
|
| 105 |
+
if isinstance(image_input, str):
|
| 106 |
+
img = PIL.Image.open(image_input)
|
| 107 |
+
else:
|
| 108 |
+
img = image_input
|
| 109 |
+
|
| 110 |
+
loop = asyncio.get_event_loop()
|
| 111 |
+
response = await loop.run_in_executor(
|
| 112 |
+
None,
|
| 113 |
+
lambda: self.client.models.generate_content(
|
| 114 |
+
model=self.google_model,
|
| 115 |
+
contents=[img, prompt],
|
| 116 |
+
config={'system_instruction': system_instruction}
|
| 117 |
+
)
|
| 118 |
+
)
|
| 119 |
+
return response.text.strip()
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Image OCR Error: {e}")
|
| 122 |
+
return f"이미지 분석 중 오류가 발생했습니다: {str(e)}"
|
| 123 |
+
|
| 124 |
+
async def simulate_reaction(self, user_mbti, target_mbti, situation, relationship, response_given):
|
| 125 |
+
"""제안된 답변을 보냈을 때 상대방(target_mbti)의 반응 시뮬레이션"""
|
| 126 |
+
system_instruction = f"너는 {target_mbti} 성향을 가진 사람이야. 상대방의 메시지에 대해 너의 성격대로 반응해봐."
|
| 127 |
+
|
| 128 |
+
prompt = f"""
|
| 129 |
+
[상황 정보]
|
| 130 |
+
- 나의 MBTI: {target_mbti} (분석된 성향)
|
| 131 |
+
- 상대방의 MBTI: {user_mbti}
|
| 132 |
+
- 우리 관계: {relationship}
|
| 133 |
+
- 상황: {situation}
|
| 134 |
+
|
| 135 |
+
[상대방이 보낸 메시지]
|
| 136 |
+
"{response_given}"
|
| 137 |
+
|
| 138 |
+
[지침]
|
| 139 |
+
1. {target_mbti}의 전형적인 사고 방식과 말투로 응답하십시오.
|
| 140 |
+
2. 속마음(생각)과 겉으로 하는 말(대화)을 구분해서 보여주십시오.
|
| 141 |
+
3. 이 메시지를 받았을 때의 감정 변화를 유니코드 기호와 함께 간략히 적어주십시오.
|
| 142 |
+
4. 마크다운 형식을 사용하되 강조 기호(**)는 사용하지 마십시오.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
return await self._call_llm(system_instruction, prompt)
|
| 146 |
+
|
| 147 |
+
async def chat_with_persona(self, history, user_input, user_mbti, target_mbti, relationship, situation):
|
| 148 |
+
"""특정 MBTI 페르소나와 대화 수행 (히스토리 포함)"""
|
| 149 |
+
system_instruction = f"""
|
| 150 |
+
너는 {target_mbti} 성향을 가진 사람이고, 상대방({user_mbti})과는 {relationship} 관계야.
|
| 151 |
+
현재 상황은 {situation}이야.
|
| 152 |
+
|
| 153 |
+
[지침]
|
| 154 |
+
1. {target_mbti}의 전형적인 말투, 단어 선택, 반응 스타일을 완벽하게 모사해.
|
| 155 |
+
2. 대화의 맥락(History)을 유지하며 자연스럽게 대화해.
|
| 156 |
+
3. 너무 장황하거나 가르치려 들지 말고, 실제 메신저 대화처럼 간결하고 생동감 있게 대답해.
|
| 157 |
+
4. 별표(**)와 같은 강조 기호는 가독성을 위해 절대 사용하지 마.
|
| 158 |
+
5. 수식 기호나 LaTeX 형식($...$)을 사용하지 마.
|
| 159 |
+
"""
|
| 160 |
+
|
| 161 |
+
if self.provider == "google" and self.client:
|
| 162 |
+
try:
|
| 163 |
+
# Gradio 6+ 히스토리(dict)를 처리하여 텍스트 컨텍스트로 변환
|
| 164 |
+
full_context = ""
|
| 165 |
+
for msg in history:
|
| 166 |
+
role = msg.get("role")
|
| 167 |
+
content = msg.get("content")
|
| 168 |
+
if role == "user":
|
| 169 |
+
full_context += f"사용자: {content}\n"
|
| 170 |
+
elif role == "assistant":
|
| 171 |
+
full_context += f"나({target_mbti}): {content}\n"
|
| 172 |
+
|
| 173 |
+
prompt = f"{full_context}사용자: {user_input}\n나({target_mbti}):"
|
| 174 |
+
|
| 175 |
+
return await self._generate_via_google(system_instruction, prompt)
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"Chat Error: {e}")
|
| 178 |
+
return "대화 중 오류가 발생했습니다."
|
| 179 |
+
else:
|
| 180 |
+
# Ollama용 히스토리 결합
|
| 181 |
+
full_context = ""
|
| 182 |
+
for msg in history:
|
| 183 |
+
role = msg.get("role")
|
| 184 |
+
content = msg.get("content")
|
| 185 |
+
if role == "user":
|
| 186 |
+
full_context += f"User: {content}\n"
|
| 187 |
+
elif role == "assistant":
|
| 188 |
+
full_context += f"Assistant: {content}\n"
|
| 189 |
+
|
| 190 |
+
prompt = f"{system_instruction}\n\n{full_context}User: {user_input}\nAssistant:"
|
| 191 |
+
return await self._generate_via_ollama(prompt)
|
| 192 |
+
|
| 193 |
+
prompt = f"{system_instruction}\n\n{full_context}User: {user_input}\nAssistant:"
|
| 194 |
+
return await self._generate_via_ollama(prompt)
|
| 195 |
+
|
| 196 |
+
async def generate_initial_greeting(self, target_mbti, relationship, situation):
|
| 197 |
+
"""설정된 상황에 맞춰 AI가 건넬 첫 인사 생성"""
|
| 198 |
+
system_instruction = f"너는 {target_mbti} 성향을 가진 사람이고, 상대방과는 {relationship} 관계야."
|
| 199 |
+
prompt = f"현재 상황은 '{situation}'이야. 이 상황에서 {target_mbti}답게 상대방에게 건넬 수 있는 자연스러운 첫 인사나 말을 한 문장으로 해줘. 별도의 설명이나 인사말 없이 실제 대사만 출력해."
|
| 200 |
+
return await self._call_llm(system_instruction, prompt)
|
| 201 |
+
|
| 202 |
+
async def get_coaching_tip(self, user_input, target_mbti, relationship):
|
| 203 |
+
"""사용자 메시지에 대한 실시간 코칭 팁 생성"""
|
| 204 |
+
system_instruction = "너는 세계 최고의 커뮤니케이션 전문가이자 심리 상담가야."
|
| 205 |
+
prompt = f"""
|
| 206 |
+
[대화 상황]
|
| 207 |
+
사용자가 {target_mbti} 성향의 사람({relationship} 관계)에게 다음과 같은 메시지를 보냈어:
|
| 208 |
+
"{user_input}"
|
| 209 |
+
|
| 210 |
+
[분석 및 조언 지침]
|
| 211 |
+
1. {target_mbti}의 성향을 고려할 때 이 메시지가 상대방에게 어떻게 느껴질지 예리하게 분석하십시오.
|
| 212 |
+
2. 상대방의 호감을 얻거나 대화를 원활하게 이어가기 위한 구체적인 수정 제안이나 팁을 제시하십시오.
|
| 213 |
+
3. 아주 정중하고 부드러운 전문 조언자 톤을 유지하십시오.
|
| 214 |
+
4. 결과는 마크다운 형식을 사용하되, 강조 기호(**)는 절대 사용하지 마십시오. 유니코드 기호(➜, ✔)를 활용하십시오.
|
| 215 |
+
"""
|
| 216 |
+
return await self._call_llm(system_instruction, prompt)
|
| 217 |
+
|
| 218 |
+
async def _call_llm(self, system_text, user_text):
|
| 219 |
+
"""공통 LLM 호출 인터페이스 (비동기)"""
|
| 220 |
+
if self.provider == "google" and self.client:
|
| 221 |
+
return await self._generate_via_google(system_text, user_text)
|
| 222 |
+
else:
|
| 223 |
+
# Ollama는 시스템 프롬프트를 일반 프롬프트 앞에 결합하여 전송
|
| 224 |
+
full_prompt = f"{system_text}\n\n{user_text}"
|
| 225 |
+
return await self._generate_via_ollama(full_prompt)
|
| 226 |
+
|
| 227 |
+
async def _generate_via_google(self, system_text, user_text):
|
| 228 |
+
try:
|
| 229 |
+
# Google SDK의 비동기 호출 (Thread 사용 혹은 직접 호출)
|
| 230 |
+
# 현재 SDK 버전에 따라 동기 함수일 경우를 대비해 run_in_executor 사용 가능
|
| 231 |
+
loop = asyncio.get_event_loop()
|
| 232 |
+
response = await loop.run_in_executor(
|
| 233 |
+
None,
|
| 234 |
+
lambda: self.client.models.generate_content(
|
| 235 |
+
model=self.google_model,
|
| 236 |
+
contents=user_text,
|
| 237 |
+
config={'system_instruction': system_text}
|
| 238 |
+
)
|
| 239 |
+
)
|
| 240 |
+
return self._clean_response(response.text)
|
| 241 |
+
except Exception as e:
|
| 242 |
+
logger.error(f"Gemma 4 호출 에러: {e}")
|
| 243 |
+
return "서비스 일시 점검 중입니다. (Gemma 4 API Error)"
|
| 244 |
+
|
| 245 |
+
async def _generate_via_ollama(self, prompt):
|
| 246 |
+
payload = {
|
| 247 |
+
"model": self.ollama_model,
|
| 248 |
+
"prompt": prompt,
|
| 249 |
+
"stream": False
|
| 250 |
+
}
|
| 251 |
+
try:
|
| 252 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 253 |
+
response = await client.post(self.ollama_url, json=payload)
|
| 254 |
+
response.raise_for_status()
|
| 255 |
+
result = response.json().get('response', '')
|
| 256 |
+
return self._clean_response(result)
|
| 257 |
+
except Exception as e:
|
| 258 |
+
logger.error(f"Ollama 호출 에러: {e}")
|
| 259 |
+
return "로컬 엔진 연결에 실패했습니다. Ollama 실행 여부를 확인하세요."
|
| 260 |
+
|
| 261 |
+
def _clean_response(self, text: str) -> str:
|
| 262 |
+
"""응답 데이터 정제 (Regex 활용)"""
|
| 263 |
+
if not text:
|
| 264 |
+
return ""
|
| 265 |
+
|
| 266 |
+
# 1. 별표(**) 강조 기호 제거 (정규표현식)
|
| 267 |
+
text = re.sub(r'\*\*', '', text)
|
| 268 |
+
|
| 269 |
+
# 2. LaTeX 및 특수 기호 치환
|
| 270 |
+
replacements = {
|
| 271 |
+
r"\$\\rightarrow\$": " → ",
|
| 272 |
+
r"\\rightarrow": " → ",
|
| 273 |
+
r"\$\\leftarrow\$": " ← ",
|
| 274 |
+
r"\\leftarrow": " ← ",
|
| 275 |
+
r"\$\\Rightarrow\$": " ⇒ ",
|
| 276 |
+
r"\\Rightarrow": " ⇒ ",
|
| 277 |
+
r"\$\\checkmark\$": " ✔ ",
|
| 278 |
+
r"\\checkmark": " ✔ ",
|
| 279 |
+
r"\\text\{.*?\}": lambda m: m.group(0)[6:-1], # \text{내용} -> 내용
|
| 280 |
+
r"\\": "" # 남은 백슬래시 제거
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
for pattern, replacement in replacements.items():
|
| 284 |
+
if callable(replacement):
|
| 285 |
+
text = re.sub(pattern, replacement, text)
|
| 286 |
+
else:
|
| 287 |
+
text = text.replace(pattern.replace("\\", ""), replacement) if "\\" in pattern else text.replace(pattern, replacement)
|
| 288 |
+
# 단순 replace 보완을 위해 다시 한번 처리
|
| 289 |
+
text = re.sub(pattern, replacement, text)
|
| 290 |
+
|
| 291 |
+
return text.strip()
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
frontend/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"clsx": "^2.1.1",
|
| 13 |
+
"lucide-react": "^1.8.0",
|
| 14 |
+
"next": "16.2.3",
|
| 15 |
+
"react": "19.2.4",
|
| 16 |
+
"react-dom": "19.2.4",
|
| 17 |
+
"recharts": "^3.8.1",
|
| 18 |
+
"tailwind-merge": "^3.5.0"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@tailwindcss/postcss": "^4",
|
| 22 |
+
"@types/node": "^20",
|
| 23 |
+
"@types/react": "^19",
|
| 24 |
+
"@types/react-dom": "^19",
|
| 25 |
+
"eslint": "^9",
|
| 26 |
+
"eslint-config-next": "16.2.3",
|
| 27 |
+
"tailwindcss": "^4",
|
| 28 |
+
"typescript": "^5"
|
| 29 |
+
}
|
| 30 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/public/file.svg
ADDED
|
|
frontend/public/globe.svg
ADDED
|
|
frontend/public/next.svg
ADDED
|
|
frontend/public/vercel.svg
ADDED
|
|
frontend/public/window.svg
ADDED
|
|
frontend/src/app/favicon.ico
ADDED
|
|
frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #f8fafc;
|
| 5 |
+
--foreground: #0f172a;
|
| 6 |
+
--primary: #4f46e5;
|
| 7 |
+
--primary-hover: #4338ca;
|
| 8 |
+
--secondary: #10b981;
|
| 9 |
+
--accent: #f59e0b;
|
| 10 |
+
--card-bg: rgba(255, 255, 255, 0.8);
|
| 11 |
+
--glass-bg: rgba(255, 255, 255, 0.7);
|
| 12 |
+
--border: #e2e8f0;
|
| 13 |
+
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
| 14 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
@media (prefers-color-scheme: dark) {
|
| 18 |
+
:root {
|
| 19 |
+
--background: #0f172a;
|
| 20 |
+
--foreground: #f8fafc;
|
| 21 |
+
--primary: #6366f1;
|
| 22 |
+
--card-bg: rgba(30, 41, 59, 0.8);
|
| 23 |
+
--glass-bg: rgba(15, 23, 42, 0.7);
|
| 24 |
+
--border: #334155;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
body {
|
| 29 |
+
background-color: var(--background);
|
| 30 |
+
color: var(--foreground);
|
| 31 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 32 |
+
overflow-x: hidden;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.premium-gradient {
|
| 36 |
+
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.glass {
|
| 40 |
+
background: var(--glass-bg);
|
| 41 |
+
backdrop-filter: blur(12px);
|
| 42 |
+
-webkit-backdrop-filter: blur(12px);
|
| 43 |
+
border: 1px solid var(--border);
|
| 44 |
+
box-shadow: var(--shadow);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.card {
|
| 48 |
+
background: var(--card-bg);
|
| 49 |
+
border-radius: 1rem;
|
| 50 |
+
padding: 1.5rem;
|
| 51 |
+
border: 1px solid var(--border);
|
| 52 |
+
box-shadow: var(--shadow);
|
| 53 |
+
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.card:hover {
|
| 57 |
+
transform: translateY(-2px);
|
| 58 |
+
box-shadow: var(--shadow-lg);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.btn-primary {
|
| 62 |
+
background-color: var(--primary);
|
| 63 |
+
color: white;
|
| 64 |
+
padding: 0.75rem 1.5rem;
|
| 65 |
+
border-radius: 0.5rem;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
transition: all 0.2s;
|
| 68 |
+
display: inline-flex;
|
| 69 |
+
align-items: center;
|
| 70 |
+
justify-content: center;
|
| 71 |
+
gap: 0.5rem;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.btn-primary:hover {
|
| 75 |
+
background-color: var(--primary-hover);
|
| 76 |
+
transform: scale(1.02);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.btn-secondary {
|
| 80 |
+
background-color: transparent;
|
| 81 |
+
border: 1px solid var(--border);
|
| 82 |
+
padding: 0.75rem 1.5rem;
|
| 83 |
+
border-radius: 0.5rem;
|
| 84 |
+
font-weight: 600;
|
| 85 |
+
transition: all 0.2s;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.btn-secondary:hover {
|
| 89 |
+
background-color: var(--border);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* Animations */
|
| 93 |
+
@keyframes fadeIn {
|
| 94 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 95 |
+
to { opacity: 1; transform: translateY(0); }
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.animate-fade-in {
|
| 99 |
+
animation: fadeIn 0.5s ease-out forwards;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* Custom Scrollbar */
|
| 103 |
+
::-webkit-scrollbar {
|
| 104 |
+
width: 8px;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
::-webkit-scrollbar-track {
|
| 108 |
+
background: transparent;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
::-webkit-scrollbar-thumb {
|
| 112 |
+
background: var(--border);
|
| 113 |
+
border-radius: 4px;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
::-webkit-scrollbar-thumb:hover {
|
| 117 |
+
background: #94a3b8;
|
| 118 |
+
}
|
frontend/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const geistSans = Geist({
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const geistMono = Geist_Mono({
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "CommuniKate | MBTI AI 소통 전문가",
|
| 17 |
+
description: "딥러닝 분석과 AI 코칭을 통한 MBTI 맞춤형 소통 가이드",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html
|
| 27 |
+
lang="en"
|
| 28 |
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 29 |
+
>
|
| 30 |
+
<body className="min-h-full flex flex-col">{children}</body>
|
| 31 |
+
</html>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/src/app/page.tsx
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { MessageSquare, BarChart3, Settings2, Sparkles, Send, Image as ImageIcon, Loader2 } from "lucide-react";
|
| 5 |
+
import MbtiChart from "@/components/MbtiChart";
|
| 6 |
+
import { post, uploadImage } from "@/lib/api";
|
| 7 |
+
|
| 8 |
+
export default function Home() {
|
| 9 |
+
const [activeTab, setActiveTab] = useState<"analysis" | "chat">("analysis");
|
| 10 |
+
|
| 11 |
+
// Analysis State
|
| 12 |
+
const [formData, setFormData] = useState({
|
| 13 |
+
my_mbti: "ENTJ",
|
| 14 |
+
target_mbti_input: "자동 분석 (AI)",
|
| 15 |
+
situation: "일상 대화",
|
| 16 |
+
relationship: "친구",
|
| 17 |
+
vibe: "호감/긍정적 😊",
|
| 18 |
+
context_detail: "",
|
| 19 |
+
target_text: "",
|
| 20 |
+
});
|
| 21 |
+
const [analysisResult, setAnalysisResult] = useState<any>(null);
|
| 22 |
+
const [loading, setLoading] = useState(false);
|
| 23 |
+
const [loadingStatus, setLoadingStatus] = useState("");
|
| 24 |
+
const [ocrLoading, setOcrLoading] = useState(false);
|
| 25 |
+
|
| 26 |
+
// Chat State
|
| 27 |
+
const [chatSettings, setChatSettings] = useState({
|
| 28 |
+
user_mbti: "ENTJ",
|
| 29 |
+
target_mbti: "INFP",
|
| 30 |
+
relationship: "직장 동료/상사",
|
| 31 |
+
situation: "업무 지시를 받는 중이거나 피드백을 요구하는 상황",
|
| 32 |
+
ai_first: true,
|
| 33 |
+
});
|
| 34 |
+
const [chatHistory, setChatHistory] = useState<any[]>([]);
|
| 35 |
+
const [chatInput, setChatInput] = useState("");
|
| 36 |
+
const [coachingTip, setCoachingTip] = useState("");
|
| 37 |
+
const [chatLoading, setChatLoading] = useState(false);
|
| 38 |
+
const [reactionMsg, setReactionMsg] = useState("");
|
| 39 |
+
|
| 40 |
+
const handleAnalysisSubmit = async () => {
|
| 41 |
+
setLoading(true);
|
| 42 |
+
setLoadingStatus("메시지 속 성향 분석 중...");
|
| 43 |
+
try {
|
| 44 |
+
setTimeout(() => setLoadingStatus("최적의 답변 레시피 생성 중..."), 2000);
|
| 45 |
+
const result = await post("/analyze", formData);
|
| 46 |
+
setLoadingStatus("완료!");
|
| 47 |
+
setAnalysisResult(result);
|
| 48 |
+
} catch (error: any) {
|
| 49 |
+
alert(error.message);
|
| 50 |
+
} finally {
|
| 51 |
+
setLoading(false);
|
| 52 |
+
setLoadingStatus("");
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleImageOCR = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 57 |
+
if (!e.target.files?.[0]) return;
|
| 58 |
+
setOcrLoading(true);
|
| 59 |
+
try {
|
| 60 |
+
const result = await uploadImage(e.target.files[0]);
|
| 61 |
+
setFormData({ ...formData, target_text: result.text });
|
| 62 |
+
} catch (error: any) {
|
| 63 |
+
alert("이미지 읽기 실패: " + error.message);
|
| 64 |
+
} finally {
|
| 65 |
+
setOcrLoading(false);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const startChat = async () => {
|
| 70 |
+
setChatLoading(true);
|
| 71 |
+
try {
|
| 72 |
+
const result = await post("/chat/start", chatSettings);
|
| 73 |
+
setChatHistory(result.history);
|
| 74 |
+
setCoachingTip(result.coaching_tip);
|
| 75 |
+
} catch (error: any) {
|
| 76 |
+
alert(error.message);
|
| 77 |
+
} finally {
|
| 78 |
+
setChatLoading(false);
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
const sendChatMessage = async () => {
|
| 83 |
+
if (!chatInput.trim()) return;
|
| 84 |
+
setChatLoading(true);
|
| 85 |
+
const userInput = chatInput;
|
| 86 |
+
setChatInput("");
|
| 87 |
+
try {
|
| 88 |
+
const result = await post("/chat", {
|
| 89 |
+
...chatSettings,
|
| 90 |
+
history: chatHistory,
|
| 91 |
+
user_input: userInput,
|
| 92 |
+
});
|
| 93 |
+
setChatHistory(result.history);
|
| 94 |
+
setCoachingTip(result.coaching_tip);
|
| 95 |
+
} catch (error: any) {
|
| 96 |
+
alert(error.message);
|
| 97 |
+
} finally {
|
| 98 |
+
setChatLoading(false);
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
const simulateReaction = async () => {
|
| 103 |
+
if (!analysisResult?.advice) return;
|
| 104 |
+
setLoading(true);
|
| 105 |
+
try {
|
| 106 |
+
const result = await post("/simulate", {
|
| 107 |
+
my_mbti: formData.my_mbti,
|
| 108 |
+
target_mbti_input: analysisResult.analysis_summary.includes("기 분석 결과") ? "자동 분석" : formData.target_mbti_input,
|
| 109 |
+
situation: formData.situation,
|
| 110 |
+
relationship: formData.relationship,
|
| 111 |
+
advice_text: analysisResult.advice,
|
| 112 |
+
});
|
| 113 |
+
setReactionMsg(result.reaction);
|
| 114 |
+
} catch (error: any) {
|
| 115 |
+
alert(error.message);
|
| 116 |
+
} finally {
|
| 117 |
+
setLoading(false);
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<main className="min-h-screen p-4 md:p-8 max-w-7xl mx-auto">
|
| 123 |
+
{/* Header */}
|
| 124 |
+
<header className="premium-gradient rounded-2xl p-8 mb-8 text-white shadow-xl animate-fade-in">
|
| 125 |
+
<h1 className="text-4xl font-extrabold mb-2 flex items-center gap-3">
|
| 126 |
+
<Sparkles className="w-8 h-8" />
|
| 127 |
+
CommuniKate: MBTI 소통 전문가
|
| 128 |
+
</h1>
|
| 129 |
+
<p className="text-white/80 text-lg">
|
| 130 |
+
정밀 성향 분석과 AI 코칭으로 당신의 대화를 최적화합니다.
|
| 131 |
+
</p>
|
| 132 |
+
</header>
|
| 133 |
+
|
| 134 |
+
{/* Tabs */}
|
| 135 |
+
<div className="flex gap-4 mb-8 bg-white/50 dark:bg-slate-900/50 p-1 rounded-xl w-fit glass">
|
| 136 |
+
<button
|
| 137 |
+
onClick={() => setActiveTab("analysis")}
|
| 138 |
+
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-bold transition-all ${
|
| 139 |
+
activeTab === "analysis" ? "bg-white dark:bg-slate-800 shadow-sm text-indigo-600" : "text-slate-500 hover:text-slate-700"
|
| 140 |
+
}`}
|
| 141 |
+
>
|
| 142 |
+
<BarChart3 className="w-5 h-5" />
|
| 143 |
+
분석 및 조언
|
| 144 |
+
</button>
|
| 145 |
+
<button
|
| 146 |
+
onClick={() => setActiveTab("chat")}
|
| 147 |
+
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-bold transition-all ${
|
| 148 |
+
activeTab === "chat" ? "bg-white dark:bg-slate-800 shadow-sm text-indigo-600" : "text-slate-500 hover:text-slate-700"
|
| 149 |
+
}`}
|
| 150 |
+
>
|
| 151 |
+
<MessageSquare className="w-5 h-5" />
|
| 152 |
+
연습하기 (AI 채팅)
|
| 153 |
+
</button>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 157 |
+
{activeTab === "analysis" ? (
|
| 158 |
+
<>
|
| 159 |
+
{/* Analysis Inputs */}
|
| 160 |
+
<div className="lg:col-span-4 space-y-6">
|
| 161 |
+
<section className="card space-y-4">
|
| 162 |
+
<h2 className="text-xl font-bold flex items-center gap-2 border-b pb-2">
|
| 163 |
+
<Settings2 className="w-5 h-5 text-indigo-500" />
|
| 164 |
+
설정 및 입력
|
| 165 |
+
</h2>
|
| 166 |
+
|
| 167 |
+
<div className="grid grid-cols-2 gap-4">
|
| 168 |
+
<div className="space-y-1">
|
| 169 |
+
<label className="text-xs font-semibold text-slate-500">나의 MBTI</label>
|
| 170 |
+
<select
|
| 171 |
+
value={formData.my_mbti}
|
| 172 |
+
onChange={(e) => setFormData({...formData, my_mbti: e.target.value})}
|
| 173 |
+
className="w-full p-2 rounded-lg border bg-white dark:bg-slate-800 outline-none focus:ring-2 focus:ring-indigo-500"
|
| 174 |
+
>
|
| 175 |
+
{["ISTJ", "ISFJ", "INFJ", "INTJ", "ISTP", "ISFP", "INFP", "INTP", "ESTP", "ESFP", "ENFP", "ENTP", "ESTJ", "ESFJ", "ENFJ", "ENTJ"].map(type => (
|
| 176 |
+
<option key={type} value={type}>{type}</option>
|
| 177 |
+
))}
|
| 178 |
+
</select>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="space-y-1">
|
| 181 |
+
<label className="text-xs font-semibold text-slate-500">상대방 MBTI</label>
|
| 182 |
+
<select
|
| 183 |
+
value={formData.target_mbti_input}
|
| 184 |
+
onChange={(e) => setFormData({...formData, target_mbti_input: e.target.value})}
|
| 185 |
+
className="w-full p-2 rounded-lg border bg-white dark:bg-slate-800 outline-none focus:ring-2 focus:ring-indigo-500"
|
| 186 |
+
>
|
| 187 |
+
<option value="자동 분석 (AI)">메시지로 분석하기</option>
|
| 188 |
+
{["ISTJ", "ISFJ", "INFJ", "INTJ", "ISTP", "ISFP", "INFP", "INTP", "ESTP", "ESFP", "ENFP", "ENTP", "ESTJ", "ESFJ", "ENFJ", "ENTJ"].map(type => (
|
| 189 |
+
<option key={type} value={type}>{type}</option>
|
| 190 |
+
))}
|
| 191 |
+
</select>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<div className="space-y-1">
|
| 196 |
+
<label className="text-xs font-semibold text-slate-500">대화 상황</label>
|
| 197 |
+
<select
|
| 198 |
+
value={formData.situation}
|
| 199 |
+
onChange={(e) => setFormData({...formData, situation: e.target.value})}
|
| 200 |
+
className="w-full p-2 rounded-lg border bg-white dark:bg-slate-800 outline-none focus:ring-2 focus:ring-indigo-500"
|
| 201 |
+
>
|
| 202 |
+
{["처음 만난 사이 (Ice-breaking)", "비즈니스 미팅", "갈등 상황", "호감 표현", "일상 대화"].map(s => (
|
| 203 |
+
<option key={s} value={s}>{s}</option>
|
| 204 |
+
))}
|
| 205 |
+
</select>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<div className="space-y-1">
|
| 209 |
+
<label className="text-xs font-semibold text-slate-500">메시지 내용</label>
|
| 210 |
+
<textarea
|
| 211 |
+
value={formData.target_text}
|
| 212 |
+
onChange={(e) => setFormData({...formData, target_text: e.target.value})}
|
| 213 |
+
placeholder="상대방이 보낸 메시지를 입력하거나 이미지를 업로드하세요."
|
| 214 |
+
className="w-full p-3 rounded-lg border bg-white dark:bg-slate-800 outline-none focus:ring-2 focus:ring-indigo-500 h-32 resize-none"
|
| 215 |
+
/>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
<div className="space-y-3 pt-2">
|
| 219 |
+
<button
|
| 220 |
+
onClick={handleAnalysisSubmit}
|
| 221 |
+
disabled={loading}
|
| 222 |
+
className="w-full btn-primary bg-indigo-600 py-4 shadow-lg shadow-indigo-200 dark:shadow-none"
|
| 223 |
+
>
|
| 224 |
+
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Sparkles className="w-5 h-5" />}
|
| 225 |
+
분석 및 조언 받기
|
| 226 |
+
</button>
|
| 227 |
+
|
| 228 |
+
<label className="block">
|
| 229 |
+
<div className="btn-secondary cursor-pointer flex items-center justify-center gap-2 py-3 border-dashed border-2 hover:border-indigo-400 hover:text-indigo-600 transition-all">
|
| 230 |
+
{ocrLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
|
| 231 |
+
대화 캡처본 불러오기
|
| 232 |
+
</div>
|
| 233 |
+
<input type="file" className="hidden" accept="image/*" onChange={handleImageOCR} />
|
| 234 |
+
</label>
|
| 235 |
+
</div>
|
| 236 |
+
</section>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
{/* Analysis Results */}
|
| 240 |
+
<div className="lg:col-span-8 space-y-6 relative">
|
| 241 |
+
{loading && (
|
| 242 |
+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/60 dark:bg-slate-900/60 backdrop-blur-sm rounded-2xl animate-fade-in">
|
| 243 |
+
<div className="bg-white dark:bg-slate-800 p-8 rounded-2xl shadow-2xl border flex flex-col items-center gap-4">
|
| 244 |
+
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin" />
|
| 245 |
+
<div className="text-center">
|
| 246 |
+
<p className="font-bold text-lg text-slate-800 dark:text-white">{loadingStatus}</p>
|
| 247 |
+
<p className="text-sm text-slate-500 mt-1">AI가 최적의 커뮤니케이션 전략을 짜고 있습니다...</p>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
{analysisResult ? (
|
| 253 |
+
<div className="animate-fade-in space-y-6">
|
| 254 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 255 |
+
<div className="card">
|
| 256 |
+
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
| 257 |
+
<BarChart3 className="w-5 h-5 text-emerald-500" />
|
| 258 |
+
분석 리포트
|
| 259 |
+
</h3>
|
| 260 |
+
<div className="prose prose-sm dark:prose-invert max-w-none mb-4 whitespace-pre-wrap">
|
| 261 |
+
{analysisResult.analysis_summary}
|
| 262 |
+
</div>
|
| 263 |
+
<MbtiChart data={analysisResult.axis_scores} />
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<div className="card bg-indigo-50/50 dark:bg-indigo-900/10 border-indigo-100 dark:border-indigo-900/50">
|
| 267 |
+
<h3 className="text-lg font-bold mb-4 flex items-center gap-2 text-indigo-600 dark:text-indigo-400">
|
| 268 |
+
<Sparkles className="w-5 h-5" />
|
| 269 |
+
솔루션 조언
|
| 270 |
+
</h3>
|
| 271 |
+
<div className="prose prose-indigo dark:prose-invert max-w-none whitespace-pre-wrap leading-relaxed">
|
| 272 |
+
{analysisResult.advice}
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
<button
|
| 276 |
+
onClick={simulateReaction}
|
| 277 |
+
className="mt-6 w-full btn-secondary text-indigo-600 border-indigo-200 bg-white"
|
| 278 |
+
>
|
| 279 |
+
🎭 상대방 반응 시뮬레이션
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
|
| 284 |
+
{reactionMsg && (
|
| 285 |
+
<div className="card border-amber-200 bg-amber-50/50 dark:bg-amber-900/10 dark:border-amber-900/50">
|
| 286 |
+
<h3 className="text-lg font-bold mb-2 flex items-center gap-2 text-amber-600">
|
| 287 |
+
💭 예상 반응 시뮬레이션 결과
|
| 288 |
+
</h3>
|
| 289 |
+
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
| 290 |
+
{reactionMsg}
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
</div>
|
| 295 |
+
) : (
|
| 296 |
+
<div className="card h-full flex flex-col items-center justify-center text-slate-400 py-20 border-dashed">
|
| 297 |
+
<BarChart3 className="w-16 h-16 mb-4 opacity-20" />
|
| 298 |
+
<p>왼쪽에서 정보를 입력하고 분석을 시작해보세요.</p>
|
| 299 |
+
</div>
|
| 300 |
+
)}
|
| 301 |
+
</div>
|
| 302 |
+
</>
|
| 303 |
+
) : (
|
| 304 |
+
<>
|
| 305 |
+
{/* Chat Settings */}
|
| 306 |
+
<div className="lg:col-span-3 space-y-6">
|
| 307 |
+
<section className="card space-y-4">
|
| 308 |
+
<h2 className="text-xl font-bold border-b pb-2 flex items-center gap-2">
|
| 309 |
+
<Settings2 className="w-5 h-5" />
|
| 310 |
+
채팅 설정
|
| 311 |
+
</h2>
|
| 312 |
+
|
| 313 |
+
<div className="space-y-1">
|
| 314 |
+
<label className="text-xs font-semibold text-slate-500">나의 MBTI</label>
|
| 315 |
+
<select
|
| 316 |
+
value={chatSettings.user_mbti}
|
| 317 |
+
onChange={(e) => setChatSettings({...chatSettings, user_mbti: e.target.value})}
|
| 318 |
+
className="w-full p-2 rounded-lg border bg-white dark:bg-slate-800"
|
| 319 |
+
>
|
| 320 |
+
{["ISTJ", "ISFJ", "INFJ", "INTJ", "ISTP", "ISFP", "INFP", "INTP", "ESTP", "ESFP", "ENFP", "ENTP", "ESTJ", "ESFJ", "ENFJ", "ENTJ"].map(type => (
|
| 321 |
+
<option key={type} value={type}>{type}</option>
|
| 322 |
+
))}
|
| 323 |
+
</select>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<div className="space-y-1">
|
| 327 |
+
<label className="text-xs font-semibold text-slate-500">상대방 MBTI</label>
|
| 328 |
+
<select
|
| 329 |
+
value={chatSettings.target_mbti}
|
| 330 |
+
onChange={(e) => setChatSettings({...chatSettings, target_mbti: e.target.value})}
|
| 331 |
+
className="w-full p-2 rounded-lg border bg-white dark:bg-slate-800"
|
| 332 |
+
>
|
| 333 |
+
{["ISTJ", "ISFJ", "INFJ", "INTJ", "ISTP", "ISFP", "INFP", "INTP", "ESTP", "ESFP", "ENFP", "ENTP", "ESTJ", "ESFJ", "ENFJ", "ENTJ"].map(type => (
|
| 334 |
+
<option key={type} value={type}>{type}</option>
|
| 335 |
+
))}
|
| 336 |
+
</select>
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<div className="space-y-1">
|
| 340 |
+
<label className="text-xs font-semibold text-slate-500">대화 배경/상황</label>
|
| 341 |
+
<textarea
|
| 342 |
+
value={chatSettings.situation}
|
| 343 |
+
onChange={(e) => setChatSettings({...chatSettings, situation: e.target.value})}
|
| 344 |
+
className="w-full p-2 rounded-lg border bg-white dark:bg-slate-800 h-24 resize-none text-sm"
|
| 345 |
+
/>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
<button
|
| 349 |
+
onClick={startChat}
|
| 350 |
+
className="w-full btn-secondary flex items-center justify-center gap-2"
|
| 351 |
+
>
|
| 352 |
+
<Loader2 className={`w-4 h-4 ${chatLoading ? "animate-spin" : "hidden"}`} />
|
| 353 |
+
대화 시작 / 초기화
|
| 354 |
+
</button>
|
| 355 |
+
</section>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
{/* Chatbot */}
|
| 359 |
+
<div className="lg:col-span-6 flex flex-col h-[700px] gap-4">
|
| 360 |
+
<div className="card flex-1 overflow-hidden flex flex-col p-0">
|
| 361 |
+
<div className="border-b p-4 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
|
| 362 |
+
<span className="font-bold flex items-center gap-2">
|
| 363 |
+
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
| 364 |
+
상대방과 가상 대화 중 ({chatSettings.target_mbti} 성향)
|
| 365 |
+
</span>
|
| 366 |
+
</div>
|
| 367 |
+
|
| 368 |
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
| 369 |
+
{chatHistory.length === 0 && (
|
| 370 |
+
<div className="text-center text-slate-400 mt-20">
|
| 371 |
+
설정을 마치고 '대화 시작'을 눌러주세요.
|
| 372 |
+
</div>
|
| 373 |
+
)}
|
| 374 |
+
{chatHistory.map((msg, i) => (
|
| 375 |
+
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
| 376 |
+
<div className={`max-w-[80%] p-3 rounded-2xl shadow-sm ${
|
| 377 |
+
msg.role === "user"
|
| 378 |
+
? "bg-indigo-600 text-white rounded-br-none"
|
| 379 |
+
: "bg-white dark:bg-slate-800 border rounded-bl-none"
|
| 380 |
+
}`}>
|
| 381 |
+
<p className="text-sm">{msg.content}</p>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
))}
|
| 385 |
+
{chatLoading && (
|
| 386 |
+
<div className="flex justify-start">
|
| 387 |
+
<div className="bg-white dark:bg-slate-800 border p-3 rounded-2xl rounded-bl-none italic text-slate-400 text-sm">
|
| 388 |
+
생각 중...
|
| 389 |
+
</div>
|
| 390 |
+
</div>
|
| 391 |
+
)}
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
+
<div className="p-4 border-t bg-white dark:bg-slate-900">
|
| 395 |
+
<form onSubmit={(e) => { e.preventDefault(); sendChatMessage(); }} className="flex gap-2">
|
| 396 |
+
<input
|
| 397 |
+
value={chatInput}
|
| 398 |
+
onChange={(e) => setChatInput(e.target.value)}
|
| 399 |
+
placeholder="메시지 입력..."
|
| 400 |
+
className="flex-1 p-3 rounded-xl border bg-slate-50 dark:bg-slate-800 outline-none focus:ring-2 focus:ring-indigo-500"
|
| 401 |
+
/>
|
| 402 |
+
<button type="submit" className="p-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-colors">
|
| 403 |
+
<Send className="w-5 h-5" />
|
| 404 |
+
</button>
|
| 405 |
+
</form>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
{/* Coaching Tip */}
|
| 411 |
+
<div className="lg:col-span-3 h-[700px]">
|
| 412 |
+
<section className="card border-amber-200 bg-amber-50/30 h-full flex flex-col overflow-hidden">
|
| 413 |
+
<h2 className="text-lg font-bold flex items-center gap-2 text-amber-700 mb-4 flex-shrink-0">
|
| 414 |
+
<Sparkles className="w-5 h-5" />
|
| 415 |
+
AI 실시간 가이드
|
| 416 |
+
</h2>
|
| 417 |
+
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
| 418 |
+
<div className="prose prose-sm prose-amber dark:prose-invert max-w-none whitespace-pre-wrap italic text-slate-700 dark:text-slate-300">
|
| 419 |
+
{coachingTip || "대화를 시작하면 이곳에서 실시간 전략 조언을 받으실 수 있습니다."}
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
<div className="mt-8 pt-4 border-t text-[10px] text-slate-400 leading-relaxed flex-shrink-0">
|
| 423 |
+
TIP: ���대방의 MBTI 특성에 맞는 단어 선택이 호감도를 결정합니다. AI 코치의 조언을 참고하여 답변을 수정해 보세요.
|
| 424 |
+
</div>
|
| 425 |
+
</section>
|
| 426 |
+
</div>
|
| 427 |
+
</>
|
| 428 |
+
)}
|
| 429 |
+
</div>
|
| 430 |
+
</main>
|
| 431 |
+
);
|
| 432 |
+
}
|
frontend/src/components/MbtiChart.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell, Text } from 'recharts';
|
| 4 |
+
|
| 5 |
+
interface MbtiChartProps {
|
| 6 |
+
data: Record<string, number>;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function MbtiChart({ data }: MbtiChartProps) {
|
| 10 |
+
// data format: { 'I/E': 0.8, 'S/N': 0.2, ... }
|
| 11 |
+
// Transform for Recharts
|
| 12 |
+
const chartData = [
|
| 13 |
+
{ name: 'I/E', value: data['I/E'] * 100, label: 'I 성향' },
|
| 14 |
+
{ name: 'S/N', value: data['S/N'] * 100, label: 'S 성향' },
|
| 15 |
+
{ name: 'T/F', value: data['T/F'] * 100, label: 'T 성향' },
|
| 16 |
+
{ name: 'P/J', value: data['P/J'] * 100, label: 'P 성향' },
|
| 17 |
+
].reverse();
|
| 18 |
+
|
| 19 |
+
const colors = ['#4f46e5', '#10b981', '#f59e0b', '#ec4899'];
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="h-[300px] w-full mt-4">
|
| 23 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 24 |
+
<BarChart
|
| 25 |
+
layout="vertical"
|
| 26 |
+
data={chartData}
|
| 27 |
+
margin={{ top: 5, right: 30, left: 40, bottom: 5 }}
|
| 28 |
+
>
|
| 29 |
+
<XAxis type="number" domain={[0, 100]} hide />
|
| 30 |
+
<YAxis
|
| 31 |
+
dataKey="name"
|
| 32 |
+
type="category"
|
| 33 |
+
tick={{ fill: 'currentColor', fontSize: 12 }}
|
| 34 |
+
width={40}
|
| 35 |
+
/>
|
| 36 |
+
<Tooltip
|
| 37 |
+
cursor={{ fill: 'transparent' }}
|
| 38 |
+
content={({ active, payload }) => {
|
| 39 |
+
if (active && payload && payload.length) {
|
| 40 |
+
return (
|
| 41 |
+
<div className="bg-white dark:bg-slate-800 p-2 border border-slate-200 dark:border-slate-700 rounded shadow-sm text-xs">
|
| 42 |
+
<p className="font-bold">{payload[0].payload.label}</p>
|
| 43 |
+
<p>{`${payload[0].value?.toFixed(1)}%`}</p>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
return null;
|
| 48 |
+
}}
|
| 49 |
+
/>
|
| 50 |
+
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={20}>
|
| 51 |
+
{chartData.map((entry, index) => (
|
| 52 |
+
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
| 53 |
+
))}
|
| 54 |
+
</Bar>
|
| 55 |
+
</BarChart>
|
| 56 |
+
</ResponsiveContainer>
|
| 57 |
+
<p className="text-[10px] text-center text-slate-500 mt-2">
|
| 58 |
+
※ 오른쪽일수록 I / S / T / P 성향이 강함을 의미합니다.
|
| 59 |
+
</p>
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
}
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
| 2 |
+
|
| 3 |
+
export async function post(endpoint: string, data: any) {
|
| 4 |
+
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
| 5 |
+
method: "POST",
|
| 6 |
+
headers: {
|
| 7 |
+
"Content-Type": "application/json",
|
| 8 |
+
},
|
| 9 |
+
body: JSON.stringify(data),
|
| 10 |
+
});
|
| 11 |
+
if (!response.ok) {
|
| 12 |
+
const error = await response.json();
|
| 13 |
+
throw new Error(error.detail || "Something went wrong");
|
| 14 |
+
}
|
| 15 |
+
return response.json();
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function uploadImage(file: File) {
|
| 19 |
+
const formData = new FormData();
|
| 20 |
+
formData.append("file", file);
|
| 21 |
+
|
| 22 |
+
const response = await fetch(`${API_BASE_URL}/ocr`, {
|
| 23 |
+
method: "POST",
|
| 24 |
+
body: formData,
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
if (!response.ok) {
|
| 28 |
+
throw new Error("OCR failed");
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return response.json();
|
| 32 |
+
}
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|