Skip to content

Commit e0ab3b5

Browse files
committed
Feat: RAG기반 금융용어 설명 기능 추가
1 parent d414178 commit e0ab3b5

29 files changed

Lines changed: 2137 additions & 22 deletions

.claude/worktrees/awesome-payne

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit d414178b6fce81607eb966fd8434c1392624fc34

docker-compose.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,17 @@ services:
2424
dockerfile: Dockerfile
2525
env_file:
2626
- .env
27+
environment:
28+
# 컨테이너 내부에서는 서비스명으로 접근
29+
- MONGO_URI=mongodb://mongodb:27017
30+
- REDIS_HOST=redis
31+
- CHROMA_HOST=chromadb
2732
ports:
2833
- "8000:8000"
2934
depends_on:
3035
- mongodb
3136
- redis
37+
- chromadb
3238
restart: unless-stopped
3339

3440
# 백그라운드 워커1 (기사 요약)
@@ -39,6 +45,9 @@ services:
3945
command: python -m app.services.queue.worker
4046
env_file:
4147
- .env
48+
environment:
49+
- MONGO_URI=mongodb://mongodb:27017
50+
- REDIS_HOST=redis
4251
depends_on:
4352
- mongodb
4453
- redis
@@ -52,6 +61,9 @@ services:
5261
command: python -m app.services.recommend_queue.worker
5362
env_file:
5463
- .env
64+
environment:
65+
- MONGO_URI=mongodb://mongodb:27017
66+
- REDIS_HOST=redis
5567
depends_on:
5668
- mongodb
5769
- redis
@@ -65,10 +77,23 @@ services:
6577
command: python -m app.services.keyword_queue.worker
6678
env_file:
6779
- .env
80+
environment:
81+
- MONGO_URI=mongodb://mongodb:27017
82+
- REDIS_HOST=redis
6883
depends_on:
6984
- mongodb
7085
- redis
7186
restart: unless-stopped
7287

88+
# 벡터 DB (RAG)
89+
chromadb:
90+
image: chromadb/chroma:latest
91+
ports:
92+
- "8001:8000"
93+
volumes:
94+
- chroma_data:/chroma/chroma
95+
restart: unless-stopped
96+
7397
volumes:
7498
mongodb_data:
99+
chroma_data:

econoeasy/app/core/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ class Settings(BaseSettings):
6464
REDIS_KEYWORD_CONSUMER_GROUP: str = "article-keyword-request-processors"
6565
REDIS_KEYWORD_CONSUMER_NAME: str = "article-keyword-request-worker-1"
6666

67+
# RAG 설정
68+
EMBEDDING_MODEL: str = "models/text-embedding-004"
69+
CHROMA_HOST: str = "localhost"
70+
CHROMA_PORT: int = 8001
71+
CHROMA_COLLECTION_NAME: str = "econoeasy_articles"
72+
RAG_TOP_K: int = 5
73+
RAG_MIN_SCORE: float = 0.4
74+
6775
model_config = SettingsConfigDict(
6876
env_file=str(ENV_FILE),
6977
env_file_encoding="utf-8",

econoeasy/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .core.config import settings
44
from .routers import summarize, recommend
55
from .routers import quiz, keyword
6+
from .routers import rag
67
from .services.queue.mongodb_client import mongodb_client
78

89

@@ -24,6 +25,7 @@
2425
app.include_router(recommend.router)
2526
app.include_router(quiz.router)
2627
app.include_router(keyword.router)
28+
app.include_router(rag.router)
2729

2830
# MongoDB 라이프사이클 이벤트
2931
@app.on_event("startup")

econoeasy/app/models/schemas.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,18 @@ class KeywordStockResponse(BaseModel):
231231

232232
class TermDefineRequest(BaseModel):
233233
term: str
234+
article_content: Optional[str] = None
235+
234236

235237
class TermDefineResponse(BaseModel):
236238
term: str
237239
definition: str
240+
241+
242+
class EnrichedTermDefineResponse(BaseModel):
243+
"""RAG 기반 용어 설명 응답 (정의 + 기사 맥락 + 최근 동향)."""
244+
term: str
245+
definition: str
246+
article_context: Optional[str] = None
247+
recent_trend: Optional[str] = None
248+
sources: List[Dict[str, Any]] = []

econoeasy/app/routers/keyword.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
from fastapi import APIRouter, HTTPException
2-
from ..models.schemas import ArticleInput, KeywordTermsResponse, KeywordStockResponse, TermDefineRequest, TermDefineResponse
2+
from ..models.schemas import (
3+
ArticleInput, KeywordTermsResponse, KeywordStockResponse,
4+
TermDefineRequest, EnrichedTermDefineResponse,
5+
)
36
from ..services.keyword.service import keyword_service
7+
from ..services.keyword.term_definition_service import term_definition_service
48

59
router = APIRouter(prefix="/keyword", tags=["keyword"])
610

11+
712
@router.post("/terms", response_model=KeywordTermsResponse)
813
async def get_related_terms(article: ArticleInput):
914
try:
1015
return await keyword_service.extract_related_terms(article)
1116
except Exception as e:
1217
raise HTTPException(status_code=500, detail=str(e))
1318

19+
1420
@router.post("/stock_id", response_model=KeywordStockResponse)
1521
async def get_related_stocks(article: ArticleInput):
1622
try:
@@ -19,10 +25,16 @@ async def get_related_stocks(article: ArticleInput):
1925
raise HTTPException(status_code=500, detail=str(e))
2026

2127

22-
@router.post("/define", response_model=TermDefineResponse)
28+
@router.post("/define", response_model=EnrichedTermDefineResponse)
2329
async def define_term(payload: TermDefineRequest):
30+
"""용어 정의 + 기사 맥락 + 최근 뉴스 동향을 한 번에 반환한다."""
2431
try:
25-
return await keyword_service.define_term(payload.term)
32+
return await term_definition_service.define_term(
33+
term=payload.term,
34+
article_content=payload.article_content,
35+
)
36+
except ValueError as e:
37+
raise HTTPException(status_code=400, detail=str(e))
2638
except Exception as e:
2739
raise HTTPException(status_code=500, detail=str(e))
2840

econoeasy/app/routers/rag.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
[구현 의도]
3+
HTTP 요청/응답 변환만 담당한다.
4+
비즈니스 로직은 전부 RAGService에 위임.
5+
ValueError → 400, RAGServiceError → 500으로 매핑.
6+
"""
7+
8+
from fastapi import APIRouter, HTTPException
9+
from pydantic import BaseModel
10+
from ..services.rag.rag_service import rag_service, RAGServiceError
11+
12+
router = APIRouter(prefix="/rag", tags=["rag"])
13+
14+
15+
class AskRequest(BaseModel):
16+
question: str
17+
18+
19+
class IndexRequest(BaseModel):
20+
article_id: str
21+
title: str
22+
content: str
23+
published_at: str
24+
url: str
25+
26+
27+
@router.post("/ask")
28+
async def ask(request: AskRequest):
29+
"""경제 뉴스 기사를 근거로 질문에 답변한다."""
30+
try:
31+
return await rag_service.ask(request.question)
32+
except ValueError as e:
33+
raise HTTPException(status_code=400, detail=str(e))
34+
except RAGServiceError as e:
35+
raise HTTPException(status_code=500, detail=str(e))
36+
except Exception as e:
37+
raise HTTPException(status_code=500, detail=f"처리 중 오류: {str(e)}")
38+
39+
40+
@router.post("/index")
41+
async def index_article(request: IndexRequest):
42+
"""단일 기사를 RAG 검색 인덱스에 추가한다."""
43+
try:
44+
await rag_service.index_article(
45+
article_id=request.article_id,
46+
title=request.title,
47+
content=request.content,
48+
published_at=request.published_at,
49+
url=request.url,
50+
)
51+
return {"status": "ok", "article_id": request.article_id}
52+
except ValueError as e:
53+
raise HTTPException(status_code=400, detail=str(e))
54+
except Exception as e:
55+
raise HTTPException(status_code=500, detail=f"인덱싱 오류: {str(e)}")
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""
2+
[구현 의도]
3+
세 가지 설명을 단일 LLM 호출로 생성한다.
4+
1. definition : 용어의 일반적 정의
5+
2. article_context : 사용자가 읽는 기사에서 이 용어가 쓰인 맥락 (기사 있을 때만)
6+
3. recent_trend : 최근 뉴스 기사들에서 이 용어의 동향 (ChromaDB 결과 있을 때만)
7+
8+
항목이 없을 때 null을 반환하도록 프롬프트에 명시한다.
9+
"""
10+
11+
12+
class TermDefinitionPrompts:
13+
14+
_BASE = """당신은 경제/금융 전문 설명가입니다.
15+
아래 지시에 따라 용어를 설명하고, 반드시 JSON 형식으로만 응답하세요.
16+
17+
용어: "{term}"
18+
19+
{article_section}{trend_section}
20+
다음 JSON 형식으로 응답하세요:
21+
{{
22+
"definition": "이 용어의 일반적인 정의 (2-3문장, 비전문가도 이해할 수 있게)",
23+
"article_context": {article_context_instruction},
24+
"recent_trend": {recent_trend_instruction}
25+
}}"""
26+
27+
_ARTICLE_SECTION = """[사용자가 읽고 있는 기사]
28+
{article_content}
29+
30+
"""
31+
32+
_TREND_SECTION = """[최근 관련 뉴스 기사들]
33+
{trend_context}
34+
35+
"""
36+
37+
@classmethod
38+
def build(
39+
cls,
40+
term: str,
41+
article_content: str | None,
42+
trend_docs: list[dict],
43+
) -> str:
44+
article_section = ""
45+
article_context_instruction = "null"
46+
47+
if article_content and article_content.strip():
48+
article_section = cls._ARTICLE_SECTION.format(
49+
article_content=article_content[:3000]
50+
)
51+
article_context_instruction = (
52+
'"위 기사에서 이 용어가 어떤 역할/맥락으로 사용되었는지 2문장으로 설명"'
53+
)
54+
55+
trend_section = ""
56+
recent_trend_instruction = "null"
57+
58+
if trend_docs:
59+
trend_lines = []
60+
for i, doc in enumerate(trend_docs, 1):
61+
meta = doc.get("metadata", {})
62+
title = meta.get("title", "")
63+
published = meta.get("publishedAt", "")
64+
text = doc.get("text", "")[:500]
65+
trend_lines.append(f"[기사{i}] {title} ({published})\n{text}")
66+
trend_section = cls._TREND_SECTION.format(
67+
trend_context="\n\n".join(trend_lines)
68+
)
69+
recent_trend_instruction = (
70+
'"위 최근 기사들을 바탕으로, 현재 이 용어와 관련된 뉴스 동향을 2-3문장으로 설명"'
71+
)
72+
73+
return cls._BASE.format(
74+
term=term,
75+
article_section=article_section,
76+
trend_section=trend_section,
77+
article_context_instruction=article_context_instruction,
78+
recent_trend_instruction=recent_trend_instruction,
79+
)

0 commit comments

Comments
 (0)