[add] analysis

This commit is contained in:
kimheesu 2025-06-13 15:28:13 +09:00
parent 10a8e18005
commit 68e809bbc8
9 changed files with 371 additions and 33 deletions

View File

@ -41,3 +41,4 @@ chainlit
rich rich
questionary questionary
langgraph==0.4.8 langgraph==0.4.8
daphne

View File

@ -1,6 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
class WebsocketConfig(AppConfig): class WebsocketConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.websocket' name = 'apps.websocket'

View File

@ -1,15 +1,11 @@
import json import json
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async 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.tokens import UntypedToken
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from django.contrib.auth import get_user_model
from django.conf import settings from django.conf import settings
import jwt import jwt
User = get_user_model()
class TradingAnalysisConsumer(AsyncWebsocketConsumer): class TradingAnalysisConsumer(AsyncWebsocketConsumer):
"""거래 분석 실시간 업데이트 WebSocket Consumer""" """거래 분석 실시간 업데이트 WebSocket Consumer"""
@ -121,6 +117,10 @@ class TradingAnalysisConsumer(AsyncWebsocketConsumer):
@database_sync_to_async @database_sync_to_async
def get_user_from_token(self): def get_user_from_token(self):
"""JWT 토큰에서 사용자 정보 추출""" """JWT 토큰에서 사용자 정보 추출"""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
User = get_user_model()
try: try:
# URL에서 토큰 추출 (query parameter 또는 header) # URL에서 토큰 추출 (query parameter 또는 header)
token = None token = None
@ -142,13 +142,16 @@ class TradingAnalysisConsumer(AsyncWebsocketConsumer):
# JWT 토큰 검증 # JWT 토큰 검증
try: try:
UntypedToken(token) # 토큰 유효성 검사 # simplejwt 설정에서 올바른 서명 키와 알고리즘 가져오기
from rest_framework_simplejwt.settings import api_settings
UntypedToken(token) # 토큰 기본 구조 검증
# 토큰에서 사용자 ID 추출 # 토큰에서 사용자 ID 추출
decoded_token = jwt.decode( decoded_token = jwt.decode(
token, token,
settings.SECRET_KEY, api_settings.SIGNING_KEY, # 올바른 서명 키 사용
algorithms=['HS256'] algorithms=[api_settings.ALGORITHM] # 올바른 알고리즘 사용
) )
user_id = decoded_token.get('user_id') user_id = decoded_token.get('user_id')
@ -171,6 +174,7 @@ class TradingAnalysisConsumer(AsyncWebsocketConsumer):
def check_session_ownership(self, session_id): def check_session_ownership(self, session_id):
"""분석 세션 소유권 확인""" """분석 세션 소유권 확인"""
try: try:
# 지연 import
from apps.authentication.models import AnalysisSession from apps.authentication.models import AnalysisSession
session = AnalysisSession.objects.get(id=session_id, user=self.user) session = AnalysisSession.objects.get(id=session_id, user=self.user)
return True return True

View File

@ -2,6 +2,9 @@
echo "🚀 Django 서버 시작 - 데이터베이스 초기화" echo "🚀 Django 서버 시작 - 데이터베이스 초기화"
# Django 설정 모듈 환경 변수 설정
export DJANGO_SETTINGS_MODULE=tradingagents_web.settings
# 1. 데이터베이스 초기화 # 1. 데이터베이스 초기화
echo "🔄 데이터베이스 초기화 중..." echo "🔄 데이터베이스 초기화 중..."
docker exec -i tradingagents_mysql mysql -u root -ppassword -e " 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!'); print('✅ 관리자: admin@example.com / admin123!');
" "
# 4. 서버 시작 # 4. 서버 시작 (환경 변수와 함께)
echo "🎉 서버 시작!" echo "🎉 서버 시작!"
python manage.py runserver daphne -b 0.0.0.0 -p 8000 tradingagents_web.asgi:application

View File

@ -8,12 +8,15 @@ https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
""" """
import os import os
import django
from django.core.asgi import get_asgi_application 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') 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({ application = ProtocolTypeRouter({
"http": get_asgi_application(), "http": get_asgi_application(),

View File

@ -1,33 +1,129 @@
import React from 'react'; // web/frontend/src/pages/Analysis/Analysis.js
import { Card, Typography } from 'antd';
import styled from 'styled-components';
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` const AnalysisContainer = styled.div`
max-width: 800px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: ${props => props.theme.spacing.lg};
`; `;
const PlaceholderCard = styled(Card)` const CustomPageHeader = styled(Card)`
text-align: center; border: none;
padding: ${props => props.theme.spacing.xl}; background-color: transparent;
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%); .ant-card-body {
padding: 0;
}
`; `;
const Analysis = () => { 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 <div style={{ textAlign: 'center', padding: '50px' }}><Spin size="large" tip="분석을 시작하고 있습니다..." /></div>;
}
if (currentSessionId && (analysisStatus === 'running' || analysisStatus === 'completed' || analysisStatus === 'failed')) {
return (
<AnalysisDisplay
sessionId={currentSessionId}
status={analysisStatus}
progress={analysisProgress[currentSessionId]}
messages={messages.filter(m => m.sessionId === currentSessionId)}
finalReport={finalReport}
onNewAnalysis={handleNewAnalysis}
/>
);
}
return <AnalysisForm onStartAnalysis={handleStartAnalysis} loading={analysisStatus === 'starting'} />;
};
return ( return (
<AnalysisContainer> <AnalysisContainer>
<PlaceholderCard> <CustomPageHeader>
<Title level={2}>분석 시작 페이지</Title> <Title level={2}>AI 기반 주식 분석</Title>
<Text type="secondary"> <Paragraph type="secondary">
여기에 거래 분석을 시작할 있는 폼이 들어갑니다. 관심 있는 종목에 대한 심층 분석을 시작하세요.
<br /> </Paragraph>
종목 선택, 분석 옵션 설정, 분석가 선택 등의 기능이 포함됩니다. </CustomPageHeader>
</Text> <Divider />
</PlaceholderCard>
{error && !currentSessionId && ( // Show top-level error only when no session is active
<Alert
message="오류"
description={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: '24px' }}
/>
)}
{renderContent()}
</AnalysisContainer> </AnalysisContainer>
); );
}; };
export default Analysis; export default Analysis;

View File

@ -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 = () => (
<>
<Title level={4}>분석 진행 ...</Title>
<Paragraph type="secondary">AI 분석가들이 정보를 수집하고 분석하고 있습니다. (세션 ID: {sessionId})</Paragraph>
<Progress percent={progress?.progress || 0} status="active" />
<Paragraph style={{ textAlign: 'center', marginTop: '10px' }}>{progress?.message}</Paragraph>
<TimelineContainer>
<Timeline>
{messages.length > 0 ? messages.map(msg => (
<Timeline.Item key={msg.id}>
<Text strong>
<Tag color={agentTagColors[msg.agent] || 'default'}>{msg.agent}</Tag>
{new Date(msg.timestamp).toLocaleTimeString()}
</Text>
<Paragraph style={{ marginLeft: '10px' }}>{msg.content}</Paragraph>
</Timeline.Item>
)) : <Empty description="실시간 분석 로그가 여기에 표시됩니다." />}
</Timeline>
<div ref={timelineEndRef} />
</TimelineContainer>
</>
);
const renderCompletedState = () => (
<Result
status="success"
title="분석이 성공적으로 완료되었습니다!"
icon={<FileDoneOutlined />}
subTitle="아래에서 생성된 최종 보고서를 확인하세요."
extra={[
<Button type="primary" key="new" icon={<RedoOutlined />} onClick={onNewAnalysis}>
분석 시작
</Button>
]}
/>
);
const renderFailedState = () => (
<Result
status="error"
title="분석에 실패했습니다."
subTitle={progress?.error || '알 수 없는 오류가 발생했습니다.'}
extra={[
<Button type="primary" key="retry" icon={<RedoOutlined />} onClick={onNewAnalysis}>
다시 시도
</Button>
]}
/>
);
return (
<DisplayCard>
{status === 'running' && renderRunningState()}
{status === 'completed' && renderCompletedState()}
{status === 'failed' && renderFailedState()}
{status === 'completed' && finalReport && (
<ReportContainer>
<Title level={3}>최종 분석 보고서</Title>
<ReactMarkdown>{finalReport}</ReactMarkdown>
</ReportContainer>
)}
</DisplayCard>
);
};
export default AnalysisDisplay;

View File

@ -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 (
<FormCard>
<Title level={4} style={{ textAlign: 'center', marginBottom: '24px' }}> 분석 시작</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
research_depth: 3,
analysts_selected: ['market', 'news'],
shallow_thinker: 'gpt-4o-mini',
deep_thinker: 'gpt-4o'
}}
>
<Form.Item
name="ticker"
label="분석할 주식 Ticker"
rules={[{ required: true, message: 'Ticker를 입력해주세요 (예: AAPL, TSLA)' }]}
>
<Input
prefix={<FundOutlined />}
placeholder="예: AAPL, GOOGL, MSFT"
size="large"
/>
</Form.Item>
<Form.Item
name="analysts_selected"
label="분석가 팀 선택"
rules={[{ required: true, message: '최소 한 명의 분석가를 선택해주세요.' }]}
>
<Checkbox.Group style={{ width: '100%' }}>
<Row>
{analystsOptions.map(option => (
<Col span={12} key={option.value}>
<Checkbox value={option.value}>{option.label}</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item
name="research_depth"
label="분석 깊이"
>
<Slider
min={1}
max={5}
step={2}
marks={{ 1: '가볍게', 3: '보통', 5: '심층' }}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="shallow_thinker" label="Shallow Thinker 모델">
<Select>
<Option value="gpt-4o-mini">GPT-4o Mini</Option>
<Option value="gpt-4o">GPT-4o</Option>
<Option value="gpt-4-turbo">GPT-4 Turbo</Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="deep_thinker" label="Deep Thinker 모델">
<Select>
<Option value="gpt-4o">GPT-4o</Option>
<Option value="gpt-4-turbo">GPT-4 Turbo</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item style={{ marginTop: '24px' }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
icon={<SendOutlined />}
size="large"
block
>
{loading ? '분석 시작 중...' : '분석 시작'}
</Button>
</Form.Item>
</Form>
</FormCard>
);
};
export default AnalysisForm;

View File

@ -97,7 +97,7 @@ const Dashboard = () => {
}); });
const [recentAnalyses, setRecentAnalyses] = useState([]); const [recentAnalyses, setRecentAnalyses] = useState([]);
const { user } = useAuth(); const { user } = useAuth();
const { connected, analysisProgress } = useWebSocket(); const { connected, messages } = useWebSocket();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {