ashfortune commited on
Commit
aa53da8
·
0 Parent(s):

first commit

Browse files
.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
+ ![Header](https://images.unsplash.com/photo-1516321318423-f06f85e504b3?ixlib=rb-1.2.1&auto=format&fit=crop&w=1350&q=80)
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
+ }