dev-yuje commited on
Commit
c64138a
·
1 Parent(s): 2954d4f

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 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
- chat_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
 
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"엔티티 목록:\n{elist}\n\n"
205
- "관계 유형: DEVELOPS, INVESTS_IN, PARTNERS_WITH, APPLIES, USED_IN, RELATED_TO\n"
206
- f"본문: {state['text'][:700]}\n\n"
207
- '공으로만:{"relations":[{"source":"...","relation":"...","target":"..."}]}'
 
 
 
 
 
 
 
 
 
 
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
- relations = json.loads(raw).get("relations", [])
215
- names = {e["name"] for e in state["entities"]}
216
- relations = [r for r in relations if r.get("source") in names and r.get("target") in names]
217
- except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  relations = []
219
- return {**state, "relations": relations}
 
 
 
 
 
 
 
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
- """추출된 엔티티 품질 검증하고, 미달 경우 최대 3회까지 자기반성(Self-Reflection) 루프를 동작시킵니다."""
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] 엔티티 품질 미달 (시도 {retry_count}/3). 피드백: {feedback[:100]}...")
235
- return "extract_entities" # 자기반성 루프로 복귀
236
-
237
  if feedback and retry_count >= 3:
238
- print(f" 🚨 [Self-Reflection] 엔티티 3회 시도 초과. 검증 오류가 있지만 패스합니다. 피드백: {feedback[:100]}...")
239
-
240
- return "extract_relations" # 검증을 정상 통과했거나 최대 3회 한도에 도달한 경우 통과
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- print(f" ✅ [{idx + 1}/{len(df)}] 신규 적재완료: {title[:35]}... | 엔티티: {[ent['name'] for ent in out['entities'][:4]]}")
 
 
 
 
 
 
 
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
- categories = {
14
- "경제": "https://news.naver.com/section/101",
15
- "IT/과학": "https://news.naver.com/section/105",
16
  }
17
- NUM_ARTICLES_PER_CATEGORY = 1500
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, category_url, num_articles):
40
- print(f" [LINK] 페이지 이동: {category_url}")
41
- driver.get(category_url)
42
- time.sleep(3)
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 selector in selectors:
68
- elements = driver.find_elements(By.CSS_SELECTOR, selector)
69
- print(f" [LINK] 셀렉터 '{selector}' -> {len(elements)} 발견")
70
- for element in elements:
71
- url = element.get_attribute("href")
72
- if (
73
- url
74
- and "news.naver.com" in url
75
- and "/article/" in url
76
- and "/comment/" not in url
77
- and url not in article_links
78
- ):
79
- article_links.append(url)
80
- if len(article_links) >= num_articles:
81
- break
82
- if len(article_links) >= num_articles:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- for category_name, category_url in categories.items():
 
 
 
 
 
169
  print(f"\n{'=' * 60}")
170
- print(f"[CRAWL] [{category_name}] 카테고리 수집 시작")
171
  print(f"{'=' * 60}")
172
 
173
- article_links = get_article_links(driver, category_url, NUM_ARTICLES_PER_CATEGORY)
174
-
175
- cat_ok, cat_fail = 0, 0
176
- for idx, article_url in enumerate(article_links, 1):
177
- print(f" [PARSE] ({idx}/{len(article_links)}) {article_url[:70]}...")
178
- article_data = parse_article_detail(driver, article_url, category_name)
179
-
180
- if article_data["title"] and article_data["content"]:
181
- all_articles.append(article_data)
182
- cat_ok += 1
183
- print(f" {article_data['title'][:40]}...")
184
- print(f" 언론사: {article_data['source']} | 날짜: {article_data['published_date']}")
185
- else:
186
- cat_fail += 1
187
- missing = [
188
- x
189
- for x, v in [
190
- ("제목", article_data["title"]),
191
- ("본문", article_data["content"]),
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  ]
193
- if not v
194
- ]
195
- print(f" ❌ 파싱실패 ({', '.join(missing)} 없음)")
196
- time.sleep(0.5)
197
 
198
- category_stats[category_name] = {"ok": cat_ok, "fail": cat_fail}
199
- print(f"\n [CRAWL] [{category_name}] 완료: 성공 {cat_ok}개 / 실패 {cat_fail}개")
200
 
201
  driver.quit()
202
  print("\n[DONE] 브라우저 종료")
203
  print(f"\n{'=' * 60}")
204
- print("[SUMMARY] 수집 결과 요약")
205
  print(f"{'=' * 60}")
206
- for cat, s in category_stats.items():
207
- print(f" {cat}: 성공 {s['ok']}건 / 실패 {s['fail']}건")
208
- print(f" 전체 수집: {len(all_articles)}건")
 
 
 
 
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
- output_filename = f"Articles_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
 
 
 
 
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("⛔ 일부 노드/관계가 비어있니다. finGraph.py 실행으로 그래프를 먼저 채워주세요.\n")
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 응답 품질 검증 ───────────────────────────────────────────────