""" app.py — FinNode GraphRAG 챗봇 ================================ Hugging Face Spaces 배포 진입점. Gradio ChatInterface + LangGraph 기반 대화 흐름 제어. 실행: python app.py """ from typing import Any, Dict, List, TypedDict import dotenv import gradio.networking # ────────────────────────────────────────── # HF Spaces/Docker 루프백 접속 검증 우회 몽키 패치 # ────────────────────────────────────────── # 일부 가상화/도커 환경에서 127.0.0.1:7860 로컬 접속 여부 자체 체크가 # 프록시 및 루프백 인터페이스 차단으로 인해 실패하여 ValueError가 발생하는 현상을 방지합니다. gradio.networking.url_ok = lambda *args, **kwargs: True import gradio as gr from langgraph.graph import END, StateGraph from src.retrieval.finRetrieval import HybridResult, graphrag from src.utils.ui_templates import CUSTOM_CSS, build_stats_html dotenv.load_dotenv() # ────────────────────────────────────────── # Startup DB 자가 진단 (Fail-Fast) # ────────────────────────────────────────── # 허깅페이스Spaces 및 실제 앱 서버 구동 시점에는 즉시 자가 진단을 수행하여, # Neo4j 데이터베이스 연결이 불가능하면 구동 실패(Crash Early)를 일으킵니다. try: graphrag._init_once() try: print("✅ [자가 진단 완료] Neo4j AuraDB 지식 그래프에 완벽하게 접속되었습니다!") except UnicodeEncodeError: print("[OK] [자가 진단 완료] Neo4j AuraDB 지식 그래프에 완벽하게 접속되었습니다!") except Exception as e: try: print(f"❌ [자가 진단 실패] Neo4j DB 연결 확인 중 에러가 발생했습니다: {e}") except UnicodeEncodeError: print(f"[FAIL] [자가 진단 실패] Neo4j DB 연결 확인 중 에러가 발생했습니다: {e}") raise e # ────────────────────────────────────────── # 1. LangGraph 챗봇 State 정의 # ────────────────────────────────────────── class ChatState(TypedDict): question: str # 사용자 질문 history: List[dict] # 대화 히스토리 [{"role": "user"/"assistant", "content": "..."}] context: str # GraphRAG 검색 결과 또는 일반 지식 답변 answer: str # 최종 답변 mode: str # "graph": 그래프 기반 | "general": 일반 지식 기반 # ────────────────────────────────────────── # 2. LangGraph 노드 정의 # ────────────────────────────────────────── def retrieve_node(state: ChatState) -> ChatState: """Node 1: search_with_fallback으로 그래프 검색 또는 일반 지식 응답 라우팅""" try: hybrid: HybridResult = graphrag.search_with_fallback( query_text=state["question"], history=state["history"], ) if hybrid.mode == "general": # 일반 지식 모드: 배너 + GPT-4o 답변 반환 disclaimer = ( "> ⚠️ **지식 그래프에서 관련 뉴스를 찾지 못했습니다.**\n" "> GPT-4o의 일반 학습 데이터를 기반으로 답변합니다.\n" "> 최신 국내 뉴스 기반 정보가 필요하다면 질문을 더 구체적으로 입력해 보세요.\n\n" "---\n\n" ) context = disclaimer + hybrid.answer return {**state, "context": context, "mode": "general"} # 그래프 기반 모드: 기존 출처 추출 + 뉴스 피드 로직 context = hybrid.answer sources = [] seen_urls: set = set() # retriever_result에서 상위 3개 뉴스 출처 추출 retriever_result = hybrid.retriever_result if retriever_result and hasattr(retriever_result, "items"): for item in retriever_result.items: meta = getattr(item, "metadata", {}) title = meta.get("article_title") url = meta.get("article_url") date = meta.get("article_date") if title and url and url not in seen_urls: seen_urls.add(url) # date 형식 포맷팅 (예: 2026-05-19T00:00:00Z -> 2026-05-19) if date and "T" in str(date): date = str(date).split("T")[0] sources.append({"title": title, "url": url, "date": date}) if len(sources) >= 3: break # 만약 retriever_result에서 찾지 못한 경우, Neo4j DB에서 키워드 기반으로 직접 관련 뉴스 3개 백업 조회 if not sources: try: from src.retrieval.finRetrieval import get_neo4j_driver driver = get_neo4j_driver() # 단순 키워드 매칭 쿼리 query_words = [w for w in state["question"].split() if len(w) > 1] conditions = [] for w in query_words[:3]: conditions.append(f"a.title CONTAINS '{w}' OR a.description CONTAINS '{w}'") with driver.session() as session: cypher = "MATCH (a:Article) " if conditions: cypher += "WHERE " + " OR ".join(conditions) + " " cypher += "RETURN a.title as title, a.url as url, a.published_date as date ORDER BY a.published_date DESC LIMIT 3" res_backup = session.run(cypher) for r in res_backup: title = r["title"] url = r["url"] date = r["date"] if title and url and url not in seen_urls: seen_urls.add(url) if date and "T" in str(date): date = str(date).split("T")[0] sources.append({"title": title, "url": url, "date": date}) except Exception: pass # 만약 여전히 비어있다면, 최신 뉴스 3개 노출 (상상해 낸 가짜 정보 방지) if not sources: try: from src.retrieval.finRetrieval import get_neo4j_driver driver = get_neo4j_driver() with driver.session() as session: res_latest = session.run( "MATCH (a:Article) RETURN a.title as title, a.url as url, a.published_date as date " "ORDER BY a.published_date DESC LIMIT 3" ) for r in res_latest: title = r["title"] url = r["url"] date = r["date"] if title and url and url not in seen_urls: seen_urls.add(url) if date and "T" in str(date): date = str(date).split("T")[0] sources.append({"title": title, "url": url, "date": date}) except Exception: pass # 답변 끝에 📰 관련 뉴스 피드 파트 정성스럽게 덧붙이기 if sources: news_feed = "\n\n📰 **관련 뉴스 피드 (실시간 분석 출처)**\n" for s in sources: date_str = f" ({s['date']})" if s['date'] else "" news_feed += f"- 🔗 [{s['title']}]({s['url']}){date_str}\n" # 중복으로 관련 뉴스 피드가 붙지 않도록 방지 if "관련 뉴스 피드" not in context: context += news_feed except Exception as e: context = f"[검색 오류: {e}]" return {**state, "context": context, "mode": state.get("mode", "graph")} def generate_node(state: ChatState) -> ChatState: """Node 2: 대화 히스토리를 고려하여 최종 답변 생성 GraphRAG(graph 모드) 또는 일반 지식(general 모드) 응답 모두 retrieve_node에서 context에 최종 텍스트를 담아주므로 그대로 사용합니다. """ answer = state["context"] if state["context"] else "관련 정보를 찾을 수 없습니다." return {**state, "answer": answer} # ────────────────────────────────────────── # 3. LangGraph 워크플로우 컴파일 # ────────────────────────────────────────── builder = StateGraph(ChatState) builder.add_node("retrieve", retrieve_node) builder.add_node("generate", generate_node) builder.set_entry_point("retrieve") builder.add_edge("retrieve", "generate") builder.add_edge("generate", END) chat_graph = builder.compile() # ────────────────────────────────────────── # 4. Gradio 연동 함수 # ────────────────────────────────────────── def chat(message: str, history: list): """Gradio ChatInterface가 호출하는 함수. Args: message: 사용자 입력 메시지 history: Gradio가 관리하는 대화 히스토리 [{"role": "user"/"assistant", "content": "..."}] 형식 Returns: Generator: 챗봇 답변 (실시간 상태 표시 포함) """ if not message.strip(): yield "질문을 입력해 주세요." return # Gradio history → LangGraph state 형식으로 변환 state: ChatState = { "question": message, "history": history, "context": "", "answer": "", "mode": "", } yield "🔍 실시간 지식 그래프에서 관련 뉴스를 검색하는 중입니다..." try: # LangGraph의 stream을 사용하여 각 노드 실행 시점마다 이벤트를 받음 for event in chat_graph.stream(state): if "retrieve" in event: retrieved_mode = event["retrieve"].get("mode", "graph") if retrieved_mode == "general": yield "🌐 관련 뉴스 없음 — GPT-4o 일반 지식으로 답변을 생성하는 중입니다..." else: yield "💡 검색 완료! 분석 결과를 바탕으로 최종 답변을 생성하는 중입니다..." elif "generate" in event: yield event["generate"]["answer"] except Exception as e: yield f"⚠️ 챗봇 처리 중 오류가 발생했습니다: {str(e)}" def get_db_stats() -> Dict[str, Any]: """Neo4j 데이터베이스로부터 실시간 지식 그래프 통계 및 요약을 안전하게 조회합니다. Returns: Dict[str, Any]: 기사 건수, 기업 수, 기술 수, 관계 수, 세부 설명 목록 """ stats: Dict[str, Any] = { "articles": 0, "companies": 0, "technologies": 0, "techs_list": [], "recent_articles": [], } try: from src.retrieval.finRetrieval import get_neo4j_driver driver = get_neo4j_driver() with driver.session() as session: # 1. 각 노드별 갯수 조회 res_articles = session.run("MATCH (a:Article) RETURN count(a) as cnt").single() if res_articles: stats["articles"] = res_articles["cnt"] res_companies = session.run("MATCH (c:AICompany) RETURN count(c) as cnt").single() if res_companies: stats["companies"] = res_companies["cnt"] res_techs = session.run("MATCH (t:AITechnology) RETURN count(t) as cnt").single() if res_techs: stats["technologies"] = res_techs["cnt"] # 2. 기술 목록 & 설명 조회 (상위 8개) res_tech_list = session.run( "MATCH (t:AITechnology) " "RETURN t.name as name, COALESCE(t.description, 'AI 혁신 기술 인프라') as desc LIMIT 8" ) stats["techs_list"] = [{"name": r["name"], "desc": r["desc"]} for r in res_tech_list] # 2.5 최신 주목 기업 리스트 (상위 5개) res_comp_list = session.run( "MATCH (c:AICompany) " "OPTIONAL MATCH (a:Article)-[:MENTIONS]->(c) " "RETURN c.name as name, count(a) as cnt " "ORDER BY cnt DESC LIMIT 5" ) stats["companies_list"] = [{"name": r["name"]} for r in res_comp_list] # 3. 최근 기사 목록 조회 (최근 4개) res_art_list = session.run( "MATCH (a:Article) " "RETURN a.title as title, a.published_date as date, a.url as url " "ORDER BY a.published_date DESC LIMIT 4" ) stats["recent_articles"] = [ {"title": r["title"], "date": r["date"], "url": r["url"]} for r in res_art_list ] except Exception as e: print(f"⚠️ [통계 조회 실패] Neo4j 통계를 가져오는 데 실패했습니다: {e}") return stats # ────────────────────────────────────────── # 5. Gradio UI 구성 # ────────────────────────────────────────── # Gradio 버전 동적 감지 및 테마 설정 분기 (로컬 6.x vs 원격 4.x 크래시 완벽 방지) try: gradio_major = int(gr.__version__.split(".")[0]) except Exception: gradio_major = 4 # 기본값 백업 theme_obj = gr.themes.Soft( font=["Pretendard", "-apple-system", "BlinkMacSystemFont", "system-ui", "sans-serif"], primary_hue="sky", secondary_hue="slate", ) CHATBOT_DESCRIPTION = """
👇 아래 예시 질문 버튼을 클릭하거나 직접 입력해 보세요.