dev-yuje commited on
Commit
cb92864
·
0 Parent(s):

feat: 프로젝트 초기 구성 및 GraphRAG 테스트 파이프라인 연동

Browse files
.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ OPENAI_API_KEY=
2
+
3
+ NEO4J_URI=
4
+ NEO4J_USERNAME=
5
+ NEO4J_CLIENT_ID=
6
+ NEO4J_CLIENT_SECRET=
7
+ # Hugging Face Spaces Deployment Settings
8
+ HF_REPO=
.github/workflows/ci.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+
11
+ steps:
12
+ - name: 소스코드 체크아웃
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Python 3.10 환경 구성
16
+ uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.10"
19
+ cache: "pip" # 의존성 설치 속도 가속
20
+
21
+ - name: 의존성 및 개발 도구 설치
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install -r requirements.txt
25
+ pip install ruff mypy pytest pytest-cov
26
+
27
+ - name: 코드 스타일 및 린트 검사 (Ruff)
28
+ run: ruff check .
29
+
30
+ - name: 정적 타입 검사 (MyPy)
31
+ run: mypy src/ --ignore-missing-imports
32
+
33
+ - name: 테스트 실행 (통합 테스트 자동 Skip 포함)
34
+ run: pytest tests/ -v
35
+
36
+ - name: 테스트 커버리지 리포트 생성
37
+ run: pytest --cov=src --cov-fail-under=20
38
+
39
+ - name: 미사용 코드 검사 (Vulture)
40
+ run: |
41
+ pip install vulture
42
+ vulture src/ --min-confidence 80
.github/workflows/deploy.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Spaces
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ # 수동 실행 허용
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ deploy:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout Source Code
14
+ uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+ lfs: true
18
+
19
+ - name: Push and Sync to Hugging Face
20
+ env:
21
+ # GitHub Repository Secrets에 저장된 변수들을 환경변수로 로드합니다.
22
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
23
+ HF_REPO: ${{ secrets.HF_REPO }}
24
+ run: |
25
+ # 1. Hugging Face Spaces 저장소를 동적 환경변수 기반 원격 추가
26
+ git remote add hf https://huggingface.co/spaces/$HF_REPO || true
27
+
28
+ # 2. 강제 동기화 푸시 (인증 패스워드 자리에 HF_TOKEN 주입)
29
+ git push --force https://user:$HF_TOKEN@huggingface.co/spaces/$HF_REPO main
.gitignore ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ──────────────────────────────────────────
2
+ # 환경변수 / 시크릿
3
+ # ──────────────────────────────────────────
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+
8
+ # ──────────────────────────────────────────
9
+ # Python 가상환경
10
+ # ──────────────────────────────────────────
11
+ .venv/
12
+ venv/
13
+ env/
14
+ __pycache__/
15
+ *.py[cod]
16
+ *.pyo
17
+ *.pyd
18
+ *.egg-info/
19
+ dist/
20
+ build/
21
+
22
+ # ──────────────────────────────────────────
23
+ # Jupyter Notebook 체크포인트
24
+ # ──────────────────────────────────────────
25
+ .ipynb_checkpoints/
26
+ */.ipynb_checkpoints/
27
+
28
+ # ──────────────────────────────────────────
29
+ # 수집 결과 데이터 (엑셀, CSV)
30
+ # 크롤링 결과는 git으로 관리하지 않음
31
+ # ──────────────────────────────────────────
32
+ Articles_*.xlsx
33
+ Articles_*.csv
34
+ *.xlsx
35
+ *.csv
36
+
37
+ # ──────────────────────────────────────────
38
+ # macOS 시스템 파일
39
+ # ──────────────────────────────────────────
40
+ .DS_Store
41
+ .AppleDouble
42
+ .LSOverride
43
+
44
+ # ──────────────────────────────────────────
45
+ # IDE / 에디터 설정
46
+ # ──────────────────────────────────────────
47
+ .vscode/
48
+ .idea/
49
+ *.swp
50
+ *.swo
51
+
52
+ # ──────────────────────────────────────────
53
+ # 로그 / 임시 파일
54
+ # ──────────────────────────────────────────
55
+ *.log
56
+ *.tmp
57
+ *.bak
58
+ *.pyc
59
+
60
+ # ──────────────────────────────────────────
61
+ # 임시 패치 스크립트 (작업 후 삭제해야 할 파일들)
62
+ # ──────────────────────────────────────────
63
+ patch_*.py
64
+ add_viz_cell.py
65
+ create_fingraph.py
66
+ rebuild_*.py
67
+ sync_*.py
68
+ modify_*.py
69
+ force_write_*.py
70
+
71
+ # ──────────────────────────────────────────
72
+ # 참고 자료
73
+ # ──────────────────────────────────────────
74
+ references
75
+
76
+ # ──────────────────────────────────────────
77
+ # 로컬 그래프 백업 데이터 (보안/용량 사유로 제외)
78
+ # ──────────────────────────────────────────
79
+ graph_backup.json
.pre-commit-config.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ # Ruff version.
4
+ rev: v0.15.13
5
+ hooks:
6
+ # Run the linter.
7
+ - id: ruff-check
8
+ # Run the formatter.
9
+ - id: ruff-format
10
+ - repo: https://github.com/pre-commit/mirrors-mypy
11
+ rev: '' # Use the sha / tag you want to point at
12
+ hooks:
13
+ - id: mypy # 타입 검사
AGENTS.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ###### 참고: https://wikidocs.net/340866
2
+
3
+ # AGENTS.md
4
+
5
+ ## 프로젝트 개요
6
+ - 목적:
7
+ - 언어: Python 3.10
8
+ - 기술스택: GraphRAG, LangChain, LangGraph, Neo4j, HugingFace, Gradio
9
+
10
+ ## 디렉토리 구조
11
+ FinNode/
12
+ ├── app.py # Gradio + LangGraph 챗봇 (HF 배포 진입점)
13
+ ├── src/
14
+ │ ├── references/ # 참고용 노트북 (수정 금지)
15
+ │ ├── utils/ # 순수 함수만 (텍스트 전처리 등)
16
+ │ ├── graphBuilder/
17
+ │ │ ├── scrapping/ # 뉴스 크롤링
18
+ │ │ │ ├── finScrapping.py
19
+ │ │ │ └── Articles_*.xlsx
20
+ │ │ └── neo4j/ # 그래프 적재
21
+ │ │ └── finGraph.py
22
+ │ └── retrieval/ # GraphRAG 검색
23
+ │ └── finRetrieval.py
24
+ ├── Dockerfile
25
+ ├── requirements.txt
26
+ ├── .env.example
27
+ ├── AGENTS.md
28
+ ├── README.md
29
+ └── .github/workflows/deploy.yml
30
+
31
+ ## 코드 규칙
32
+ - 함수명: snake_case
33
+ - 클래스명: PascalCase
34
+ - 변수명: camelCase
35
+ - 한 함수는 하나의 역할만 수행한다
36
+ - 타입 힌트 필수
37
+
38
+ ## 절대 금지
39
+ - 'src/references/' 파일 수정 금지(참고자료)
40
+
41
+ ## COMMIT 규칙
42
+ - 커밋 메시지: 'feat:', 'fix:', 'refactor:' 접두사 사용
43
+ - push 하나에 하나의 변경만
44
+ - 테스트 없는 push는 올리지 않는다
45
+
46
+ ## 테스트
47
+ - 테스트 파일 위치: 'tests/' 디렉토리
48
+ - 실행 명령: 'pytest tests/'
49
+ - 반드시 예시 입력으로 테스트한다
50
+
51
+ ### 테스트 케이스로 기대 동작 명시
52
+ 이 프로젝트는 기능의 안정성을 위해 아래의 두 가지 수준의 테스트 코드가 필수적으로 통과해야 합니다.
53
+
54
+ #### 1. 단위 테스트 (Unit Test) - 예시: `chunk_text`
55
+ 외부 의존성(DB, API) 없이 텍스트 전처리 로직이 완벽히 작동하는지 검증합니다.
56
+
57
+ ```python
58
+ # tests/test_chunk_text.py
59
+ def test_chunk_text_empty_returns_empty_list():
60
+ assert chunk_text("") == []
61
+
62
+ def test_chunk_text_short_text_returns_single_chunk():
63
+ result = chunk_text("짧은 텍스트", size=500, overlap=50)
64
+ assert len(result) == 1
65
+
66
+ def test_chunk_text_long_text_splits_into_multiple_chunks():
67
+ result = chunk_text("가" * 1000, size=500, overlap=50)
68
+ assert len(result) >= 2
69
+ ```
70
+
71
+ #### 2. 통합 및 RAG 시나리오 테스트 (Integration Test) - 예시: `GraphRAG`
72
+ 실제 뉴스 지식 그래프가 빌드된 후, 임의의 최신 데이터를 동적으로 탐색하여 포트폴리오 수준의 완성도 높은 답변을 도출하는지 검증합니다.
73
+
74
+ ```python
75
+ # tests/test_retrieval.py
76
+ def test_portfolio_showcase_aggregation_query():
77
+ """
78
+ [포트폴리오 핵심 골드 시나리오]
79
+ 특정 기업 고정 없이, '금융AI' 분야의 적극적인 기업 TOP 3와 대표 서비스를
80
+ 그래프 탐색을 통해 완벽한 근거(출처)와 함께 응답하는지 검증합니다.
81
+ """
82
+ showcase_query = "최근 수집된 뉴스에서 금융AI(AIField) 분야에 가장 적극적으로 기술을 개발하고 있는 기업 TOP 3와 그 기업들이 개발한 대표 서비스를 알려줘."
83
+ response = graphrag.search(query_text=showcase_query)
84
+
85
+ assert response is not None
86
+ assert len(response.answer.strip()) > 0
87
+ # 출처 표기 및 랭킹 구조화 지침 준수 여부 검증
88
+ assert any(indicator in response.answer for indicator in ["1.", "TOP", "기사", "출처"]) # 일종의 skill
89
+ ```
90
+
91
+ ## 자동 검사
92
+ - 커밋 전 `pre-commit` 자동 실행
93
+ - `ruff`, `mypy` 검사 통과 필수
94
+ - 검사 실패 시 커밋 불가
95
+
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Base image with Python 3.10
2
+ FROM python:3.10-slim
3
+
4
+ # Install system dependencies including Chrome (Chromium) and ChromeDriver for Selenium
5
+ RUN apt-get update && apt-get install -y \
6
+ wget \
7
+ gnupg \
8
+ unzip \
9
+ curl \
10
+ chromium \
11
+ chromium-driver \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Set working directory inside container
15
+ WORKDIR /app
16
+
17
+ # Create a non-root user (UID 1000) for Hugging Face Spaces compatibility
18
+ RUN useradd -m -u 1000 user
19
+ RUN chown user:user /app
20
+ USER user
21
+ ENV HOME=/home/user \
22
+ PATH=/home/user/.local/bin:$PATH \
23
+ CHROME_BIN=/usr/bin/chromium \
24
+ CHROMEDRIVER_PATH=/usr/bin/chromedriver \
25
+ PYTHONPATH=/app
26
+
27
+ # Copy requirements and install python dependencies
28
+ COPY --chown=user web_app/requirements.txt /app/requirements.txt
29
+ RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
30
+
31
+ # Copy all essential packages into container working directory
32
+ COPY --chown=user pipeline /app/pipeline
33
+ COPY --chown=user web_app /app/web_app
34
+
35
+ # Expose standard Hugging Face Space port
36
+ EXPOSE 7860
37
+
38
+ # Run Streamlit on port 7860 and address 0.0.0.0 (app.py is inside web_app/)
39
+ CMD ["streamlit", "run", "web_app/app.py", "--server.port", "7860", "--server.address", "0.0.0.0"]
README.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FinNode 🕸️
2
+
3
+ **Neo4j GraphRAG 기반 AI 뉴스 지식 그래프 플랫폼**
4
+
5
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
6
+ [![Neo4j](https://img.shields.io/badge/Neo4j-AuraDB-blue.svg)](https://neo4j.com/)
7
+ [![LangGraph](https://img.shields.io/badge/LangGraph-Pipeline-orange.svg)](https://langchain.com/)
8
+ [![Gradio](https://img.shields.io/badge/Gradio-UI-red.svg)](https://gradio.app/)
9
+ [![CI](https://github.com/yuje/FinGraph/actions/workflows/ci.yml/badge.svg)](https://github.com/yuje/FinGraph/actions/workflows/ci.yml)
10
+
11
+ ---
12
+
13
+ ## 📝 보고서
14
+ > [최종 기획안 및 프로젝트 보고서 (업데이트 예정)]()
15
+
16
+ ## 🎥 시연 영상
17
+ > [서비스 시연 영상 링크 (업데이트 예정)]()
18
+
19
+ ---
20
+
21
+ ## 1. 프로젝트 배경 및 목적
22
+ 최신 AI 기술과 핀테크 트렌드는 빠르게 변화하며, 일반적인 RAG(검색 증강 생성) 기술만으로는 여러 뉴스 기사에 흩어져 있는 **'기업-기술-서비스' 간의 복잡한 관계**를 파악하기 어렵습니다.
23
+
24
+ **FinNode**는 네이버 뉴스에서 AI 관련 기사를 실시간으로 수집하고, **LangGraph 파이프라인**을 통해 엔티티와 관계를 자동 추출하여 **Neo4j 지식 그래프**에 적재합니다. 이를 기반으로 Vector 및 Cypher 복합 검색(GraphRAG)을 수행하여, 단순한 문서 검색을 넘어 **"현재 금융AI 분야에서 가장 적극적인 기업과 기술 트렌드"**를 완벽한 근거와 함께 추론하고 답변하는 차세대 챗봇 시스템입니다.
25
+
26
+ ---
27
+
28
+ ## 2. 시스템 아키텍처
29
+
30
+ ```text
31
+ [Naver News]
32
+ │ Selenium 크롤링
33
+
34
+ [LangGraph Pipeline] (gpt-4o-mini)
35
+ check_ai ──(AI 아님)──▶ 스킵
36
+ │ (AI 관련)
37
+
38
+ extract_entities
39
+
40
+
41
+ extract_relations
42
+
43
+
44
+ [Neo4j AuraDB]
45
+ Article / Content / AICompany / AITechnology / AIService / AIField / Media
46
+
47
+
48
+ [GraphRAG ToolsRetriever] ──▶ gpt-4o 최종 답변 생성
49
+
50
+
51
+ [Gradio 챗봇 UI (Hugging Face Spaces 배포)]
52
+ ```
53
+
54
+ ---
55
+
56
+ ## 3. 주요 기능
57
+
58
+ - **실시간 뉴스 크롤링**: Selenium 헤드리스 브라우저로 네이버 뉴스 카테고리별 기사 자동 수집
59
+ - **LangGraph AI 파이프라인**: 수집된 기사를 3단계 자동 분석 (`판별` → `엔티티 추출` → `관계 추출`)
60
+ - **Neo4j 지식 그래프 적재**: 추출된 엔티티(Company, Tech, Service 등)와 관계를 MERGE 트랜잭션으로 중복 없이 DB 적재
61
+ - **GraphRAG 챗봇**: 3가지 Retriever를 통합한 ToolsRetriever 기반 자연어 질의응답
62
+ - `Vector Retriever`: 본문 청크 의미 유사도 검색
63
+ - `VectorCypher Retriever`: 벡터 검색 후 해당 기사의 연관 그래프(기업·기술·서비스) 반환 (트렌드 분석에 최적화)
64
+ - `Text2Cypher Retriever`: 자연어 → Cypher 쿼리 자동 변환 및 데이터 집계
65
+
66
+ ---
67
+
68
+ ## 4. 기술 스택
69
+
70
+ - **Language**: Python 3.10
71
+ - **AI / LLM**: LangChain, LangGraph, OpenAI (`gpt-4o`, `text-embedding-3-small`)
72
+ - **Database**: Neo4j (AuraDB Cloud)
73
+ - **Web / Crawling**: Gradio, Selenium, Pandas
74
+ - **CI/CD**: GitHub Actions, Hugging Face Spaces
75
+
76
+ ---
77
+
78
+ ## 5. 그래프 스키마
79
+
80
+ ### 노드 및 관계
81
+ | 구분 | 내용 |
82
+ |------|-----------|
83
+ | **노드 (Nodes)** | `Article`, `Content`, `AICompany`, `AITechnology`, `AIService`, `AIField`, `Media`, `Category` |
84
+ | **관계 (Edges)** | `HAS_CHUNK`, `PUBLISHED`, `BELONGS_TO`, `MENTIONS`, `DEVELOPS`, `INVESTS_IN`, `PARTNERS_WITH`, `APPLIES`, `USED_IN`, `RELATED_TO` |
85
+
86
+ ---
87
+
88
+ ## 6. 설치 및 실행 가이드
89
+
90
+ ### 사전 준비
91
+ - Python 3.10+
92
+ - Neo4j AuraDB 인스턴스 (또는 로컬 Neo4j)
93
+ - OpenAI API Key
94
+
95
+ ### 로컬 실행
96
+ ```bash
97
+ # 1. 저장소 클론
98
+ git clone https://github.com/yuje/FinGraph.git
99
+ cd FinGraph
100
+
101
+ # 2. 가상환경 생성 및 의존성 설치
102
+ python -m venv .venv
103
+ source .venv/bin/activate
104
+ pip install -r requirements.txt
105
+
106
+ # 3. 환경 변수 설정
107
+ cp .env.example .env
108
+ # .env 파일에 OpenAI Key, Neo4j 접속 정보 입력
109
+
110
+ # 4. Gradio 앱 실행
111
+ python app.py
112
+ ```
113
+ 브라우저에서 `http://localhost:7860` 접속
114
+
115
+ ---
116
+
117
+ ## 7. 배포 (Hugging Face Spaces)
118
+
119
+ GitHub → Hugging Face Spaces 자동 배포가 `deploy.yml`을 통해 설정되어 있습니다.
120
+ `main` 브랜치에 Push 시 자동으로 동기화됩니다.
121
+
122
+ 1. **Hugging Face 토큰 발급**: Settings → Tokens에서 Write 권한 토큰 생성
123
+ 2. **GitHub Secrets 등록**: `HF_TOKEN`, `HF_REPO` (예: yuje/FinNode) 등록
124
+ 3. **HF Space Secrets 등록**: `.env` 항목(OpenAI, Neo4j 키) 동일하게 등록
app.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — FinNode GraphRAG 챗봇
3
+ ================================
4
+ Hugging Face Spaces 배포 진입점.
5
+ Gradio ChatInterface + LangGraph 기반 대화 흐름 제어.
6
+
7
+ 실행:
8
+ python app.py
9
+ """
10
+
11
+ import os
12
+ import dotenv
13
+ import gradio as gr
14
+ from typing import TypedDict, List
15
+ from langgraph.graph import StateGraph, END
16
+ from src.retrieval.finRetrieval import graphrag
17
+
18
+ dotenv.load_dotenv()
19
+
20
+ # ──────────────────────────────────────────
21
+ # 1. LangGraph 챗봇 State 정의
22
+ # ──────────────────────────────────────────
23
+
24
+ class ChatState(TypedDict):
25
+ question: str # 사용자 질문
26
+ history: List[dict] # 대화 히스토리 [{"role": "user"/"assistant", "content": "..."}]
27
+ context: str # GraphRAG 검색 결과
28
+ answer: str # 최종 답변
29
+
30
+
31
+ # ──────────────────────────────────────────
32
+ # 2. LangGraph 노드 정의
33
+ # ──────────────────────────────────────────
34
+
35
+ def retrieve_node(state: ChatState) -> ChatState:
36
+ """Node 1: GraphRAG로 관련 컨텍스트 검색"""
37
+ try:
38
+ result = graphrag.search(query_text=state["question"])
39
+ context = result.answer # GraphRAG가 이미 답변을 완성하므로 바로 사용
40
+ except Exception as e:
41
+ context = f"[검색 오류: {e}]"
42
+ return {**state, "context": context}
43
+
44
+
45
+ def generate_node(state: ChatState) -> ChatState:
46
+ """Node 2: 대화 히스토리를 고려하여 최종 답변 생성
47
+
48
+ GraphRAG가 이미 검색 + 생성을 처리하므로,
49
+ 여기서는 히스토리 기반 후처리나 추가 포맷팅만 수행합니다.
50
+ """
51
+ # GraphRAG 결과를 바로 답변으로 사용
52
+ # (히스토리 기반 후속 질문 처리가 필요하면 이 노드를 확장하세요)
53
+ answer = state["context"] if state["context"] else "관련 정보를 찾을 수 없습니다."
54
+ return {**state, "answer": answer}
55
+
56
+
57
+ # ──────────────────────────────────────────
58
+ # 3. LangGraph 워크플로우 컴파일
59
+ # ──────────────────────────────────────────
60
+
61
+ builder = StateGraph(ChatState)
62
+ builder.add_node("retrieve", retrieve_node)
63
+ builder.add_node("generate", generate_node)
64
+ builder.set_entry_point("retrieve")
65
+ builder.add_edge("retrieve", "generate")
66
+ builder.add_edge("generate", END)
67
+
68
+ chat_graph = builder.compile()
69
+
70
+
71
+ # ──────────────────────────────────────────
72
+ # 4. Gradio 연동 함수
73
+ # ──────────────────────────────────────────
74
+
75
+ def chat(message: str, history: list) -> str:
76
+ """Gradio ChatInterface가 호출하는 함수.
77
+
78
+ Args:
79
+ message: 사용자 입력 메시지
80
+ history: Gradio가 관리하는 대화 히스토리
81
+ [{"role": "user"/"assistant", "content": "..."}] 형식
82
+
83
+ Returns:
84
+ str: 챗봇 답변
85
+ """
86
+ if not message.strip():
87
+ return "질문을 입력해 주세요."
88
+
89
+ # Gradio history → LangGraph state 형식으로 변환
90
+ state: ChatState = {
91
+ "question": message,
92
+ "history": history,
93
+ "context": "",
94
+ "answer": "",
95
+ }
96
+
97
+ result = chat_graph.invoke(state)
98
+ return result["answer"]
99
+
100
+
101
+ # ──────────────────────────────────────────
102
+ # 5. Gradio UI 구성
103
+ # ──────────────────────────────────────────
104
+
105
+ with gr.Blocks(
106
+ title="FinNode — AI 기업 트렌드 분석 챗봇",
107
+ theme=gr.themes.Soft(primary_hue="indigo"),
108
+ ) as demo:
109
+ gr.Markdown(
110
+ """
111
+ # 🔗 FinNode — AI 기업 트렌드 분석 챗봇
112
+ > 최신 AI 뉴스를 기반으로 구축된 지식 그래프(GraphRAG)에서 답변합니다.
113
+
114
+ **예시 질문**
115
+ - 삼성전자의 최근 AI 기술 트렌드는?
116
+ - 카카오가 개발 중인 AI 서비스 목록을 알려줘
117
+ - 어떤 기업이 LLM 기술을 개발하나요?
118
+ - 최근 AI 관련 뉴스 기사를 요약해줘
119
+ """
120
+ )
121
+
122
+ chatbot = gr.ChatInterface(
123
+ fn=chat,
124
+ type="messages", # Gradio 4.x 이상 표준 형식
125
+ chatbot=gr.Chatbot(
126
+ height=500,
127
+ placeholder="질문을 입력하면 지식 그래프에서 답변을 찾아드립니다.",
128
+ ),
129
+ textbox=gr.Textbox(
130
+ placeholder="예: 네이버의 AI 기술 트렌드는 무엇인가요?",
131
+ container=False,
132
+ scale=7,
133
+ ),
134
+ examples=[
135
+ "삼성전자의 최근 AI 기술 트렌드는?",
136
+ "카카오가 개발 중인 AI 서비스 목록을 알려줘",
137
+ "어떤 기업이 LLM 기술을 개발하나요?",
138
+ "최근 AI 관련 뉴스 기사를 요약해줘",
139
+ ],
140
+ retry_btn=None,
141
+ undo_btn="↩️ 되돌리기",
142
+ clear_btn="🗑️ 대화 초기화",
143
+ )
144
+
145
+ if __name__ == "__main__":
146
+ demo.launch()
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pyproject.toml
2
+
3
+ [tool.ruff]
4
+ line-length = 88
5
+ target-version = "py310"
6
+
7
+ [tool.ruff.lint]
8
+ select = [
9
+ "E", # pycodestyle 기본 규칙
10
+ "F", # pyflakes (미사용 변수 등)
11
+ "I", # isort (import 정렬)
12
+ "N", # 네이밍 규칙
13
+ ]
14
+ ignore = []
15
+
16
+ # 절대 import만 허용 (상대 import 금지)
17
+ [tool.ruff.lint.flake8-tidy-imports]
18
+ ban-relative-imports = "all"
19
+
20
+ # vulture로 사용하지 않는 코드 확인
21
+ [tool.vulture]
22
+ min_confidence = 80
23
+ paths = ["src/"]
24
+ ignore_names = ["test_*", "setUp", "tearDown"]
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GraphRAG 핵심
2
+ neo4j-graphrag[openai]
3
+ langchain-openai
4
+ langgraph
5
+
6
+ # Gradio UI (HF Spaces 배포)
7
+ gradio>=4.0.0
8
+
9
+ # 데이터 크롤링 및 처리
10
+ selenium
11
+ webdriver-manager
12
+ pandas
13
+ openpyxl
14
+
15
+ # 환경변수 관리
16
+ python-dotenv
run_pipeline.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pipeline.workflow import pipeline
3
+ from pipeline.db_writer import write_graph_to_neo4j, chunk_and_embed_article
4
+
5
+ def run_test():
6
+ # 1. 모의 테스트용 뉴스 기사 데이터 준비
7
+ test_article = {
8
+ "article_id": "TEST_ART_999",
9
+ "title": "OpenAI, 차세대 인공지능 GPT-5 전격 공개 및 금융AI 적용 선언",
10
+ "content": (
11
+ "인공지능 대표 기업 OpenAI가 새로운 초지능 언어 모델인 GPT-5를 전격 발표했습니다. "
12
+ "이번 모델은 고도의 금융분야 추론 능력을 극대화하여 다양한 금융AI(Financial AI) 시스템에 즉각 적용(APPLIES)됩니다. "
13
+ "OpenAI는 이를 위해 글로벌 대형 금융사인 골드만삭스와 전략적 파트너십(PARTNERS_WITH)을 체결하고 상용 솔루션을 공동 공급하기로 합의했습니다."
14
+ ),
15
+ "url": "https://example.com/news/gpt5-finance",
16
+ "published_date": "2026-05-19 09:30",
17
+ "source": "테크파이낸셜"
18
+ }
19
+
20
+ print("==================================================")
21
+ print("🚀 [1/3] LangGraph AI 분석 엔진 가동 (nodes.py)")
22
+ print("==================================================")
23
+
24
+ # 2. LangGraph 상태 초기화 및 파이프라인 구동
25
+ initial_state = {
26
+ "article_id": test_article["article_id"],
27
+ "title": test_article["title"],
28
+ "text": test_article["title"] + "\n" + test_article["content"],
29
+ "is_ai_related": False,
30
+ "entities": [],
31
+ "relations": []
32
+ }
33
+
34
+ # 컴파일된 파이프라인 가동
35
+ output_state = pipeline.invoke(initial_state)
36
+
37
+ print(f"👉 AI 뉴스 여부 판별: {output_state['is_ai_related']}")
38
+ print(f"👉 추출된 지식 엔티티 목록 (총 {len(output_state['entities'])}개):")
39
+ print(json.dumps(output_state['entities'], indent=2, ensure_ascii=False))
40
+ print(f"👉 추출된 엔티티 간 관계선 목록 (총 {len(output_state['relations'])}개):")
41
+ print(json.dumps(output_state['relations'], indent=2, ensure_ascii=False))
42
+
43
+ # 3. 데이터베이스 적재 실행
44
+ if output_state['is_ai_related']:
45
+ print("\n==================================================")
46
+ print("💾 [2/3] Neo4j AuraDB 지식 그래프 노드 및 관계선 적재")
47
+ print("==================================================")
48
+ write_graph_to_neo4j(test_article, output_state['entities'], output_state['relations'])
49
+ print("✅ 지식 그래프 적재 완료 (MERGE 트랜잭션 성공)")
50
+
51
+ print("\n==================================================")
52
+ print("🧠 [3/3] 본문 청킹 및 OpenAI text-embedding-3-small 벡터화")
53
+ print("==================================================")
54
+ chunk_and_embed_article(test_article)
55
+ print("✅ 벡터 적재 완료 (HAS_CHUNK 노드 매핑 성공)")
56
+ print("\n🎉 모든 파이프라인 단독 구동 테스트가 완벽히 성공했습니다!")
57
+ else:
58
+ print("\n⏭️ AI 관련 기사가 아니므로 그래프 상세 분석 및 벡터 적재를 건너뜁니다.")
59
+
60
+ if __name__ == "__main__":
61
+ run_test()
src/__init__.py ADDED
File without changes
src/graphBuilder/__init__.py ADDED
File without changes
src/graphBuilder/neo4j/__init__.py ADDED
File without changes
src/graphBuilder/neo4j/finGraph.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ finGraph.py — AI 뉴스 지식 그래프 빌더
3
+ =====================================
4
+ 실행 순서:
5
+ 1. finScrapping.py 실행 → Articles_*.xlsx 생성
6
+ 2. 이 파일 실행 → Neo4j에 엔티티/관계/벡터 적재
7
+
8
+ 노드: AICompany, AITechnology, AIService, AIField, Article, Content, Media
9
+ 관계: DEVELOPS, INVESTS_IN, PARTNERS_WITH, APPLIES, USED_IN, RELATED_TO,
10
+ MENTIONS, HAS_CHUNK, PUBLISHED
11
+ """
12
+
13
+ import os
14
+ import glob
15
+ import json
16
+ import pandas as pd
17
+ import neo4j
18
+ import dotenv
19
+ from typing import TypedDict, List, Dict
20
+ from langchain_openai import ChatOpenAI
21
+ from langgraph.graph import StateGraph, END
22
+ from neo4j_graphrag.llm import OpenAILLM
23
+ from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings
24
+ from neo4j_graphrag.indexes import create_vector_index
25
+
26
+ dotenv.load_dotenv()
27
+
28
+ URI = os.getenv("NEO4J_URI", "neo4j://localhost:7687")
29
+ AUTH = (os.getenv("NEO4J_USERNAME", "neo4j"), os.getenv("NEO4J_PASSWORD", "password"))
30
+ driver = neo4j.GraphDatabase.driver(URI, auth=AUTH)
31
+
32
+ chat_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
33
+ rag_llm = OpenAILLM(model_name="gpt-4o", model_params={"temperature": 0})
34
+ embedder = OpenAIEmbeddings(model="text-embedding-3-small")
35
+
36
+ INDEX_NAME = "content_vector_index"
37
+
38
+ # ──────────────────────────────────────────
39
+ # 1. LangGraph 파이프라인 정의 (엔티티/관계 추출)
40
+ # ──────────────────────────────────────────
41
+
42
+ class ArticleState(TypedDict):
43
+ article_id: str
44
+ title: str
45
+ text: str
46
+ is_ai_related: bool
47
+ entities: List[Dict]
48
+ relations: List[Dict]
49
+
50
+
51
+ def check_ai_relevance(state: ArticleState) -> ArticleState:
52
+ """Node 1: AI 관련 여부 판별"""
53
+ prompt = (
54
+ "다음 기사가 AI(인공지능) 기술·기업·서비스와 관련 있으면 yes, 아니면 no로만 답하세요.\n\n"
55
+ f"{state['text'][:400]}\n\n답변(yes/no):"
56
+ )
57
+ res = chat_llm.invoke(prompt)
58
+ return {**state, "is_ai_related": res.content.strip().lower().startswith("yes")}
59
+
60
+
61
+ def extract_entities(state: ArticleState) -> ArticleState:
62
+ """Node 2: 엔티티 추출"""
63
+ prompt = f"""다음 AI 뉴스에서 엔티티를 추출하세요.
64
+ 엔티티 유형:
65
+ - AICompany: 기업/기관 (예: 삼성전자, OpenAI)
66
+ - AITechnology: AI 기술 (예: 대규모언어모델, 강화학습)
67
+ - AIService: 서비스/제품 (예: ChatGPT, HyperCLOVA X)
68
+ - AIField: 적용 분야 (예: 금융AI, AI 반도체)
69
+
70
+ 제목: {state['title']}
71
+ 본문: {state['text'][:900]}
72
+
73
+ JSON으로만 응답:{{"entities":[{{"name":"...","type":"AICompany|AITechnology|AIService|AIField","description":"..."}}]}}"""
74
+ res = chat_llm.invoke(prompt)
75
+ try:
76
+ raw = res.content.strip()
77
+ if "```" in raw:
78
+ raw = raw.split("```")[1].lstrip("json")
79
+ entities = json.loads(raw).get("entities", [])
80
+ except Exception:
81
+ entities = []
82
+ return {**state, "entities": entities}
83
+
84
+
85
+ def extract_relations(state: ArticleState) -> ArticleState:
86
+ """Node 3: 관계 추출"""
87
+ if not state["entities"]:
88
+ return {**state, "relations": []}
89
+ elist = "\n".join([f"- {e['name']} ({e['type']})" for e in state["entities"]])
90
+ prompt = (
91
+ f"엔티티 목록:\n{elist}\n\n"
92
+ "관계 유형: DEVELOPS, INVESTS_IN, PARTNERS_WITH, APPLIES, USED_IN, RELATED_TO\n"
93
+ f"본문: {state['text'][:700]}\n\n"
94
+ '공으로만:{"relations":[{"source":"...","relation":"...","target":"..."}]}'
95
+ )
96
+ res = chat_llm.invoke(prompt)
97
+ try:
98
+ raw = res.content.strip()
99
+ if "```" in raw:
100
+ raw = raw.split("```")[1].lstrip("json")
101
+ relations = json.loads(raw).get("relations", [])
102
+ names = {e["name"] for e in state["entities"]}
103
+ relations = [r for r in relations if r.get("source") in names and r.get("target") in names]
104
+ except Exception:
105
+ relations = []
106
+ return {**state, "relations": relations}
107
+
108
+
109
+ def route_after_check(state: ArticleState) -> str:
110
+ return "extract_entities" if state["is_ai_related"] else END
111
+
112
+
113
+ builder = StateGraph(ArticleState)
114
+ builder.add_node("check_ai", check_ai_relevance)
115
+ builder.add_node("extract_entities", extract_entities)
116
+ builder.add_node("extract_relations", extract_relations)
117
+ builder.set_entry_point("check_ai")
118
+ builder.add_conditional_edges("check_ai", route_after_check)
119
+ builder.add_edge("extract_entities", "extract_relations")
120
+ builder.add_edge("extract_relations", END)
121
+ pipeline = builder.compile()
122
+
123
+
124
+ # ──────────────────────────────────────────
125
+ # 2. Neo4j 스키마 초기화 및 적재 함수
126
+ # ──────────────────────────────────────────
127
+
128
+ ENTITY_TYPE_MAP = {
129
+ "AICompany": "AICompany",
130
+ "AITechnology": "AITechnology",
131
+ "AIService": "AIService",
132
+ "AIField": "AIField",
133
+ }
134
+
135
+
136
+ def setup_schema(tx) -> None:
137
+ constraints = [
138
+ "CREATE CONSTRAINT IF NOT EXISTS FOR (n:AICompany) REQUIRE n.name IS UNIQUE",
139
+ "CREATE CONSTRAINT IF NOT EXISTS FOR (n:AITechnology) REQUIRE n.name IS UNIQUE",
140
+ "CREATE CONSTRAINT IF NOT EXISTS FOR (n:AIService) REQUIRE n.name IS UNIQUE",
141
+ "CREATE CONSTRAINT IF NOT EXISTS FOR (n:AIField) REQUIRE n.name IS UNIQUE",
142
+ "CREATE CONSTRAINT IF NOT EXISTS FOR (n:Article) REQUIRE n.article_id IS UNIQUE",
143
+ "CREATE CONSTRAINT IF NOT EXISTS FOR (n:Content) REQUIRE n.content_id IS UNIQUE",
144
+ "CREATE CONSTRAINT IF NOT EXISTS FOR (n:Media) REQUIRE n.name IS UNIQUE",
145
+ ]
146
+ for c in constraints:
147
+ try:
148
+ tx.run(c)
149
+ except Exception:
150
+ pass
151
+
152
+
153
+ def upsert_entity(tx, e: Dict) -> None:
154
+ ntype = ENTITY_TYPE_MAP.get(e.get("type", "AICompany"), "AICompany")
155
+ tx.run(
156
+ f"MERGE (n:{ntype} {{name:$name}}) "
157
+ "ON CREATE SET n.description=$desc "
158
+ "ON MATCH SET n.description=COALESCE(n.description,$desc)",
159
+ name=e["name"], desc=e.get("description", ""),
160
+ )
161
+
162
+
163
+ def upsert_relation(tx, r: Dict) -> None:
164
+ rel = r.get("relation", "RELATED_TO").upper().replace(" ", "_")
165
+ allowed = {"DEVELOPS", "INVESTS_IN", "PARTNERS_WITH", "APPLIES", "USED_IN", "RELATED_TO"}
166
+ if rel not in allowed:
167
+ return
168
+ try:
169
+ tx.run(
170
+ f"MATCH (s {{name:$src}}) MATCH (t {{name:$tgt}}) MERGE (s)-[:{rel}]->(t)",
171
+ src=r["source"], tgt=r["target"],
172
+ )
173
+ except Exception:
174
+ pass
175
+
176
+
177
+ def upsert_article_and_mentions(tx, row: pd.Series, entities: List[Dict]) -> None:
178
+ tx.run(
179
+ "MERGE (a:Article {article_id:$aid}) "
180
+ "SET a.title=$title, a.url=$url, a.published_date=$date",
181
+ aid=row.get("article_id", ""), title=row.get("title", ""),
182
+ url=row.get("url", ""), date=str(row.get("published_date", "")),
183
+ )
184
+ if pd.notna(row.get("source", "")):
185
+ tx.run(
186
+ "MERGE (m:Media {name:$src}) "
187
+ "WITH m MATCH (a:Article {article_id:$aid}) MERGE (m)-[:PUBLISHED]->(a)",
188
+ src=row["source"], aid=row.get("article_id", ""),
189
+ )
190
+ for e in entities:
191
+ ntype = ENTITY_TYPE_MAP.get(e.get("type", "AICompany"), "AICompany")
192
+ try:
193
+ tx.run(
194
+ f"MATCH (a:Article {{article_id:$aid}}) "
195
+ f"MATCH (n:{ntype} {{name:$name}}) MERGE (a)-[:MENTIONS]->(n)",
196
+ aid=row.get("article_id", ""), name=e["name"],
197
+ )
198
+ except Exception:
199
+ pass
200
+
201
+
202
+ def chunk_text(text: str, size: int = 500, overlap: int = 50) -> List[str]:
203
+ if not text or pd.isna(text):
204
+ return []
205
+ text = str(text)
206
+ return [
207
+ text[i:i + size].strip()
208
+ for i in range(0, len(text), size - overlap)
209
+ if text[i:i + size].strip()
210
+ ]
211
+
212
+
213
+ # ──────────────────────────────────────────
214
+ # 3. 메인 실행 (스크립트로 직접 호출 시)
215
+ # ──────────────────────────────────────────
216
+
217
+ def main() -> None:
218
+ # 최신 엑셀 로드
219
+ xlsx_files = sorted(glob.glob("Articles_*.xlsx"))
220
+ if not xlsx_files:
221
+ raise FileNotFoundError("Articles_*.xlsx 파일이 없습니다. finScrapping.py를 먼저 실행하세요.")
222
+ latest_file = xlsx_files[-1]
223
+ df = pd.read_excel(latest_file)
224
+ print(f"✅ 로드 완료: {latest_file} ({len(df)}건)")
225
+
226
+ # Neo4j 초기화
227
+ with driver.session() as s:
228
+ s.execute_write(lambda tx: tx.run("MATCH (n) DETACH DELETE n"))
229
+ s.execute_write(setup_schema)
230
+ print("✅ Neo4j 초기화 완료")
231
+
232
+ # 엔티티/관계 추출 및 적재
233
+ print(f"총 {len(df)}건 처리 시작...")
234
+ for idx, row in df.iterrows():
235
+ aid = str(row.get("article_id", f"ART_{idx}"))
236
+ title = str(row.get("title", ""))
237
+ text = title + "\n" + str(row.get("content", ""))
238
+ state: ArticleState = dict(
239
+ article_id=aid, title=title, text=text,
240
+ is_ai_related=False, entities=[], relations=[],
241
+ )
242
+ out = pipeline.invoke(state)
243
+ if out["is_ai_related"]:
244
+ with driver.session() as s:
245
+ for e in out["entities"]:
246
+ s.execute_write(upsert_entity, e)
247
+ for r in out["relations"]:
248
+ s.execute_write(upsert_relation, r)
249
+ s.execute_write(upsert_article_and_mentions, row, out["entities"])
250
+ print(f" ✅ [{idx+1}/{len(df)}] {title[:35]}... | 엔티티: {[e['name'] for e in out['entities'][:4]]}")
251
+ else:
252
+ print(f" ⏭️ [{idx+1}/{len(df)}] AI 비관련: {title[:35]}...")
253
+ print("\n✅ 엔티티/관계 추출 및 Neo4j 적재 완료")
254
+
255
+ # Content 청킹 + 임베딩
256
+ print("Content 노드 생성 및 임베딩 시작...")
257
+ for idx, row in df.iterrows():
258
+ aid = str(row.get("article_id", f"ART_{idx}"))
259
+ chunks = chunk_text(str(row.get("content", "")))
260
+ with driver.session() as s:
261
+ for i, chunk in enumerate(chunks):
262
+ cid = f"{aid}_chunk_{i}"
263
+ vec = embedder.embed_query(chunk)
264
+ s.run(
265
+ "MERGE (c:Content {content_id:$cid}) "
266
+ "SET c.chunk=$chunk, c.article_id=$aid, c.chunk_index=$i, c.embedding=$vec "
267
+ "WITH c MATCH (a:Article {article_id:$aid}) MERGE (a)-[:HAS_CHUNK]->(c)",
268
+ cid=cid, chunk=chunk, aid=aid, i=i, vec=vec,
269
+ )
270
+ print("✅ Content 노드 임베딩 완료")
271
+
272
+ # 벡터 인덱스 생성
273
+ create_vector_index(driver, INDEX_NAME, label="Content",
274
+ embedding_property="embedding", dimensions=1536, similarity_fn="cosine")
275
+ print(f"✅ 벡터 인덱스 [{INDEX_NAME}] 생성 완료")
276
+
277
+
278
+ if __name__ == "__main__":
279
+ main()
src/graphBuilder/scrapping/__init__.py ADDED
File without changes
src/graphBuilder/scrapping/finScrapping.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+ from selenium import webdriver
5
+ from selenium.webdriver.common.by import By
6
+ from webdriver_manager.chrome import ChromeDriverManager
7
+ from selenium.webdriver.chrome.service import Service
8
+ import pandas as pd
9
+ import time
10
+ from datetime import datetime
11
+ import re
12
+ from collections import Counter
13
+
14
+ # 수집 대상 카테고리
15
+ categories = {
16
+ '경제': 'https://news.naver.com/section/101',
17
+ 'IT/과학': 'https://news.naver.com/section/105',
18
+ }
19
+ NUM_ARTICLES_PER_CATEGORY = 80
20
+
21
+ # AI 핀테크 키워드 (FinNode 프로젝트 전용)
22
+ FINTECH_AI_KEYWORDS = [
23
+ # AI 기술
24
+ 'AI', '인공지능', '생성형 AI', '대규모언어모델',
25
+ # AI 핀테크 (금융)
26
+ '핀테크',
27
+ ]
28
+
29
+ print('[INIT] ChromeDriver 초기화 중...')
30
+ service = Service(ChromeDriverManager().install())
31
+ options = webdriver.ChromeOptions()
32
+ options.add_argument('--no-sandbox')
33
+ options.add_argument('--disable-dev-shm-usage')
34
+ driver = webdriver.Chrome(service=service, options=options)
35
+ print('[INIT] ✅ 브라우저 실행 완료')
36
+
37
+ def get_article_links(driver, category_url, num_articles):
38
+ print(f' [LINK] 페이지 이동: {category_url}')
39
+ driver.get(category_url)
40
+ time.sleep(3)
41
+ print(f' [LINK] 로드 완료 (title: {driver.title})')
42
+
43
+ article_links = []
44
+ selectors = [
45
+ 'a.sa_text_title', 'a.sa_text_lede', 'a.sa_text_strong',
46
+ '.sa_text a', '.cluster_text_headline a', '.cluster_text_lede a'
47
+ ]
48
+
49
+ for selector in selectors:
50
+ elements = driver.find_elements(By.CSS_SELECTOR, selector)
51
+ print(f" [LINK] 셀렉터 '{selector}' -> {len(elements)}개 발견")
52
+ for element in elements:
53
+ url = element.get_attribute('href')
54
+ if (url and 'news.naver.com' in url and '/article/' in url
55
+ and '/comment/' not in url and url not in article_links):
56
+ article_links.append(url)
57
+ if len(article_links) >= num_articles:
58
+ break
59
+ if len(article_links) >= num_articles:
60
+ break
61
+
62
+ print(f' [LINK] ✅ 총 {len(article_links)}개 링크 확보\n')
63
+ return article_links[:num_articles]
64
+
65
+ def parse_article_detail(driver, article_url, category):
66
+ driver.get(article_url)
67
+ time.sleep(1.5)
68
+ article_data = {
69
+ 'article_id': '', 'title': '', 'content': '', 'url': article_url,
70
+ 'published_date': '', 'source': '', 'author': '', 'category': category
71
+ }
72
+ try:
73
+ match = re.search(r'article/(\d+)/(\d+)', article_url)
74
+ article_data['article_id'] = (
75
+ f"ART_{match.group(1)}_{match.group(2)}" if match
76
+ else f"ART_{datetime.now().strftime('%Y%m%d%H%M%S')}"
77
+ )
78
+ for sel in ['#title_area span', '#ct .media_end_head_headline',
79
+ '.media_end_head_headline', 'h2#title_area', '.news_end_title']:
80
+ try:
81
+ el = driver.find_element(By.CSS_SELECTOR, sel)
82
+ if el.text.strip():
83
+ article_data['title'] = el.text.strip(); break
84
+ except: continue
85
+ for sel in ['#dic_area', 'article#dic_area',
86
+ '.go_trans._article_content', '._article_body_contents']:
87
+ try:
88
+ el = driver.find_element(By.CSS_SELECTOR, sel)
89
+ if el.text.strip():
90
+ article_data['content'] = el.text.strip(); break
91
+ except: continue
92
+ try:
93
+ el = driver.find_element(By.CSS_SELECTOR, 'a.media_end_head_top_logo img')
94
+ article_data['source'] = el.get_attribute('alt')
95
+ except:
96
+ try:
97
+ el = driver.find_element(By.CSS_SELECTOR, '.media_end_head_top_logo_text')
98
+ article_data['source'] = el.text.strip()
99
+ except: pass
100
+ try:
101
+ el = driver.find_element(By.CSS_SELECTOR,
102
+ 'span.media_end_head_info_datestamp_time, span[data-date-time]')
103
+ article_data['published_date'] = (el.get_attribute('data-date-time') or el.text).strip()
104
+ except:
105
+ article_data['published_date'] = datetime.now().strftime('%Y-%m-%d %H:%M')
106
+ try:
107
+ el = driver.find_element(By.CSS_SELECTOR,
108
+ 'em.media_end_head_journalist_name, span.byline_s')
109
+ article_data['author'] = el.text.strip()
110
+ except: pass
111
+ except Exception as e:
112
+ print(f' [PARSE] ⚠️ 파싱 오류: {e}')
113
+ return article_data
114
+
115
+ # ── 1단계: 전체 기사 수집 ──
116
+ all_articles = []
117
+ category_stats = {}
118
+
119
+ for category_name, category_url in categories.items():
120
+ print(f"\n{'='*60}")
121
+ print(f'[CRAWL] [{category_name}] 카테고리 수집 시작')
122
+ print(f"{'='*60}")
123
+
124
+ article_links = get_article_links(driver, category_url, NUM_ARTICLES_PER_CATEGORY)
125
+
126
+ cat_ok, cat_fail = 0, 0
127
+ for idx, article_url in enumerate(article_links, 1):
128
+ print(f' [PARSE] ({idx}/{len(article_links)}) {article_url[:70]}...')
129
+ article_data = parse_article_detail(driver, article_url, category_name)
130
+
131
+ if article_data['title'] and article_data['content']:
132
+ all_articles.append(article_data)
133
+ cat_ok += 1
134
+ print(f" ✅ {article_data['title'][:40]}...")
135
+ print(f" 언론사: {article_data['source']} | 날짜: {article_data['published_date']}")
136
+ else:
137
+ cat_fail += 1
138
+ missing = [x for x, v in [('제목', article_data['title']), ('본문', article_data['content'])] if not v]
139
+ print(f" ❌ 파싱실패 ({', '.join(missing)} 없음)")
140
+ time.sleep(0.5)
141
+
142
+ category_stats[category_name] = {'ok': cat_ok, 'fail': cat_fail}
143
+ print(f"\n [CRAWL] [{category_name}] 완료: 성공 {cat_ok}개 / 실패 {cat_fail}개")
144
+
145
+ driver.quit()
146
+ print(f'\n[DONE] 브라우저 종료')
147
+ print(f"\n{'='*60}")
148
+ print(f'[SUMMARY] 수집 결과 요약')
149
+ print(f"{'='*60}")
150
+ for cat, s in category_stats.items():
151
+ print(f' {cat}: 성공 {s["ok"]}건 / 실패 {s["fail"]}건')
152
+ print(f' 전체 수집: {len(all_articles)}건')
153
+
154
+ df_all = pd.DataFrame(all_articles)
155
+ df_all
156
+
157
+
158
+
159
+
160
+ # ── 2단계: AI 핀테크 키워드 필터링 ──
161
+ print(f"\n{'='*60}")
162
+ print('[FILTER] AI 핀테크 키워드 필터링 시작')
163
+ print(f"{'='*60}")
164
+
165
+ filtered_articles = []
166
+ for _, row in df_all.iterrows():
167
+ text = f"{row['title']} {row['content']}"
168
+ matched = [kw for kw in FINTECH_AI_KEYWORDS if kw.replace(" ", "") in text.replace(" ", "")]
169
+ if matched:
170
+ row_dict = row.to_dict()
171
+ row_dict['matched_keywords'] = ', '.join(matched)
172
+ filtered_articles.append(row_dict)
173
+
174
+ df_filtered = pd.DataFrame(filtered_articles)
175
+
176
+ print(f' 전체 수집: {len(df_all)}건')
177
+ print(f' AI 핀테크 관련: {len(df_filtered)}건 ({len(df_filtered)/max(len(df_all),1)*100:.1f}%)')
178
+ print(f'\n [키워드별 매칭 현황]')
179
+ all_kw = [kw for row in filtered_articles for kw in row['matched_keywords'].split(', ')]
180
+ kw_counts = Counter(all_kw)
181
+ for kw in FINTECH_AI_KEYWORDS:
182
+ print(f' {kw}: {kw_counts.get(kw, 0)}건')
183
+
184
+ df_filtered
185
+
186
+ # ── 3단계: 저장 ──
187
+ output_filename = f"Articles_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
188
+ df_filtered.to_excel(output_filename, index=False, engine='openpyxl')
189
+ print(f'[SAVE] ✅ 저장 완료: {output_filename}')
190
+ print(f'[SAVE] - AI 핀테크 기사: {len(df_filtered)}건')
191
+
192
+
193
+
194
+
195
+ # ── 4단계: 키워드 빈도 시각화 ──
196
+ import matplotlib.pyplot as plt
197
+ import platform
198
+ from collections import Counter
199
+
200
+ # 폰트 깨짐 방지 (Mac 환경: AppleGothic)
201
+ if platform.system() == 'Darwin':
202
+ plt.rc('font', family='AppleGothic')
203
+ plt.rcParams['axes.unicode_minus'] = False
204
+
205
+ if not filtered_articles:
206
+ print('시각화할 데이터가 없습니다.')
207
+ else:
208
+ # 빈도수 계산
209
+ all_kw = [kw for row in filtered_articles for kw in row['matched_keywords'].split(', ')]
210
+ kw_counts = Counter(all_kw)
211
+
212
+ # 📌 변경 포인트: FINTECH_AI_KEYWORDS 전체 목록을 순서대로 그래프에 강제 표시 (0건 포함)
213
+ keywords = FINTECH_AI_KEYWORDS
214
+ counts = [kw_counts.get(kw, 0) for kw in keywords]
215
+
216
+ plt.figure(figsize=(12, 6))
217
+
218
+ # 막대 그래프 생성
219
+ bars = plt.bar(keywords, counts, color='skyblue', edgecolor='white')
220
+
221
+ # 막대 위에 숫자(빈도수) 표시
222
+ for bar in bars:
223
+ height = bar.get_height()
224
+ # 막대의 중앙(x), 막대의 높이(y) 위치에 텍스트를 배치
225
+ plt.text(bar.get_x() + bar.get_width() / 2.0, height, f'{height}',
226
+ ha='center', va='bottom', size=11, fontweight='bold', color='black')
227
+
228
+ plt.title('수집된 AI 핀테크 기사 키워드 출현 빈도 (전체)', fontsize=15, pad=15)
229
+ plt.xlabel('키워드', fontsize=12)
230
+ plt.ylabel('출현 횟수 (건)', fontsize=12)
231
+ plt.grid(axis='y', linestyle='--', alpha=0.7)
232
+ plt.xticks(rotation=45)
233
+ plt.tight_layout()
234
+ plt.show()
235
+
src/retrieval/__init__.py ADDED
File without changes
src/retrieval/finRetrieval.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ finRetrieval.py — GraphRAG 검색 모듈
3
+ =====================================
4
+ app.py에서 import하여 Gradio 챗봇과 연동합니다.
5
+
6
+ 사용법:
7
+ from src.retrieval.finRetrieval import graphrag
8
+
9
+ response = graphrag.search(query_text="삼성전자 AI 서비스는?")
10
+ print(response.answer)
11
+ """
12
+
13
+ import os
14
+ import dotenv
15
+ import neo4j
16
+ from neo4j_graphrag.llm import OpenAILLM
17
+ from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings
18
+ from neo4j_graphrag.retrievers import (
19
+ VectorRetriever,
20
+ VectorCypherRetriever,
21
+ Text2CypherRetriever,
22
+ ToolsRetriever,
23
+ )
24
+ from neo4j_graphrag.generation import RagTemplate, GraphRAG
25
+
26
+ dotenv.load_dotenv()
27
+
28
+ # ──────────────────────────────────────────
29
+ # 1. DB / LLM / Embedder 초기화
30
+ # ──────────────────────────────────────────
31
+
32
+ URI = os.getenv("NEO4J_URI", "neo4j://localhost:7687")
33
+ AUTH = (os.getenv("NEO4J_USERNAME", "neo4j"), os.getenv("NEO4J_PASSWORD", "password"))
34
+ driver = neo4j.GraphDatabase.driver(URI, auth=AUTH)
35
+
36
+ rag_llm = OpenAILLM(model_name="gpt-4o", model_params={"temperature": 0})
37
+ embedder = OpenAIEmbeddings(model="text-embedding-3-small")
38
+
39
+ INDEX_NAME = "content_vector_index"
40
+
41
+ # ──────────────────────────────────────────
42
+ # 2. Retriever 세 종류 초기화
43
+ # ──────────────────────────────────────────
44
+
45
+ # (1) 본문 청크 의미 유사도 검색
46
+ vector_retriever = VectorRetriever(
47
+ driver=driver,
48
+ index_name=INDEX_NAME,
49
+ embedder=embedder,
50
+ )
51
+
52
+ # (2) 벡터 검색 후 그래프 탐색 (기업·기술·서비스 함께 반환)
53
+ _retrieval_query = """
54
+ MATCH (content:Content)<-[:HAS_CHUNK]-(article:Article)
55
+ OPTIONAL MATCH (article)-[:MENTIONS]->(company:AICompany)
56
+ OPTIONAL MATCH (company)-[:DEVELOPS]->(tech:AITechnology)
57
+ OPTIONAL MATCH (company)-[:DEVELOPS]->(svc:AIService)
58
+ OPTIONAL MATCH (article)-[:MENTIONS]->(field:AIField)
59
+ RETURN
60
+ content.chunk AS chunk,
61
+ article.title AS article_title,
62
+ article.url AS article_url,
63
+ article.published_date AS article_date,
64
+ collect(DISTINCT company.name) AS companies,
65
+ collect(DISTINCT tech.name) AS technologies,
66
+ collect(DISTINCT svc.name) AS services,
67
+ collect(DISTINCT field.name) AS fields
68
+ ORDER BY article.published_date DESC
69
+ LIMIT 3
70
+ """
71
+
72
+ vector_cypher_retriever = VectorCypherRetriever(
73
+ driver=driver,
74
+ index_name=INDEX_NAME,
75
+ retrieval_query=_retrieval_query,
76
+ embedder=embedder,
77
+ )
78
+
79
+ # (3) 자연어 → Cypher 자동 변환 검색
80
+ def _get_schema() -> str:
81
+ with driver.session() as s:
82
+ nodes = s.run(
83
+ "CALL db.schema.nodeTypeProperties() "
84
+ "YIELD nodeType, propertyName "
85
+ "RETURN nodeType, collect(propertyName) as props"
86
+ ).data()
87
+ rels = s.run(
88
+ "MATCH (n)-[r]->(m) "
89
+ "RETURN DISTINCT labels(n)[0] as src, type(r) as rel, labels(m)[0] as tgt "
90
+ "LIMIT 30"
91
+ ).data()
92
+ txt = "=== Neo4j Schema ===\n노드:\n"
93
+ for n in nodes:
94
+ txt += f"- {n['nodeType']}: {n['props']}\n"
95
+ txt += "\n관계:\n"
96
+ for r in rels:
97
+ txt += f"- ({r['src']})-[:{r['rel']}]->({r['tgt']})\n"
98
+ return txt
99
+
100
+
101
+ _examples = [
102
+ """USER INPUT: 카카오의 AI 서비스 목록을 알려주세요
103
+ CYPHER QUERY:
104
+ MATCH (c:AICompany {name:"카카오"})-[:DEVELOPS]->(s:AIService)
105
+ RETURN s.name, s.description""",
106
+
107
+ """USER INPUT: 삼성전자가 개발 중인 AI 기술은?
108
+ CYPHER QUERY:
109
+ MATCH (c:AICompany {name:"삼성전자"})-[:DEVELOPS]->(t:AITechnology)
110
+ RETURN t.name, t.description""",
111
+
112
+ """USER INPUT: 최근 AI 관련 기사 5개
113
+ CYPHER QUERY:
114
+ MATCH (a:Article)-[:MENTIONS]->(:AICompany)
115
+ RETURN DISTINCT a.article_id, a.title, a.url, a.published_date
116
+ ORDER BY a.published_date DESC LIMIT 5""",
117
+
118
+ """USER INPUT: 어떤 기업이 LLM 기술을 개발하나요?
119
+ CYPHER QUERY:
120
+ MATCH (c:AICompany)-[:DEVELOPS]->(t:AITechnology)
121
+ WHERE t.name CONTAINS "언어모델" OR t.name CONTAINS "LLM"
122
+ RETURN c.name, t.name""",
123
+ ]
124
+
125
+ text2cypher_retriever = Text2CypherRetriever(
126
+ driver=driver,
127
+ llm=rag_llm,
128
+ neo4j_schema=_get_schema(),
129
+ examples=_examples,
130
+ )
131
+
132
+ # ──────────────────────────────────────────
133
+ # 3. ToolsRetriever + GraphRAG 조립
134
+ # ──────────────────────────────────────────
135
+
136
+ tools_retriever = ToolsRetriever(
137
+ driver=driver,
138
+ llm=rag_llm,
139
+ tools=[
140
+ vector_retriever.convert_to_tool(
141
+ name="vector_retriever",
142
+ description="뉴스 본문의 의미(내용) 유사도 기반 검색. AI 기술·서비스 관련 텍스트를 찾을 때 사용.",
143
+ ),
144
+ vector_cypher_retriever.convert_to_tool(
145
+ name="vectorcypher_retriever",
146
+ description="벡터 검색 후 해당 기사에서 언급된 기업·기술·서비스 그래프를 함께 반환. 기업 AI 트렌드 분석에 최적.",
147
+ ),
148
+ text2cypher_retriever.convert_to_tool(
149
+ name="text2cypher_retriever",
150
+ description="자연어를 Cypher로 변환. 특정 기업 서비스 목록, 기술 보유 기업 등 구조적 질의에 사용.",
151
+ ),
152
+ ],
153
+ )
154
+
155
+ _prompt_template = RagTemplate(
156
+ template="""당신은 AI 기술 트렌드 분석 전문가입니다.
157
+ 취업 준비생이 기업 지원 동기를 작성할 수 있도록 해당 기업의 AI 서비스·기술 트렌드를 명확하게 설명해 주세요.
158
+
159
+ 질문: {query_text}
160
+
161
+ 검색된 정보:
162
+ {context}
163
+
164
+ 답변 지침:
165
+ 1. 기업이 개발 중인 AI 기술과 서비스를 구체적으로 명시하세요.
166
+ 2. 뉴스 기사 제목과 URL을 근거로 포함하세요.
167
+ 3. 지원자가 어떤 서비스에 어떻게 기여할 수 있는지 시사점을 1~2줄 추가하세요.
168
+ 4. 검색 결과에 없는 내용은 추측하지 마세요.
169
+
170
+ 답변:""",
171
+ expected_inputs=["context", "query_text"],
172
+ )
173
+
174
+ # app.py에서 이 객체를 직접 import하여 사용합니다.
175
+ graphrag = GraphRAG(
176
+ llm=rag_llm,
177
+ retriever=tools_retriever,
178
+ prompt_template=_prompt_template,
179
+ )
src/utils/__init__.py ADDED
File without changes
tests/test_chunk_text.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.graphBuilder.neo4j.finGraph import chunk_text
2
+
3
+ def test_chunk_text_empty_returns_empty_list():
4
+ assert chunk_text("") == []
5
+
6
+ def test_chunk_text_none_returns_empty_list():
7
+ assert chunk_text(None) == []
8
+
9
+ def test_chunk_text_short_text_returns_single_chunk():
10
+ result = chunk_text("짧은 텍스트", size=500, overlap=50)
11
+ assert len(result) == 1
12
+
13
+ def test_chunk_text_long_text_splits_into_multiple_chunks():
14
+ result = chunk_text("가" * 1000, size=500, overlap=50)
15
+ assert len(result) >= 2
tests/test_retrieval.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pytest
3
+ from src.retrieval.finRetrieval import graphrag
4
+
5
+ # API 키와 Neo4j 연결정보가 없을 경우 테스트를 건너뜁니다.
6
+ has_credentials = (
7
+ os.getenv("OPENAI_API_KEY") is not None and
8
+ os.getenv("NEO4J_URI") is not None
9
+ )
10
+
11
+ @pytest.mark.skipif(
12
+ not has_credentials,
13
+ reason="OpenAI API Key 또는 Neo4j 연결 환경변수가 없으므로 통합 테스트를 건너뜁니다."
14
+ )
15
+ def test_portfolio_showcase_aggregation_query():
16
+ """
17
+ [포트폴리오 핵심 시나리오]
18
+ 특정 기업을 지정하지 않고, 금융AI 분야의 최신 트렌드 기업 TOP 3와 대표 서비스를
19
+ 동적으로 그래프 탐색(GraphRAG)하여 올바른 형식으로 답변하는지 검증합니다.
20
+ """
21
+ showcase_query = (
22
+ "최근 수집된 뉴스에서 금융AI(AIField) 분야에 가장 적극적으로 기술을 개발하고 있는 "
23
+ "기업 TOP 3와 그 기업들이 개발한 대표 서비스를 알려줘."
24
+ )
25
+
26
+ # GraphRAG 검색 및 생성 실행
27
+ response = graphrag.search(query_text=showcase_query)
28
+
29
+ # 1. 응답 객체 및 속성 존재 여부 검증
30
+ assert response is not None
31
+ assert hasattr(response, "answer")
32
+
33
+ # 2. 답변 텍스트 유효성 검증
34
+ answer = response.answer
35
+ assert len(answer.strip()) > 0
36
+
37
+ # 3. 답변 형식 검증 (순위 구조나 출처 지침 준수 여부)
38
+ assert any(indicator in answer for indicator in ["1.", "첫째", "TOP", "기사", "출처"])
39
+
40
+ print(f"\n✨ [포트폴리오 쇼케이스 RAG 결과]\n{answer}")