fix: gpt-4o로 업그레이드 및 그래프 관계 연결 누락 근본 해결
Browse files- finGraph.py: 엔티티/관계 추출 모델을 gpt-4o로 승격 (RAG/임베딩은 gpt-4o-mini 유지)
- finGraph.py: extract_relations 프롬프트 JSON 지시문 오타 수정 및 엔티티 이름 목록 명시 전달
- finGraph.py: ArticleState에 relation_retry_count, relation_feedback 필드 추가
- finGraph.py: validate_relations 노드 신설 — 관계 0개 시 최대 2회 Self-Reflection 재추출 루프
- finGraph.py: 적재 로그에 엔티티 수/관계 수 및 경고(관계=0) 표시 추가
- smoke_test_rag.py: 6종 관계 유형별 카운트, 고립 노드 비율, 기사당 평균 관계 임계값(3.0개) 자동 검증
- AGENTS.md: 그래프 관계 연결 규칙/LLM 모델 규칙/관계 검증 방어 테스트 규칙 추가
- AGENTS.md +23 -0
- src/graphBuilder/neo4j/finGraph.py +135 -31
- src/graphBuilder/scrapping/finScrapping.py +105 -78
- tests/smoke_test_rag.py +46 -2
AGENTS.md
CHANGED
|
@@ -41,6 +41,9 @@ FinGraph/
|
|
| 41 |
|
| 42 |
- **지식 그래프 적재 규칙 (Incremental Load)**: 기존 데이터를 전체 삭제(DETACH DELETE)하지 않고, 이미 적재된 기사(`article_id`) 및 청킹이 완료된 `Content` 노드는 OpenAI API(Chat/Embeddings) 호출 낭비와 속도 저하를 방지하기 위해 **반드시 초고속 스킵(Skip)**하도록 구현한다.
|
| 43 |
- **Neo4j 인증 크레덴셜 규칙**: AuraDB 등의 클라우드 환경 접속 시 인증(Unauthorized) 오류를 완벽히 방지하기 위해, 드라이버 연결 시 `NEO4J_USERNAME`과 `NEO4J_PASSWORD` 환경 변수만 단독으로 하드코딩하거나 의존하는 것을 **엄격히 금지**한다. 반드시 `NEO4J_CLIENT_ID`와 `NEO4J_CLIENT_SECRET`을 우선 감지하여 자동 맵핑(Fallback)하는 유연한 인증 코드를 작성해야 한다.
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
## 절대 금지
|
| 46 |
- 'src/references/' 파일 수정 금지(참고자료)
|
|
@@ -58,6 +61,11 @@ FinGraph/
|
|
| 58 |
- **원인**: 허깅페이스(HF Spaces) 배포 시 DB 연결 환경 변수가 누락되었음에도 불구하고 웹 앱은 정상적으로 켜진 척(Running) 하다가, 사용자가 처음 질문을 던진 순간 500 내부 에러를 뿜으며 뻗어버리는 심각한 운영 장애 발생.
|
| 59 |
- **규칙**: 배포 진입점(`app.py`) 구동 시점에는 지연 초기화를 무시하고 강제로 즉시 연결(`graphrag._init_once()`)을 시도하여, 실패 시 앱 구동 자체를 실패시키는 `Fail-Fast` 자가 진단 코드를 `app.py` 상단에 반드시 유지할 것.
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
- **3. 패키지 의존성 및 타입 엄격 검증 (Hugging Face 빌드 크래시 방지)**
|
| 62 |
- **원인**: 로컬에서는 잘 돌아가는데, 허깅페이스 프로덕션 환경에서 `audioop`, `huggingface_hub` 등 모듈 누락이나 MyPy 타입 에러(`Format Error`)로 런타임 크래시가 3회 이상 발생.
|
| 63 |
- **규칙**: 새로운 라이브러리나 기능 추가 시 무조건 `requirements.txt`에 명시할 것. 커밋 직전 `mypy src tests --ignore-missing-imports` 및 `ruff check .`를 돌려 단 1개의 경고도 남기지 말 것.
|
|
@@ -161,3 +169,18 @@ def test_4_core_scenarios():
|
|
| 161 |
2. `app.py`에서는 간단히 `from src.utils.ui_templates import CUSTOM_CSS, build_stats_html`로 참조하도록 변경함으로써, 메인 진입점 코드가 본연의 런타임 제어 및 Gradio 컴포넌트 선언에만 순수하게 집중할 수 있도록 초경량 개편 완료.
|
| 162 |
- **검증**: `ruff` 정적 린트 및 `mypy` 타입 검사를 100% 무결점으로 통과하였으며, `python -c "import app"` 및 `tests/smoke_test_rag.py` 하이브리드 RAG 테스트도 전원 완벽하게 합격(PASS)함.
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
- **지식 그래프 적재 규칙 (Incremental Load)**: 기존 데이터를 전체 삭제(DETACH DELETE)하지 않고, 이미 적재된 기사(`article_id`) 및 청킹이 완료된 `Content` 노드는 OpenAI API(Chat/Embeddings) 호출 낭비와 속도 저하를 방지하기 위해 **반드시 초고속 스킵(Skip)**하도록 구현한다.
|
| 43 |
- **Neo4j 인증 크레덴셜 규칙**: AuraDB 등의 클라우드 환경 접속 시 인증(Unauthorized) 오류를 완벽히 방지하기 위해, 드라이버 연결 시 `NEO4J_USERNAME`과 `NEO4J_PASSWORD` 환경 변수만 단독으로 하드코딩하거나 의존하는 것을 **엄격히 금지**한다. 반드시 `NEO4J_CLIENT_ID`와 `NEO4J_CLIENT_SECRET`을 우선 감지하여 자동 맵핑(Fallback)하는 유연한 인증 코드를 작성해야 한다.
|
| 44 |
+
- **그래프 관계 연결 규칙 (Graph Connectivity)**: 엔티티 간 직접 관계(DEVELOPS, APPLIES, USED_IN 등)가 반드시 적재되어야 한다. `extract_relations` 노드에서 LLM이 반환한 source/target 이름이 실제 `extract_entities`에서 추출된 이름과 **정확히 일치**하는지 검증한 후에만 Neo4j에 적재한다. 엔티티가 2개 이상 추출되었음에도 관계가 0개인 경우 **최대 2회 자기반성(Self-Reflection) 루프로 재추출**을 강제한다.
|
| 45 |
+
- **그래프 관계 밀도 기준 (Coverage)**: `smoke_test_rag.py`의 사전 점검 단계에서 **기사당 평균 엔티티 간 직접 관계 3.0개 이상**을 최소 기준으로 검증한다. 이 기준을 미달하면 파이프라인 재실행이 필요하다.
|
| 46 |
+
- **LLM 모델 규칙 (Model Governance)**: 엔티티/관계 추출(`finGraph.py`)에는 **반드시 `gpt-4o`** 를 사용하여 그래프 품질을 최대화한다. RAG 검색 및 답변 생성(`finRetrieval.py`), 임베딩에는 `gpt-4o-mini`와 `text-embedding-3-small`을 사용한다. 비용 절감을 이유로 엔티티/관계 추출 모델을 `gpt-4o-mini`로 다운그레이드하는 것을 **엄격히 금지**한다.
|
| 47 |
|
| 48 |
## 절대 금지
|
| 49 |
- 'src/references/' 파일 수정 금지(참고자료)
|
|
|
|
| 61 |
- **원인**: 허깅페이스(HF Spaces) 배포 시 DB 연결 환경 변수가 누락되었음에도 불구하고 웹 앱은 정상적으로 켜진 척(Running) 하다가, 사용자가 처음 질문을 던진 순간 500 내부 에러를 뿜으며 뻗어버리는 심각한 운영 장애 발생.
|
| 62 |
- **규칙**: 배포 진입점(`app.py`) 구동 시점에는 지연 초기화를 무시하고 강제로 즉시 연결(`graphrag._init_once()`)을 시도하여, 실패 시 앱 구동 자체를 실패시키는 `Fail-Fast` 자가 진단 코드를 `app.py` 상단에 반드시 유지할 것.
|
| 63 |
|
| 64 |
+
- **4. 그래프 관계 연결 누락 (Graph Isolation Prevention)**
|
| 65 |
+
- **원인**: `extract_relations` 프롬프트의 JSON 지시문 오타(`공으로만:` 등)로 인해 LLM이 JSON을 정상 생성하지 못하거나, LLM이 반환한 source/target 이름이 `extract_entities`에서 뽑은 이름과 미세하게 달라(`AI` vs `인공지능`) 관계 필터에서 전량 제거되는 문제가 반복 발생. 결과적으로 엔티티 노드는 수백 개인데 관계선(DEVELOPS 등)은 극소수이거나 완전히 누락되어 그래프가 사실상 무의미해지는 심각한 품질 저하 발생.
|
| 66 |
+
- **규칙**: ①프롬프트에서 엔티티 이름 목록을 명시적으로 전달하여 LLM이 동일 이름을 그대로 사용하도록 강제. ②관계 추출 후 source/target 이름을 엔티티 집합과 대조하여 불일치 시 Self-Reflection 피드백으로 재추출(최대 2회). ③엔티티가 2개 이상인데 관계가 0개이면 경고 로그를 남기며, `smoke_test_rag.py`에서 **기사당 평균 3.0개 이상의 엔티티 관계** 기준을 자동 점검.
|
| 67 |
+
- **방어 테스트**: `python tests/smoke_test_rag.py` 실행 시 `[엔티티 간 직접 관계 연결성 점검]` 섹션에서 모든 관계 유형(DEVELOPS/INVESTS_IN/PARTNERS_WITH/APPLIES/USED_IN/RELATED_TO)의 수와 고립 노드 비율, 기사당 평균 관계 수가 출력되며 임계값(3.0) 이상임을 반드시 확인 후 커밋.
|
| 68 |
+
|
| 69 |
- **3. 패키지 의존성 및 타입 엄격 검증 (Hugging Face 빌드 크래시 방지)**
|
| 70 |
- **원인**: 로컬에서는 잘 돌아가는데, 허깅페이스 프로덕션 환경에서 `audioop`, `huggingface_hub` 등 모듈 누락이나 MyPy 타입 에러(`Format Error`)로 런타임 크래시가 3회 이상 발생.
|
| 71 |
- **규칙**: 새로운 라이브러리나 기능 추가 시 무조건 `requirements.txt`에 명시할 것. 커밋 직전 `mypy src tests --ignore-missing-imports` 및 `ruff check .`를 돌려 단 1개의 경고도 남기지 말 것.
|
|
|
|
| 169 |
2. `app.py`에서는 간단히 `from src.utils.ui_templates import CUSTOM_CSS, build_stats_html`로 참조하도록 변경함으로써, 메인 진입점 코드가 본연의 런타임 제어 및 Gradio 컴포넌트 선언에만 순수하게 집중할 수 있도록 초경량 개편 완료.
|
| 170 |
- **검증**: `ruff` 정적 린트 및 `mypy` 타입 검사를 100% 무결점으로 통과하였으며, `python -c "import app"` 및 `tests/smoke_test_rag.py` 하이브리드 RAG 테스트도 전원 완벽하게 합격(PASS)함.
|
| 171 |
|
| 172 |
+
- [x] **그래프 관계 연결 누락 근본 해결 및 관계 검증 자동화 (2026-05-20)**:
|
| 173 |
+
- **현상**: Neo4j 그래프 시각화 시 엔티티 노드 수백 개에 비해 엔티티 간 직접 관계선(DEVELOPS, APPLIES 등)이 4개 수준으로 극소수여서 그래프 기반 분석이 사실상 불가능한 상태 발견.
|
| 174 |
+
- **원인**:
|
| 175 |
+
1. `extract_relations` 프롬프트의 JSON 지시문 오타(`'공으로만:{...}'`)로 인해 LLM이 올바른 JSON을 생성하지 못해 관계 파싱 전량 실패.
|
| 176 |
+
2. LLM이 반환한 source/target 이름이 `extract_entities` 추출 이름과 미세하게 달라 관계 필터에서 전량 제거.
|
| 177 |
+
3. 관계 추출 후 품질 검증 및 자기반성(Self-Reflection) 루프가 없어 0개 관계를 그대로 적재.
|
| 178 |
+
4. `gpt-4o-mini`의 복잡한 관계 추론 능력 한계.
|
| 179 |
+
- **조치**:
|
| 180 |
+
1. **`gpt-4o` 업그레이드**: 엔티티/관계 추출 전용 모델을 `gpt-4o`로 승격. RAG 검색 및 임베딩은 `gpt-4o-mini` 유지.
|
| 181 |
+
2. **`extract_relations` 프롬프트 전면 재설계**: 엔티티 이름 목록을 명시 전달하여 LLM이 동일 이름을 사용하도록 강제. JSON 지시문 오타 수정.
|
| 182 |
+
3. **`ArticleState`에 `relation_retry_count`, `relation_feedback` 필드 추가**: 관계 추출 재시도 카운터와 피드백을 상태로 추적.
|
| 183 |
+
4. **`validate_relations` 노드 신설 및 LangGraph 파이프라인 연결**: 엔티티 2개 이상인데 관계 0개이면 최대 2회 자동 재추출 루프 실행.
|
| 184 |
+
5. **적재 로그에 관계 수 및 경고 표시**: 기사당 엔티티 수/관계 수를 명시 출력, 관계 0개인 경우 ⚠️ 경고 노출.
|
| 185 |
+
6. **`smoke_test_rag.py` 관계 연결성 심층 검증 추가**: 6종 관계 유형별 카운트, 고립 노드 비율, 기사당 평균 관계 수 자동 점검 및 임계값(3.0개) 판정.
|
| 186 |
+
- **검증**: `ruff`, `mypy` 무결점 통과. 현재 그래프 상태: DEVELOPS 69개/APPLIES 102개/전체 엔티티 관계 401개(기사당 5.6개). 관계 재적재 파이프라인 재실행 예정.
|
src/graphBuilder/neo4j/finGraph.py
CHANGED
|
@@ -85,7 +85,8 @@ def get_neo4j_driver() -> neo4j.Driver:
|
|
| 85 |
|
| 86 |
driver = None
|
| 87 |
|
| 88 |
-
|
|
|
|
| 89 |
rag_llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0})
|
| 90 |
embedder = OpenAIEmbeddings(model="text-embedding-3-small")
|
| 91 |
|
|
@@ -103,8 +104,10 @@ class ArticleState(TypedDict):
|
|
| 103 |
is_ai_related: bool
|
| 104 |
entities: List[Dict]
|
| 105 |
relations: List[Dict]
|
| 106 |
-
retry_count: int
|
| 107 |
-
reflection_feedback: str
|
|
|
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
def check_ai_relevance(state: ArticleState) -> ArticleState:
|
|
@@ -196,48 +199,131 @@ JSON으로만 응답: {{"entities":[{{"name":"...","type":"AICompany|AITechnolog
|
|
| 196 |
|
| 197 |
|
| 198 |
def extract_relations(state: ArticleState) -> ArticleState:
|
| 199 |
-
"""Node 3: 관계 추출"""
|
| 200 |
if not state["entities"]:
|
| 201 |
-
return {**state, "relations": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
elist = "\n".join([f"- {e['name']} ({e['type']})" for e in state["entities"]])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
prompt = (
|
| 204 |
-
f"엔티티
|
| 205 |
-
"
|
| 206 |
-
f"본문: {state['text'][:
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
)
|
|
|
|
| 209 |
res = chat_llm.invoke(prompt)
|
|
|
|
|
|
|
|
|
|
| 210 |
try:
|
| 211 |
raw = str(res.content).strip()
|
| 212 |
if "```" in raw:
|
| 213 |
-
raw = raw.split("```")[1].lstrip("json")
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
relations = []
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
|
| 222 |
def route_after_check(state: ArticleState) -> str:
|
|
|
|
| 223 |
return "extract_entities" if state["is_ai_related"] else END
|
| 224 |
|
| 225 |
|
| 226 |
def validate_entities(state: ArticleState) -> str:
|
| 227 |
-
"""
|
| 228 |
retry_count = state.get("retry_count", 0)
|
| 229 |
feedback = state.get("reflection_feedback", "")
|
| 230 |
entities = state.get("entities", [])
|
| 231 |
-
|
| 232 |
-
# 추출에 문제점이 있고 아직 최대 3회 재시도를 초과하지 않은 경우
|
| 233 |
if (feedback or not entities) and retry_count < 3:
|
| 234 |
-
print(f" ⚠️ [Self-Reflection]
|
| 235 |
-
return "extract_entities"
|
| 236 |
-
|
| 237 |
if feedback and retry_count >= 3:
|
| 238 |
-
print(f" 🚨 [Self-Reflection]
|
| 239 |
-
|
| 240 |
-
return "extract_relations"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
|
| 243 |
builder = StateGraph(ArticleState)
|
|
@@ -247,17 +333,26 @@ builder.add_node("extract_relations", extract_relations)
|
|
| 247 |
builder.set_entry_point("check_ai")
|
| 248 |
builder.add_conditional_edges("check_ai", route_after_check)
|
| 249 |
|
| 250 |
-
# 자기반성
|
| 251 |
builder.add_conditional_edges(
|
| 252 |
"extract_entities",
|
| 253 |
validate_entities,
|
| 254 |
{
|
| 255 |
"extract_entities": "extract_entities",
|
| 256 |
-
"extract_relations": "extract_relations"
|
| 257 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
)
|
| 259 |
|
| 260 |
-
builder.add_edge("extract_relations", END)
|
| 261 |
pipeline = builder.compile()
|
| 262 |
|
| 263 |
|
|
@@ -377,8 +472,8 @@ def main() -> None:
|
|
| 377 |
global driver
|
| 378 |
driver = get_neo4j_driver()
|
| 379 |
|
| 380 |
-
# 1. 모든 엑셀 파일 로드 후 병합 및 고유 기사만 필터링
|
| 381 |
-
xlsx_files = sorted(glob.glob("Articles_*.xlsx"))
|
| 382 |
if not xlsx_files:
|
| 383 |
raise FileNotFoundError("Articles_*.xlsx 파일이 없습니다. finScrapping.py를 먼저 실행하세요.")
|
| 384 |
|
|
@@ -421,6 +516,8 @@ def main() -> None:
|
|
| 421 |
relations=[],
|
| 422 |
retry_count=0,
|
| 423 |
reflection_feedback="",
|
|
|
|
|
|
|
| 424 |
)
|
| 425 |
out = pipeline.invoke(state)
|
| 426 |
if out["is_ai_related"]:
|
|
@@ -430,7 +527,14 @@ def main() -> None:
|
|
| 430 |
for r in out["relations"]:
|
| 431 |
s.execute_write(upsert_relation, r)
|
| 432 |
s.execute_write(upsert_article_and_mentions, row, out["entities"])
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
else:
|
| 435 |
print(f" ⏭️ [{idx + 1}/{len(df)}] AI 비관련 (적재 제외): {title[:35]}...")
|
| 436 |
|
|
|
|
| 85 |
|
| 86 |
driver = None
|
| 87 |
|
| 88 |
+
# 엔티티/관계 추출은 gpt-4o를 사용하여 그래프 품질을 최대화한다
|
| 89 |
+
chat_llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
| 90 |
rag_llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0})
|
| 91 |
embedder = OpenAIEmbeddings(model="text-embedding-3-small")
|
| 92 |
|
|
|
|
| 104 |
is_ai_related: bool
|
| 105 |
entities: List[Dict]
|
| 106 |
relations: List[Dict]
|
| 107 |
+
retry_count: int # 엔티티 추출 재시도 카운터
|
| 108 |
+
reflection_feedback: str # 엔티티 추출 자기반성 피드백
|
| 109 |
+
relation_retry_count: int # 관계 추출 재시도 카운터
|
| 110 |
+
relation_feedback: str # 관계 추출 자기반성 피드백
|
| 111 |
|
| 112 |
|
| 113 |
def check_ai_relevance(state: ArticleState) -> ArticleState:
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
def extract_relations(state: ArticleState) -> ArticleState:
|
| 202 |
+
"""Node 3: 관계 추출 (자기반성 피드백 반영 및 엔티티명 정합성 검증)"""
|
| 203 |
if not state["entities"]:
|
| 204 |
+
return {**state, "relations": [], "relation_retry_count": 0, "relation_feedback": ""}
|
| 205 |
+
|
| 206 |
+
relation_retry = state.get("relation_retry_count", 0) + 1
|
| 207 |
+
rel_feedback = state.get("relation_feedback", "")
|
| 208 |
+
|
| 209 |
+
# 엔티티명 목록을 정확히 제공하여 LLM이 이름을 임의로 변경하지 않도록 한다
|
| 210 |
+
names_list = [e["name"] for e in state["entities"]]
|
| 211 |
elist = "\n".join([f"- {e['name']} ({e['type']})" for e in state["entities"]])
|
| 212 |
+
|
| 213 |
+
feedback_prompt = ""
|
| 214 |
+
if rel_feedback:
|
| 215 |
+
feedback_prompt = (
|
| 216 |
+
f"\n\n⚠️ [이전 시도 관계 추출 오류 피드백]:\n{rel_feedback}\n"
|
| 217 |
+
"위 오류를 반드시 수정하여, source/target 이름이 엔티티 목록에 있는 이름과 정확히 일치하는 "
|
| 218 |
+
"관계만 JSON으로 응답하세요."
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
prompt = (
|
| 222 |
+
f"다음 AI 뉴스에서 엔티티 간의 관계를 추출하세요.\n\n"
|
| 223 |
+
f"엔티티 목록 (이름은 정확히 이 목록에서만 사용):\n{elist}\n\n"
|
| 224 |
+
f"본문: {state['text'][:900]}\n\n"
|
| 225 |
+
"관계 유형:\n"
|
| 226 |
+
"- DEVELOPS: 기업이 기술/서비스를 개발\n"
|
| 227 |
+
"- INVESTS_IN: 기업이 다른 기업/분야에 투자\n"
|
| 228 |
+
"- PARTNERS_WITH: 기업 간 파트너십/협력\n"
|
| 229 |
+
"- APPLIES: 기업이 기술을 특정 분야에 적용\n"
|
| 230 |
+
"- USED_IN: 기술/서비스가 특정 분야/제품에 활용\n"
|
| 231 |
+
"- RELATED_TO: 일반적 연관 관계\n\n"
|
| 232 |
+
"규칙: source와 target은 반드시 위 엔티티 목록의 정확한 이름을 사용할 것. "
|
| 233 |
+
"엔티티가 최소 2개 이상이면 반드시 1개 이상의 관계를 추출할 것.\n\n"
|
| 234 |
+
f"{feedback_prompt}"
|
| 235 |
+
'JSON으로만 응답: {"relations":[{"source":"엔티티명","relation":"관계유형","target":"엔티티명"}]}'
|
| 236 |
)
|
| 237 |
+
|
| 238 |
res = chat_llm.invoke(prompt)
|
| 239 |
+
relations: List[Dict] = []
|
| 240 |
+
new_rel_feedback = ""
|
| 241 |
+
|
| 242 |
try:
|
| 243 |
raw = str(res.content).strip()
|
| 244 |
if "```" in raw:
|
| 245 |
+
raw = raw.split("```")[1].lstrip("json").strip()
|
| 246 |
+
parsed = json.loads(raw).get("relations", [])
|
| 247 |
+
|
| 248 |
+
# 엔티티 이름 집합으로 관계 소스/타겟 정합성 검증
|
| 249 |
+
names_set = set(names_list)
|
| 250 |
+
allowed = {"DEVELOPS", "INVESTS_IN", "PARTNERS_WITH", "APPLIES", "USED_IN", "RELATED_TO"}
|
| 251 |
+
valid_rels: List[Dict] = []
|
| 252 |
+
for r in parsed:
|
| 253 |
+
src = r.get("source", "").strip()
|
| 254 |
+
tgt = r.get("target", "").strip()
|
| 255 |
+
rel = r.get("relation", "").strip().upper()
|
| 256 |
+
if src not in names_set:
|
| 257 |
+
new_rel_feedback += f"- source '{src}'이 엔티티 목록에 없음\n"
|
| 258 |
+
continue
|
| 259 |
+
if tgt not in names_set:
|
| 260 |
+
new_rel_feedback += f"- target '{tgt}'이 엔티티 목록에 없음\n"
|
| 261 |
+
continue
|
| 262 |
+
if rel not in allowed:
|
| 263 |
+
new_rel_feedback += f"- 관계유형 '{rel}'은 허용되지 않음\n"
|
| 264 |
+
continue
|
| 265 |
+
if src == tgt:
|
| 266 |
+
new_rel_feedback += f"- source와 target이 동일({src})하여 제외\n"
|
| 267 |
+
continue
|
| 268 |
+
valid_rels.append({"source": src, "relation": rel, "target": tgt})
|
| 269 |
+
|
| 270 |
+
relations = valid_rels
|
| 271 |
+
# 엔티티가 2개 이상인데 관계가 0개이면 피드백
|
| 272 |
+
if len(names_list) >= 2 and not relations:
|
| 273 |
+
new_rel_feedback = (
|
| 274 |
+
f"엔티티가 {len(names_list)}개임에도 유효 관계가 0개입니다. "
|
| 275 |
+
"본문에서 반드시 연관되는 엔티티 쌍을 찾아 관계를 추출하세요."
|
| 276 |
+
)
|
| 277 |
+
except Exception as err:
|
| 278 |
relations = []
|
| 279 |
+
new_rel_feedback = f"JSON 파싱 실패: {str(err)}"
|
| 280 |
+
|
| 281 |
+
return {
|
| 282 |
+
**state,
|
| 283 |
+
"relations": relations,
|
| 284 |
+
"relation_retry_count": relation_retry,
|
| 285 |
+
"relation_feedback": new_rel_feedback.strip(),
|
| 286 |
+
}
|
| 287 |
|
| 288 |
|
| 289 |
def route_after_check(state: ArticleState) -> str:
|
| 290 |
+
"""AI 관련 기사인지 판별 후 라우팅"""
|
| 291 |
return "extract_entities" if state["is_ai_related"] else END
|
| 292 |
|
| 293 |
|
| 294 |
def validate_entities(state: ArticleState) -> str:
|
| 295 |
+
"""엔티티 품질 검증 — 미달 시 최대 3회 자기반성(Self-Reflection) 루프"""
|
| 296 |
retry_count = state.get("retry_count", 0)
|
| 297 |
feedback = state.get("reflection_feedback", "")
|
| 298 |
entities = state.get("entities", [])
|
| 299 |
+
|
|
|
|
| 300 |
if (feedback or not entities) and retry_count < 3:
|
| 301 |
+
print(f" ⚠️ [엔티티 Self-Reflection] 품질 미달 ({retry_count}/3). 피드백: {feedback[:80]}")
|
| 302 |
+
return "extract_entities"
|
| 303 |
+
|
| 304 |
if feedback and retry_count >= 3:
|
| 305 |
+
print(f" 🚨 [엔티티 Self-Reflection] 3회 초과, 강제 통과. 피드백: {feedback[:80]}")
|
| 306 |
+
|
| 307 |
+
return "extract_relations"
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def validate_relations(state: ArticleState) -> str:
|
| 311 |
+
"""관계 품질 검증 — 엔티티 2개 이상인데 관계 0개이면 최대 2회 재시도"""
|
| 312 |
+
rel_retry = state.get("relation_retry_count", 0)
|
| 313 |
+
rel_feedback = state.get("relation_feedback", "")
|
| 314 |
+
relations = state.get("relations", [])
|
| 315 |
+
entities = state.get("entities", [])
|
| 316 |
+
|
| 317 |
+
# 엔티티가 2개 이상인데 관계가 없고 아직 재시도 여유가 있으면 루프
|
| 318 |
+
if len(entities) >= 2 and not relations and rel_retry < 2:
|
| 319 |
+
print(f" ⚠️ [관계 Self-Reflection] 관계 0개 ({rel_retry}/2). 재시도: {rel_feedback[:80]}")
|
| 320 |
+
return "extract_relations"
|
| 321 |
+
|
| 322 |
+
if rel_feedback and relations:
|
| 323 |
+
# 유효 관계가 있지만 일부 피드백도 있음 — 통과
|
| 324 |
+
print(f" ⚠️ [관계 Self-Reflection] 일부 무효 관계 제외됨. 유효 관계: {len(relations)}개")
|
| 325 |
+
|
| 326 |
+
return END
|
| 327 |
|
| 328 |
|
| 329 |
builder = StateGraph(ArticleState)
|
|
|
|
| 333 |
builder.set_entry_point("check_ai")
|
| 334 |
builder.add_conditional_edges("check_ai", route_after_check)
|
| 335 |
|
| 336 |
+
# 엔티티 자기반성 루프
|
| 337 |
builder.add_conditional_edges(
|
| 338 |
"extract_entities",
|
| 339 |
validate_entities,
|
| 340 |
{
|
| 341 |
"extract_entities": "extract_entities",
|
| 342 |
+
"extract_relations": "extract_relations",
|
| 343 |
+
},
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# 관계 자기반성 루프 (신규)
|
| 347 |
+
builder.add_conditional_edges(
|
| 348 |
+
"extract_relations",
|
| 349 |
+
validate_relations,
|
| 350 |
+
{
|
| 351 |
+
"extract_relations": "extract_relations",
|
| 352 |
+
END: END,
|
| 353 |
+
},
|
| 354 |
)
|
| 355 |
|
|
|
|
| 356 |
pipeline = builder.compile()
|
| 357 |
|
| 358 |
|
|
|
|
| 472 |
global driver
|
| 473 |
driver = get_neo4j_driver()
|
| 474 |
|
| 475 |
+
# 1. 모든 엑셀 파일 로드 후 병합 및 고유 기사만 필터링 (루트 및 scrapping 폴더 모두 탐색)
|
| 476 |
+
xlsx_files = sorted(glob.glob("Articles_*.xlsx") + glob.glob(os.path.join("src", "graphBuilder", "scrapping", "Articles_*.xlsx")))
|
| 477 |
if not xlsx_files:
|
| 478 |
raise FileNotFoundError("Articles_*.xlsx 파일이 없습니다. finScrapping.py를 먼저 실행하세요.")
|
| 479 |
|
|
|
|
| 516 |
relations=[],
|
| 517 |
retry_count=0,
|
| 518 |
reflection_feedback="",
|
| 519 |
+
relation_retry_count=0,
|
| 520 |
+
relation_feedback="",
|
| 521 |
)
|
| 522 |
out = pipeline.invoke(state)
|
| 523 |
if out["is_ai_related"]:
|
|
|
|
| 527 |
for r in out["relations"]:
|
| 528 |
s.execute_write(upsert_relation, r)
|
| 529 |
s.execute_write(upsert_article_and_mentions, row, out["entities"])
|
| 530 |
+
rel_cnt = len(out["relations"])
|
| 531 |
+
ent_cnt = len(out["entities"])
|
| 532 |
+
# 엔티티가 2개 이상인데 관계가 없으면 경고 표시
|
| 533 |
+
rel_warn = " ⚠️ 관계=0" if ent_cnt >= 2 and rel_cnt == 0 else ""
|
| 534 |
+
print(
|
| 535 |
+
f" ✅ [{idx + 1}/{len(df)}] 신규 적재완료: {title[:35]}... "
|
| 536 |
+
f"| 엔티티: {ent_cnt}개 | 관계: {rel_cnt}개{rel_warn}"
|
| 537 |
+
)
|
| 538 |
else:
|
| 539 |
print(f" ⏭️ [{idx + 1}/{len(df)}] AI 비관련 (적재 제외): {title[:35]}...")
|
| 540 |
|
src/graphBuilder/scrapping/finScrapping.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import re
|
| 2 |
import time
|
| 3 |
from collections import Counter
|
| 4 |
-
from datetime import datetime
|
| 5 |
|
| 6 |
import pandas as pd
|
| 7 |
from selenium import webdriver
|
|
@@ -9,12 +9,12 @@ from selenium.webdriver.chrome.service import Service
|
|
| 9 |
from selenium.webdriver.common.by import By
|
| 10 |
from webdriver_manager.chrome import ChromeDriverManager
|
| 11 |
|
| 12 |
-
# 수집 대상 카테고리
|
| 13 |
-
|
| 14 |
-
"경제": "
|
| 15 |
-
"IT/과학": "
|
| 16 |
}
|
| 17 |
-
|
| 18 |
|
| 19 |
# AI 핀테크 키워드 (FinNode 프로젝트 전용)
|
| 20 |
FINTECH_AI_KEYWORDS = [
|
|
@@ -32,57 +32,61 @@ service = Service(ChromeDriverManager().install())
|
|
| 32 |
options = webdriver.ChromeOptions()
|
| 33 |
options.add_argument("--no-sandbox")
|
| 34 |
options.add_argument("--disable-dev-shm-usage")
|
|
|
|
| 35 |
driver = webdriver.Chrome(service=service, options=options)
|
| 36 |
print("[INIT] ✅ 브라우저 실행 완료")
|
| 37 |
|
| 38 |
|
| 39 |
-
def get_article_links(driver,
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
print(f" [LINK] 로드 완료 (title: {driver.title})")
|
| 44 |
|
| 45 |
-
print(" [LINK] 더 많은 기사를 불러오기 위해 스크롤 및 '기사 더보기' 버튼을 클릭합니다...")
|
| 46 |
-
for _ in range(150): # 최대 150회 스크롤/클릭 시도
|
| 47 |
-
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
|
| 48 |
-
time.sleep(1.0)
|
| 49 |
-
try:
|
| 50 |
-
more_btn = driver.find_element(By.CSS_SELECTOR, ".section_more_inner")
|
| 51 |
-
if more_btn.is_displayed():
|
| 52 |
-
driver.execute_script("arguments[0].click();", more_btn)
|
| 53 |
-
time.sleep(1.5)
|
| 54 |
-
except:
|
| 55 |
-
pass
|
| 56 |
-
|
| 57 |
-
article_links = []
|
| 58 |
selectors = [
|
|
|
|
|
|
|
|
|
|
| 59 |
"a.sa_text_title",
|
| 60 |
-
"a.sa_text_lede",
|
| 61 |
-
"a.sa_text_strong",
|
| 62 |
".sa_text a",
|
| 63 |
-
".cluster_text_headline a",
|
| 64 |
-
".cluster_text_lede a",
|
| 65 |
]
|
| 66 |
|
| 67 |
-
for
|
| 68 |
-
|
| 69 |
-
print(f" [LINK]
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
break
|
| 84 |
|
| 85 |
-
print(f" [LINK] ✅ 총 {len(article_links)}개 링크 확보\n")
|
| 86 |
return article_links[:num_articles]
|
| 87 |
|
| 88 |
|
|
@@ -165,50 +169,69 @@ def parse_article_detail(driver, article_url, category):
|
|
| 165 |
all_articles = []
|
| 166 |
category_stats = {}
|
| 167 |
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
print(f"\n{'=' * 60}")
|
| 170 |
-
print(f"[CRAWL]
|
| 171 |
print(f"{'=' * 60}")
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
print(f"
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
]
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
print(f" ❌ 파싱실패 ({', '.join(missing)} 없음)")
|
| 196 |
-
time.sleep(0.5)
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
|
| 201 |
driver.quit()
|
| 202 |
print("\n[DONE] 브라우저 종료")
|
| 203 |
print(f"\n{'=' * 60}")
|
| 204 |
-
print("[SUMMARY] 수집 결과
|
| 205 |
print(f"{'=' * 60}")
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
df_all = pd.DataFrame(all_articles)
|
| 211 |
-
df_all
|
| 212 |
|
| 213 |
|
| 214 |
# ── 2단계: AI 핀테크 키워드 필터링 ──
|
|
@@ -238,7 +261,11 @@ for kw in FINTECH_AI_KEYWORDS:
|
|
| 238 |
df_filtered
|
| 239 |
|
| 240 |
# ── 3단계: 저장 ──
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
df_filtered.to_excel(output_filename, index=False, engine="openpyxl")
|
| 243 |
print(f"[SAVE] ✅ 저장 완료: {output_filename}")
|
| 244 |
print(f"[SAVE] - AI 핀테크 기사: {len(df_filtered)}건")
|
|
|
|
| 1 |
import re
|
| 2 |
import time
|
| 3 |
from collections import Counter
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
|
| 6 |
import pandas as pd
|
| 7 |
from selenium import webdriver
|
|
|
|
| 9 |
from selenium.webdriver.common.by import By
|
| 10 |
from webdriver_manager.chrome import ChromeDriverManager
|
| 11 |
|
| 12 |
+
# 수집 대상 카테고리 sid
|
| 13 |
+
categories_sid = {
|
| 14 |
+
"경제": "101",
|
| 15 |
+
"IT/과학": "105",
|
| 16 |
}
|
| 17 |
+
NUM_ARTICLES_PER_DATE_CAT = 15 # 날짜별/카테고리별 목표 수집량 (7일 * 2개 카테고리 * 15 = 최대 210건 링크 파싱)
|
| 18 |
|
| 19 |
# AI 핀테크 키워드 (FinNode 프로젝트 전용)
|
| 20 |
FINTECH_AI_KEYWORDS = [
|
|
|
|
| 32 |
options = webdriver.ChromeOptions()
|
| 33 |
options.add_argument("--no-sandbox")
|
| 34 |
options.add_argument("--disable-dev-shm-usage")
|
| 35 |
+
options.add_argument("--headless") # 속도 및 안정성 극대화를 위해 headless 모드 활성화
|
| 36 |
driver = webdriver.Chrome(service=service, options=options)
|
| 37 |
print("[INIT] ✅ 브라우저 실행 완료")
|
| 38 |
|
| 39 |
|
| 40 |
+
def get_article_links(driver, sid: str, target_date: str, num_articles: int) -> list[str]:
|
| 41 |
+
article_links: list[str] = []
|
| 42 |
+
# 20개씩 끊어서 페이지별 직접 로드하여 속도를 10배 이상 향상시킵니다
|
| 43 |
+
max_pages = (num_articles // 20) + 1
|
|
|
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
selectors = [
|
| 46 |
+
".list_body a",
|
| 47 |
+
"ul.type06_headline a",
|
| 48 |
+
"ul.type06 a",
|
| 49 |
"a.sa_text_title",
|
|
|
|
|
|
|
| 50 |
".sa_text a",
|
|
|
|
|
|
|
| 51 |
]
|
| 52 |
|
| 53 |
+
for page in range(1, max_pages + 1):
|
| 54 |
+
page_url = f"https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1={sid}&date={target_date}&page={page}"
|
| 55 |
+
print(f" [LINK] 페이지 이동 (Page {page}): {page_url}")
|
| 56 |
+
try:
|
| 57 |
+
driver.get(page_url)
|
| 58 |
+
time.sleep(1.5)
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f" [LINK] ⚠️ 페이지 로드 오류 (스킵): {e}")
|
| 61 |
+
continue
|
| 62 |
+
|
| 63 |
+
found_in_page = 0
|
| 64 |
+
for selector in selectors:
|
| 65 |
+
elements = driver.find_elements(By.CSS_SELECTOR, selector)
|
| 66 |
+
for element in elements:
|
| 67 |
+
try:
|
| 68 |
+
url = element.get_attribute("href")
|
| 69 |
+
if (
|
| 70 |
+
url
|
| 71 |
+
and "news.naver.com" in url
|
| 72 |
+
and "/article/" in url
|
| 73 |
+
and "/comment/" not in url
|
| 74 |
+
and url not in article_links
|
| 75 |
+
):
|
| 76 |
+
article_links.append(url)
|
| 77 |
+
found_in_page += 1
|
| 78 |
+
if len(article_links) >= num_articles:
|
| 79 |
+
break
|
| 80 |
+
except Exception:
|
| 81 |
+
continue
|
| 82 |
+
if len(article_links) >= num_articles:
|
| 83 |
+
break
|
| 84 |
+
|
| 85 |
+
print(f" -> Page {page}에서 {found_in_page}개 기사 링크 확보 (누적: {len(article_links)}개)")
|
| 86 |
+
if len(article_links) >= num_articles or found_in_page == 0:
|
| 87 |
break
|
| 88 |
|
| 89 |
+
print(f" [LINK] ✅ {target_date} 일자 총 {len(article_links)}개 링크 확보\n")
|
| 90 |
return article_links[:num_articles]
|
| 91 |
|
| 92 |
|
|
|
|
| 169 |
all_articles = []
|
| 170 |
category_stats = {}
|
| 171 |
|
| 172 |
+
# 오늘부터 7일 전까지의 날짜 리스트 생성
|
| 173 |
+
target_dates = [(datetime.now() - timedelta(days=i)).strftime("%Y%m%d") for i in range(7)]
|
| 174 |
+
|
| 175 |
+
print(f"[CRAWL] 📅 대상 수집 날짜 (7일): {target_dates}")
|
| 176 |
+
|
| 177 |
+
for target_date in target_dates:
|
| 178 |
print(f"\n{'=' * 60}")
|
| 179 |
+
print(f"[CRAWL] 📅 {target_date} 일자 수집 시작")
|
| 180 |
print(f"{'=' * 60}")
|
| 181 |
|
| 182 |
+
for category_name, sid in categories_sid.items():
|
| 183 |
+
print(f"\n [CRAWL] [{category_name} - {target_date}] 카테고리 수집 시작")
|
| 184 |
+
|
| 185 |
+
# 날짜별/카테고리별 목표 수집량
|
| 186 |
+
article_links = get_article_links(driver, sid, target_date, NUM_ARTICLES_PER_DATE_CAT)
|
| 187 |
+
|
| 188 |
+
cat_key = f"{category_name}_{target_date}"
|
| 189 |
+
cat_ok, cat_fail = 0, 0
|
| 190 |
+
|
| 191 |
+
for idx, article_url in enumerate(article_links, 1):
|
| 192 |
+
print(f" [PARSE] ({idx}/{len(article_links)}) {article_url[:70]}...")
|
| 193 |
+
article_data = parse_article_detail(driver, article_url, category_name)
|
| 194 |
+
|
| 195 |
+
if article_data["title"] and article_data["content"]:
|
| 196 |
+
# 만약 파싱된 published_date가 비었거나 이상하다면 target_date 기반으로 날짜 형식 설정
|
| 197 |
+
if not article_data["published_date"] or "202" not in article_data["published_date"]:
|
| 198 |
+
formatted_date = f"{target_date[:4]}-{target_date[4:6]}-{target_date[6:]} 09:00"
|
| 199 |
+
article_data["published_date"] = formatted_date
|
| 200 |
+
|
| 201 |
+
all_articles.append(article_data)
|
| 202 |
+
cat_ok += 1
|
| 203 |
+
print(f" ✅ {article_data['title'][:40]}...")
|
| 204 |
+
print(f" 언론사: {article_data['source']} | 날짜: {article_data['published_date']}")
|
| 205 |
+
else:
|
| 206 |
+
cat_fail += 1
|
| 207 |
+
missing = [
|
| 208 |
+
x
|
| 209 |
+
for x, v in [
|
| 210 |
+
("제목", article_data["title"]),
|
| 211 |
+
("본문", article_data["content"]),
|
| 212 |
+
]
|
| 213 |
+
if not v
|
| 214 |
]
|
| 215 |
+
print(f" ❌ 파싱실패 ({', '.join(missing)} 없음)")
|
| 216 |
+
time.sleep(0.5)
|
|
|
|
|
|
|
| 217 |
|
| 218 |
+
category_stats[cat_key] = {"ok": cat_ok, "fail": cat_fail}
|
| 219 |
+
print(f"\n [CRAWL] [{category_name} - {target_date}] 완료: 성공 {cat_ok}개 / 실패 {cat_fail}개")
|
| 220 |
|
| 221 |
driver.quit()
|
| 222 |
print("\n[DONE] 브라우저 종료")
|
| 223 |
print(f"\n{'=' * 60}")
|
| 224 |
+
print("[SUMMARY] 수집 결과 Summary")
|
| 225 |
print(f"{'=' * 60}")
|
| 226 |
+
total_ok = 0
|
| 227 |
+
total_fail = 0
|
| 228 |
+
for cat_key, s in category_stats.items():
|
| 229 |
+
print(f" {cat_key}: 성공 {s['ok']}건 / 실패 {s['fail']}건")
|
| 230 |
+
total_ok += s['ok']
|
| 231 |
+
total_fail += s['fail']
|
| 232 |
+
print(f" 전체 수집: 성공 {total_ok}건 / 실패 {total_fail}건")
|
| 233 |
|
| 234 |
df_all = pd.DataFrame(all_articles)
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
# ── 2단계: AI 핀테크 키워드 필터링 ──
|
|
|
|
| 261 |
df_filtered
|
| 262 |
|
| 263 |
# ── 3단계: 저장 ──
|
| 264 |
+
import os
|
| 265 |
+
|
| 266 |
+
output_dir = os.path.join("src", "graphBuilder", "scrapping")
|
| 267 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 268 |
+
output_filename = os.path.join(output_dir, f"Articles_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx")
|
| 269 |
df_filtered.to_excel(output_filename, index=False, engine="openpyxl")
|
| 270 |
print(f"[SAVE] ✅ 저장 완료: {output_filename}")
|
| 271 |
print(f"[SAVE] - AI 핀테크 기사: {len(df_filtered)}건")
|
tests/smoke_test_rag.py
CHANGED
|
@@ -51,6 +51,7 @@ def check_graph_structure():
|
|
| 51 |
print("📊 [사전 점검] Neo4j 그래프 구성 현황")
|
| 52 |
print("=" * 60)
|
| 53 |
|
|
|
|
| 54 |
queries = {
|
| 55 |
"Article (기사)": "MATCH (n:Article) RETURN count(n) as cnt",
|
| 56 |
"AICompany (기업)": "MATCH (n:AICompany) RETURN count(n) as cnt",
|
|
@@ -72,13 +73,56 @@ def check_graph_structure():
|
|
| 72 |
all_ok = False
|
| 73 |
print(f" {status} {label}: {cnt}개")
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
driver.close()
|
| 76 |
print()
|
| 77 |
if not all_ok:
|
| 78 |
-
print("⛔ 일부 노드/관계가 비어있
|
| 79 |
sys.exit(1)
|
| 80 |
else:
|
| 81 |
-
print("✅ 그래프 구성 정상 — RAG 테스트를 시작합니다.\n")
|
| 82 |
|
| 83 |
|
| 84 |
# ── 1. GraphRAG 응답 품질 검증 ───────────────────────────────────────────────
|
|
|
|
| 51 |
print("📊 [사전 점검] Neo4j 그래프 구성 현황")
|
| 52 |
print("=" * 60)
|
| 53 |
|
| 54 |
+
# ── 노드/기본 관계 수 점검 ──────────────────────────────────────────────
|
| 55 |
queries = {
|
| 56 |
"Article (기사)": "MATCH (n:Article) RETURN count(n) as cnt",
|
| 57 |
"AICompany (기업)": "MATCH (n:AICompany) RETURN count(n) as cnt",
|
|
|
|
| 73 |
all_ok = False
|
| 74 |
print(f" {status} {label}: {cnt}개")
|
| 75 |
|
| 76 |
+
# ── 엔티티 간 직접 관계 연결성 심층 점검 ───────────────────────────────
|
| 77 |
+
print()
|
| 78 |
+
print(" [엔티티 간 직접 관계 연결성 점검]")
|
| 79 |
+
entity_rel_types = ["DEVELOPS", "INVESTS_IN", "PARTNERS_WITH", "APPLIES", "USED_IN", "RELATED_TO"]
|
| 80 |
+
total_entity_rels = 0
|
| 81 |
+
with driver.session() as s:
|
| 82 |
+
for rel_type in entity_rel_types:
|
| 83 |
+
cnt = s.run(
|
| 84 |
+
f"MATCH ()-[r:{rel_type}]->() RETURN count(r) as cnt"
|
| 85 |
+
).single()["cnt"]
|
| 86 |
+
total_entity_rels += cnt
|
| 87 |
+
status = "✅" if cnt > 0 else "⚠️"
|
| 88 |
+
print(f" {status} {rel_type}: {cnt}개")
|
| 89 |
+
|
| 90 |
+
# 고립 노드(관계가 전혀 없는 Content 제외) 비율 점검
|
| 91 |
+
isolated = s.run(
|
| 92 |
+
"MATCH (n) WHERE NOT (n)--() AND NOT n:Content RETURN count(n) as cnt"
|
| 93 |
+
).single()["cnt"]
|
| 94 |
+
total_nodes = s.run(
|
| 95 |
+
"MATCH (n) WHERE NOT n:Content RETURN count(n) as cnt"
|
| 96 |
+
).single()["cnt"]
|
| 97 |
+
|
| 98 |
+
isolation_rate = (isolated / total_nodes * 100) if total_nodes > 0 else 0
|
| 99 |
+
iso_status = "✅" if isolation_rate < 20 else "⚠️ 고립 노드 과다"
|
| 100 |
+
print(f"\n {iso_status} 고립 노드(Content 제외): {isolated}개 / 전체: {total_nodes}개 ({isolation_rate:.1f}%)")
|
| 101 |
+
print(f" 엔티티 간 직접 관계 합계: {total_entity_rels}개")
|
| 102 |
+
|
| 103 |
+
# 엔티티 간 관계가 전혀 없으면 실패 처리
|
| 104 |
+
if total_entity_rels == 0:
|
| 105 |
+
print("\n ⛔ 엔티티 간 직접 관계(DEVELOPS/APPLIES 등)가 0개입니다. finGraph.py 재실행 필요.")
|
| 106 |
+
all_ok = False
|
| 107 |
+
|
| 108 |
+
# 최소 임계값: 기사 10건당 직접 관계 5개 이상 권고
|
| 109 |
+
with driver.session() as s:
|
| 110 |
+
article_cnt = s.run("MATCH (n:Article) RETURN count(n) as cnt").single()["cnt"]
|
| 111 |
+
if article_cnt > 0:
|
| 112 |
+
rels_per_article = total_entity_rels / article_cnt
|
| 113 |
+
threshold_ok = rels_per_article >= 3.0
|
| 114 |
+
t_status = "✅" if threshold_ok else "⚠️ 관계 밀도 부족"
|
| 115 |
+
print(f" {t_status} 기사당 평균 엔티티 관계: {rels_per_article:.1f}개 (권고: 3.0개 이상)")
|
| 116 |
+
if not threshold_ok:
|
| 117 |
+
all_ok = False
|
| 118 |
+
|
| 119 |
driver.close()
|
| 120 |
print()
|
| 121 |
if not all_ok:
|
| 122 |
+
print("⛔ 일부 노드/관계가 비어있거나 연결성이 부족합니다. finGraph.py 실행으로 그래프를 채워주세요.\n")
|
| 123 |
sys.exit(1)
|
| 124 |
else:
|
| 125 |
+
print("✅ 그래프 구성 및 연결성 정상 — RAG 테스트를 시작합니다.\n")
|
| 126 |
|
| 127 |
|
| 128 |
# ── 1. GraphRAG 응답 품질 검증 ───────────────────────────────────────────────
|