From 68e809bbc8b61feae7bd0ce40e426d860e57c8dc Mon Sep 17 00:00:00 2001 From: kimheesu Date: Fri, 13 Jun 2025 15:28:13 +0900 Subject: [PATCH] [add] analysis --- requirements.txt | 1 + web/backend/apps/websocket/apps.py | 2 +- web/backend/apps/websocket/consumers.py | 20 +-- web/backend/reset_and_run.sh | 7 +- web/backend/tradingagents_web/asgi.py | 9 +- web/frontend/src/pages/Analysis/Analysis.js | 132 +++++++++++++++--- .../Analysis/components/AnalysisDisplay.js | 112 +++++++++++++++ .../pages/Analysis/components/AnalysisForm.js | 119 ++++++++++++++++ web/frontend/src/pages/Dashboard/Dashboard.js | 2 +- 9 files changed, 371 insertions(+), 33 deletions(-) create mode 100644 web/frontend/src/pages/Analysis/components/AnalysisDisplay.js create mode 100644 web/frontend/src/pages/Analysis/components/AnalysisForm.js diff --git a/requirements.txt b/requirements.txt index 618ed54d..8c7cda8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,3 +41,4 @@ chainlit rich questionary langgraph==0.4.8 +daphne \ No newline at end of file diff --git a/web/backend/apps/websocket/apps.py b/web/backend/apps/websocket/apps.py index 3be36af2..70c31e36 100644 --- a/web/backend/apps/websocket/apps.py +++ b/web/backend/apps/websocket/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig - + class WebsocketConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.websocket' diff --git a/web/backend/apps/websocket/consumers.py b/web/backend/apps/websocket/consumers.py index 38d355d4..d324b436 100644 --- a/web/backend/apps/websocket/consumers.py +++ b/web/backend/apps/websocket/consumers.py @@ -1,15 +1,11 @@ import json from channels.generic.websocket import AsyncWebsocketConsumer from channels.db import database_sync_to_async -from django.contrib.auth.models import AnonymousUser from rest_framework_simplejwt.tokens import UntypedToken from rest_framework_simplejwt.exceptions import InvalidToken, TokenError -from django.contrib.auth import get_user_model from django.conf import settings import jwt -User = get_user_model() - class TradingAnalysisConsumer(AsyncWebsocketConsumer): """거래 분석 실시간 업데이트 WebSocket Consumer""" @@ -121,6 +117,10 @@ class TradingAnalysisConsumer(AsyncWebsocketConsumer): @database_sync_to_async def get_user_from_token(self): """JWT 토큰에서 사용자 정보 추출""" + from django.contrib.auth import get_user_model + from django.contrib.auth.models import AnonymousUser + + User = get_user_model() try: # URL에서 토큰 추출 (query parameter 또는 header) token = None @@ -142,13 +142,16 @@ class TradingAnalysisConsumer(AsyncWebsocketConsumer): # JWT 토큰 검증 try: - UntypedToken(token) # 토큰 유효성 검사 + # simplejwt 설정에서 올바른 서명 키와 알고리즘 가져오기 + from rest_framework_simplejwt.settings import api_settings + + UntypedToken(token) # 토큰 기본 구조 검증 # 토큰에서 사용자 ID 추출 decoded_token = jwt.decode( - token, - settings.SECRET_KEY, - algorithms=['HS256'] + token, + api_settings.SIGNING_KEY, # 올바른 서명 키 사용 + algorithms=[api_settings.ALGORITHM] # 올바른 알고리즘 사용 ) user_id = decoded_token.get('user_id') @@ -171,6 +174,7 @@ class TradingAnalysisConsumer(AsyncWebsocketConsumer): def check_session_ownership(self, session_id): """분석 세션 소유권 확인""" try: + # 지연 import from apps.authentication.models import AnalysisSession session = AnalysisSession.objects.get(id=session_id, user=self.user) return True diff --git a/web/backend/reset_and_run.sh b/web/backend/reset_and_run.sh index 3f14df06..36023437 100644 --- a/web/backend/reset_and_run.sh +++ b/web/backend/reset_and_run.sh @@ -2,6 +2,9 @@ echo "🚀 Django 서버 시작 - 데이터베이스 초기화" +# Django 설정 모듈 환경 변수 설정 +export DJANGO_SETTINGS_MODULE=tradingagents_web.settings + # 1. 데이터베이스 초기화 echo "🔄 데이터베이스 초기화 중..." docker exec -i tradingagents_mysql mysql -u root -ppassword -e " @@ -25,6 +28,6 @@ if not User.objects.filter(email='admin@example.com').exists(): print('✅ 관리자: admin@example.com / admin123!'); " -# 4. 서버 시작 +# 4. 서버 시작 (환경 변수와 함께) echo "🎉 서버 시작!" -python manage.py runserver \ No newline at end of file +daphne -b 0.0.0.0 -p 8000 tradingagents_web.asgi:application \ No newline at end of file diff --git a/web/backend/tradingagents_web/asgi.py b/web/backend/tradingagents_web/asgi.py index 3a78e90a..3844394b 100644 --- a/web/backend/tradingagents_web/asgi.py +++ b/web/backend/tradingagents_web/asgi.py @@ -8,12 +8,15 @@ https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ """ import os +import django from django.core.asgi import get_asgi_application -from channels.routing import ProtocolTypeRouter, URLRouter -from channels.auth import AuthMiddlewareStack -import apps.websocket.routing os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tradingagents_web.settings') +django.setup() + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +import apps.websocket.routing application = ProtocolTypeRouter({ "http": get_asgi_application(), diff --git a/web/frontend/src/pages/Analysis/Analysis.js b/web/frontend/src/pages/Analysis/Analysis.js index d60b797f..e49cd6bb 100644 --- a/web/frontend/src/pages/Analysis/Analysis.js +++ b/web/frontend/src/pages/Analysis/Analysis.js @@ -1,33 +1,129 @@ -import React from 'react'; -import { Card, Typography } from 'antd'; -import styled from 'styled-components'; +// web/frontend/src/pages/Analysis/Analysis.js -const { Title, Text } = Typography; +import React, { useState, useEffect } from 'react'; +import { Card, Divider, Spin, Alert, Typography } from 'antd'; +import styled from 'styled-components'; +import api from '../../services/api'; +import { useWebSocket } from '../../contexts/WebSocketContext'; +import AnalysisForm from './components/AnalysisForm'; +import AnalysisDisplay from './components/AnalysisDisplay'; + +const { Title, Paragraph } = Typography; const AnalysisContainer = styled.div` - max-width: 800px; + max-width: 900px; margin: 0 auto; + padding: ${props => props.theme.spacing.lg}; `; -const PlaceholderCard = styled(Card)` - text-align: center; - padding: ${props => props.theme.spacing.xl}; - background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%); +const CustomPageHeader = styled(Card)` + border: none; + background-color: transparent; + .ant-card-body { + padding: 0; + } `; const Analysis = () => { + const [currentSessionId, setCurrentSessionId] = useState(null); + const [analysisStatus, setAnalysisStatus] = useState('idle'); // idle, running, completed, failed + const [error, setError] = useState(null); + const [finalReport, setFinalReport] = useState(null); + + const { subscribeToAnalysis, analysisProgress, messages, clearMessages, clearAnalysisProgress } = useWebSocket(); + + // WebSocket 메시지로부터 상태 업데이트 + useEffect(() => { + if (currentSessionId && analysisProgress[currentSessionId]) { + const progress = analysisProgress[currentSessionId]; + setAnalysisStatus(progress.status); + if (progress.status === 'completed') { + setFinalReport(progress.result); + } else if (progress.status === 'failed') { + setError(progress.error || '분석 중 알 수 없는 오류가 발생했습니다.'); + } + } + }, [analysisProgress, currentSessionId]); + + // 분석 시작 핸들러 + const handleStartAnalysis = async (values) => { + setAnalysisStatus('starting'); + setError(null); + setFinalReport(null); + clearMessages(); + if(currentSessionId) clearAnalysisProgress(currentSessionId); + + try { + const response = await api.post('/api/trading/start-analysis/', values); + const { session_id } = response.data; + + setCurrentSessionId(session_id); + subscribeToAnalysis(session_id); + setAnalysisStatus('running'); + + } catch (err) { + const errorMessage = err.response?.data?.error || '분석 시작에 실패했습니다.'; + setError(errorMessage); + setAnalysisStatus('failed'); + } + }; + + // 새 분석 시작 핸들러 (Display 컴포넌트에서 호출) + const handleNewAnalysis = () => { + if(currentSessionId) clearAnalysisProgress(currentSessionId); + setCurrentSessionId(null); + setAnalysisStatus('idle'); + setError(null); + setFinalReport(null); + clearMessages(); + }; + + const renderContent = () => { + if (analysisStatus === 'starting') { + return
; + } + + if (currentSessionId && (analysisStatus === 'running' || analysisStatus === 'completed' || analysisStatus === 'failed')) { + return ( + m.sessionId === currentSessionId)} + finalReport={finalReport} + onNewAnalysis={handleNewAnalysis} + /> + ); + } + + return ; + }; + return ( - - 분석 시작 페이지 - - 여기에 거래 분석을 시작할 수 있는 폼이 들어갑니다. -
- 종목 선택, 분석 옵션 설정, 분석가 선택 등의 기능이 포함됩니다. -
-
+ + AI 기반 주식 분석 + + 관심 있는 종목에 대한 심층 분석을 시작하세요. + + + + + {error && !currentSessionId && ( // Show top-level error only when no session is active + setError(null)} + style={{ marginBottom: '24px' }} + /> + )} + + {renderContent()}
); }; -export default Analysis; \ No newline at end of file +export default Analysis; \ No newline at end of file diff --git a/web/frontend/src/pages/Analysis/components/AnalysisDisplay.js b/web/frontend/src/pages/Analysis/components/AnalysisDisplay.js new file mode 100644 index 00000000..5a33abc4 --- /dev/null +++ b/web/frontend/src/pages/Analysis/components/AnalysisDisplay.js @@ -0,0 +1,112 @@ +import React, { useRef, useEffect } from 'react'; +import { Card, Progress, Timeline, Button, Result, Typography, Empty, Tag } from 'antd'; +import { FileDoneOutlined, RedoOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; +import ReactMarkdown from 'react-markdown'; + +const { Title, Paragraph, Text } = Typography; + +const DisplayCard = styled(Card)` + border-radius: ${props => props.theme.borderRadius.lg}; + box-shadow: ${props => props.theme.shadows.lg}; + margin-top: ${props => props.theme.spacing.lg}; +`; + +const TimelineContainer = styled.div` + max-height: 400px; + overflow-y: auto; + padding: ${props => props.theme.spacing.md}; + border: 1px solid ${props => props.theme.colors.border}; + border-radius: ${props => props.theme.borderRadius.md}; + background-color: ${props => props.theme.colors.backgroundSecondary}; +`; + +const ReportContainer = styled.div` + margin-top: ${props => props.theme.spacing.lg}; + padding: ${props => props.theme.spacing.lg}; + background-color: #fafafa; + border-radius: ${props => props.theme.borderRadius.md}; +`; + +const agentTagColors = { + market: 'blue', + social: 'cyan', + news: 'green', + fundamentals: 'purple', +}; + +const AnalysisDisplay = ({ sessionId, status, progress, messages, finalReport, onNewAnalysis }) => { + const timelineEndRef = useRef(null); + + useEffect(() => { + timelineEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const renderRunningState = () => ( + <> + 분석 진행 중... + AI 분석가들이 정보를 수집하고 분석하고 있습니다. (세션 ID: {sessionId}) + + {progress?.message} + + + + {messages.length > 0 ? messages.map(msg => ( + + + {msg.agent} + {new Date(msg.timestamp).toLocaleTimeString()} + + {msg.content} + + )) : } + +
+ + + ); + + const renderCompletedState = () => ( + } + subTitle="아래에서 생성된 최종 보고서를 확인하세요." + extra={[ + + ]} + /> + ); + + const renderFailedState = () => ( + } onClick={onNewAnalysis}> + 다시 시도 + + ]} + /> + ); + + return ( + + {status === 'running' && renderRunningState()} + {status === 'completed' && renderCompletedState()} + {status === 'failed' && renderFailedState()} + + {status === 'completed' && finalReport && ( + + 최종 분석 보고서 + {finalReport} + + )} + + ); +}; + +export default AnalysisDisplay; \ No newline at end of file diff --git a/web/frontend/src/pages/Analysis/components/AnalysisForm.js b/web/frontend/src/pages/Analysis/components/AnalysisForm.js new file mode 100644 index 00000000..f75ac69e --- /dev/null +++ b/web/frontend/src/pages/Analysis/components/AnalysisForm.js @@ -0,0 +1,119 @@ +import React from 'react'; +import { Form, Input, Button, Card, Select, Slider, Checkbox, Row, Col, Typography } from 'antd'; +import { FundOutlined, SendOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; + +const { Title } = Typography; +const { Option } = Select; + +const FormCard = styled(Card)` + border-radius: ${props => props.theme.borderRadius.lg}; + box-shadow: ${props => props.theme.shadows.lg}; +`; + +const analystsOptions = [ + { label: '시장 분석가 (Market)', value: 'market' }, + { label: '소셜 분석가 (Social)', value: 'social' }, + { label: '뉴스 분석가 (News)', value: 'news' }, + { label: '재무 분석가 (Fundamentals)', value: 'fundamentals' }, +]; + +const AnalysisForm = ({ onStartAnalysis, loading }) => { + const [form] = Form.useForm(); + + const handleSubmit = (values) => { + onStartAnalysis(values); + }; + + return ( + + 새 분석 시작 +
+ + } + placeholder="예: AAPL, GOOGL, MSFT" + size="large" + /> + + + + + + {analystsOptions.map(option => ( + + {option.label} + + ))} + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default AnalysisForm; \ No newline at end of file diff --git a/web/frontend/src/pages/Dashboard/Dashboard.js b/web/frontend/src/pages/Dashboard/Dashboard.js index 9b34368d..c296ca56 100644 --- a/web/frontend/src/pages/Dashboard/Dashboard.js +++ b/web/frontend/src/pages/Dashboard/Dashboard.js @@ -97,7 +97,7 @@ const Dashboard = () => { }); const [recentAnalyses, setRecentAnalyses] = useState([]); const { user } = useAuth(); - const { connected, analysisProgress } = useWebSocket(); + const { connected, messages } = useWebSocket(); const navigate = useNavigate(); useEffect(() => {