Commit ·
cb92864
0
Parent(s):
feat: 프로젝트 초기 구성 및 GraphRAG 테스트 파이프라인 연동
Browse files- .env.example +8 -0
- .github/workflows/ci.yml +42 -0
- .github/workflows/deploy.yml +29 -0
- .gitignore +79 -0
- .pre-commit-config.yaml +13 -0
- AGENTS.md +95 -0
- Dockerfile +39 -0
- README.md +124 -0
- app.py +146 -0
- pyproject.toml +24 -0
- requirements.txt +16 -0
- run_pipeline.py +61 -0
- src/__init__.py +0 -0
- src/graphBuilder/__init__.py +0 -0
- src/graphBuilder/neo4j/__init__.py +0 -0
- src/graphBuilder/neo4j/finGraph.py +279 -0
- src/graphBuilder/scrapping/__init__.py +0 -0
- src/graphBuilder/scrapping/finScrapping.py +235 -0
- src/retrieval/__init__.py +0 -0
- src/retrieval/finRetrieval.py +179 -0
- src/utils/__init__.py +0 -0
- tests/test_chunk_text.py +15 -0
- tests/test_retrieval.py +40 -0
.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 |
+
[](https://www.python.org/)
|
| 6 |
+
[](https://neo4j.com/)
|
| 7 |
+
[](https://langchain.com/)
|
| 8 |
+
[](https://gradio.app/)
|
| 9 |
+
[](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}")
|