File size: 16,133 Bytes
f4f2569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# ============================================================
# 실습 4 (웹 배포판): 네이버 뉴스 기반 팩트체크 파이프라인
#   - 원본 CLI 스크립트(langgraph_factcheck.py)를 Streamlit 웹앱으로 변환
#   - Hugging Face Spaces(SDK: Streamlit)에 그대로 올려 배포하는 것을 목표로 함
# ============================================================
#
# [이 파일이 원본과 다른 점]
#   1) print(...) 로 터미널에 찍던 출력을 → 화면(Streamlit 위젯)으로 보여준다.
#   2) 코드에 박아두던 주장(claim) 을 → 사용자가 입력창에 직접 넣는다.
#   3) API 키를 .env 가 아니라 → Hugging Face "Secrets"(=환경변수)에서 읽는다.
#   4) 그래프(LangGraph) 조립은 매 실행마다 다시 만들 필요가 없으므로
#      @st.cache_resource 로 한 번만 만들어 재사용한다(속도/비용 절약).
#
# [Hugging Face Spaces 배포 방법 — 요약]
#   - Space 생성 시 SDK를 "Streamlit" 으로 선택한다.
#   - 이 파일 이름을 app.py 로 두면 Spaces가 자동으로 실행한다(진입점).
#   - requirements.txt 에 의존성(아래 목록)을 적어 둔다.
#   - Space의 Settings → "Repository secrets" 에 아래 3개 키를 등록한다:
#       GEMINI_API_KEY, NAVER_CLIENT_ID, NAVER_CLIENT_SECRET
#     (Secrets에 넣으면 코드에서 os.environ 으로 읽을 수 있다.)
# ============================================================


# ── 환경 설정 ──────────────────────────────────────────────
import os
import re
import requests
import streamlit as st

# 로컬에서 테스트할 때만 .env 를 읽는다.
#   - Hugging Face Spaces 에서는 .env 파일이 없고 Secrets(환경변수)로 주입되므로,
#     python-dotenv 가 없거나 .env 가 없어도 오류 없이 넘어가도록 try 로 감싼다.
try:
    from dotenv import load_dotenv
    load_dotenv()
except Exception:
    pass

# Gemini 라이브러리는 GOOGLE_API_KEY 라는 이름을 찾는다.
#   - 우리가 등록한 건 GEMINI_API_KEY 이므로, 값이 있으면 그 이름으로 복사해 준다.
if os.environ.get("GEMINI_API_KEY"):
    os.environ["GOOGLE_API_KEY"] = os.environ["GEMINI_API_KEY"]


# ── 패키지 임포트 ──────────────────────────────────────────
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_google_genai import ChatGoogleGenerativeAI


# ============================================================
# 0단계: 페이지 기본 설정 — 브라우저 탭 제목, 아이콘, 레이아웃
# ============================================================
st.set_page_config(
    page_title="뉴스 기반 팩트체크",   # 브라우저 탭에 뜨는 제목
    page_icon="🔎",                    # 탭 아이콘(파비콘)
    layout="centered",                  # 가운데 정렬 레이아웃
)


# ============================================================
# 1단계: 상태(State) 정의 — 그래프 전체에서 공유하는 데이터
# ============================================================
# 노드들이 차례로 이 딕셔너리의 빈 칸을 채워 나간다. (원본과 동일)
class FactCheckState(TypedDict):
    claim: str       # [입력] 검증할 주장 (예: "한국은행이 기준금리를 인상했다")
    query: str       # extract_query가 채움: 뉴스 검색에 쓸 검색어
    evidence: str    # search_news가 채움: 검색된 뉴스 원문(제목+본문)
    summary: str     # summarize가 채움: 근거로 쓸 요약문
    verdict: str     # fact_check가 채움: 최종 판정 결과


# ── 네이버 뉴스 검색 "도구" 함수 ────────────────────────────
# LLM에게 주는 게 아니라 우리가 직접 호출하는 일반 함수다. (원본과 동일)
def naver_news_search(query: str, display: int = 5) -> str:
    """네이버 뉴스 검색 API를 호출해 상위 기사 몇 개의 제목+요약을 문자열로 돌려준다."""
    cid = os.environ.get("NAVER_CLIENT_ID", "")
    secret = os.environ.get("NAVER_CLIENT_SECRET", "")
    if not cid or not secret:
        return "(네이버 API 키 없음 — Spaces Secrets의 NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 확인)"

    url = "https://openapi.naver.com/v1/search/news.json"
    headers = {                          # 네이버는 헤더로 인증한다
        "X-Naver-Client-Id": cid,
        "X-Naver-Client-Secret": secret,
    }
    params = {"query": query, "display": display, "sort": "sim"}  # sim=정확도순

    try:
        r = requests.get(url, headers=headers, params=params, timeout=10)
        r.raise_for_status()             # 200이 아니면 예외 발생
        items = r.json().get("items", [])
    except Exception as e:
        return f"(뉴스 검색 실패: {e})"

    if not items:
        return "(검색 결과 없음)"

    # 결과의 <b></b> 같은 HTML 태그와 특수문자를 제거해 깔끔한 텍스트로 만든다
    def clean(text: str) -> str:
        text = re.sub(r"<[^>]+>", "", text)        # 모든 <...> 태그 제거
        text = text.replace("&quot;", '"').replace("&amp;", "&")
        text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&nbsp;", " ")
        return text.strip()

    lines = []
    for i, it in enumerate(items, 1):    # enumerate(..., 1): 번호를 1부터 매김
        title = clean(it.get("title", ""))
        desc = clean(it.get("description", ""))
        lines.append(f"{i}) {title}\n   {desc}")
    return "\n".join(lines)


# ============================================================
# 2단계: 그래프(LLM + 노드 + 엣지) 만들기
# ============================================================
# @st.cache_resource :
#   - "한 번 만든 무거운 객체(여기선 컴파일된 그래프)를 메모리에 캐싱"하는 데코레이터.
#   - 사용자가 버튼을 누를 때마다 그래프를 새로 조립하면 느리고 낭비이므로,
#     최초 1회만 만들고 그 뒤로는 같은 객체를 재사용한다.
@st.cache_resource(show_spinner="🧠 팩트체크 엔진을 준비하는 중...")
def build_app():
    # ── LLM(두뇌) 초기화 ──
    #   temperature=0 : 팩트체크는 일관성이 중요하므로 창의성을 최소화한다.
    llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash-lite", temperature=0)

    # ── 노드 1: 주장에서 검색어 뽑기 ──
    def extract_query(state: FactCheckState) -> dict:
        claim = state["claim"]
        prompt = (
            f"다음 주장을 사실 확인하려고 한다. 네이버 뉴스에서 검색할 핵심 키워드만 "
            f"5단어 이내로, 다른 말 없이 키워드만 출력해라.\n주장: {claim}"
        )
        query = llm.invoke(prompt).content.strip()
        return {"query": query}          # 바뀐 키만 반환 → LangGraph가 상태에 합쳐 줌

    # ── 노드 2: 네이버 뉴스 검색 ──
    def search_news(state: FactCheckState) -> dict:
        evidence = naver_news_search(state["query"], display=5)
        return {"evidence": evidence}

    # ── 노드 3: 근거 요약 ──
    def summarize(state: FactCheckState) -> dict:
        prompt = (
            "다음 뉴스 검색 결과를 사실 확인의 근거로 쓰려고 한다. "
            "핵심 사실만 3~4문장으로 요약해라. 추측은 빼고 보도된 내용만.\n\n"
            f"{state['evidence']}"
        )
        summary = llm.invoke(prompt).content.strip()
        return {"summary": summary}

    # ── 노드 4: 팩트체크 (요약을 근거로 진위 판정) ──
    def fact_check(state: FactCheckState) -> dict:
        prompt = (
            "너는 팩트체커다. 아래 '근거'(뉴스 요약)만을 토대로 '주장'의 진위를 판정해라.\n"
            "형식을 정확히 지켜 출력:\n"
            "판정: [사실 / 거짓 / 불확실] 중 하나\n"
            "이유: 근거에 비춰 1~2문장\n\n"
            f"[주장]\n{state['claim']}\n\n"
            f"[근거]\n{state['summary']}"
        )
        verdict = llm.invoke(prompt).content.strip()
        return {"verdict": verdict}

    # ── 그래프 조립 — 노드를 한 줄로 이어 파이프라인 구성 ──
    graph = StateGraph(FactCheckState)
    graph.add_node("extract_query", extract_query)
    graph.add_node("search_news", search_news)
    graph.add_node("summarize", summarize)
    graph.add_node("fact_check", fact_check)

    # 분기 없이 한 방향으로 쭉 흐른다 (직선형 워크플로우)
    #   [START] → extract_query → search_news → summarize → fact_check → [END]
    graph.add_edge(START, "extract_query")
    graph.add_edge("extract_query", "search_news")
    graph.add_edge("search_news", "summarize")
    graph.add_edge("summarize", "fact_check")
    graph.add_edge("fact_check", END)

    return graph.compile()               # 컴파일된 실행 가능한 앱 객체를 돌려준다


# ============================================================
# 3단계: 화면(UI) 구성
# ============================================================
st.title("🔎 뉴스 기반 팩트체크")
st.caption("LangGraph 워크플로우 · 네이버 뉴스 검색 · Gemini")

# 사이드바: 앱 설명과 동작 원리를 접어서(expander) 보여준다.
with st.sidebar:
    st.header("ℹ️ 어떻게 동작하나요?")
    st.markdown(
        """
        주장 하나를 입력하면 아래 **4단계 파이프라인**이 순서대로 흐릅니다.

        1. **검색어 추출** — 주장에서 핵심 키워드를 뽑습니다.
        2. **뉴스 검색** — 네이버 뉴스 API로 관련 기사를 모읍니다.
        3. **근거 요약** — 검색 결과의 핵심 사실만 요약합니다.
        4. **진위 판정** — 요약을 근거로 사실/거짓/불확실을 판정합니다.

        > LLM의 '기억'이 아니라 '최신 뉴스'를 근거로 삼는 것이 핵심입니다.
        """
    )
    st.divider()
    st.markdown(
        "**배포 메모**: 이 앱은 `GEMINI_API_KEY`, `NAVER_CLIENT_ID`, "
        "`NAVER_CLIENT_SECRET` 환경변수가 필요합니다."
    )

# ── 필수 환경변수 점검 ──
#   키가 없으면 LLM 호출이 실패하므로, 미리 친절히 안내하고 멈춘다.
missing = [
    name for name in ("GEMINI_API_KEY", "NAVER_CLIENT_ID", "NAVER_CLIENT_SECRET")
    if not os.environ.get(name)
]
if missing:
    st.error(
        "다음 환경변수가 설정되지 않았습니다: "
        + ", ".join(f"`{m}`" for m in missing)
        + "\n\nHugging Face Space의 **Settings → Repository secrets** 에 등록해 주세요."
    )
    st.stop()                            # 키가 없으면 아래 코드를 실행하지 않고 중단

# ── 예시 주장(샘플) — 버튼으로 입력창을 빠르게 채워 볼 수 있게 ──
EXAMPLES = [
    "한국은행이 최근 기준금리를 동결했다.",
    "삼성전자가 3나노 공정 양산을 시작했다.",
]

# st.session_state : 사용자가 버튼을 눌렀을 때의 값을 다음 실행까지 기억하는 저장소.
#   - "claim_text" 키가 아직 없으면 첫 예시로 초기화한다.
if "claim_text" not in st.session_state:
    st.session_state["claim_text"] = EXAMPLES[0]

st.write("**예시로 빠르게 시작하기:**")
cols = st.columns(len(EXAMPLES))         # 예시 개수만큼 가로 칸을 만든다
for col, example in zip(cols, EXAMPLES):
    # 예시 버튼을 누르면 입력창(claim_text)에 그 문장을 넣어 준다.
    if col.button(example, use_container_width=True):
        st.session_state["claim_text"] = example

# ── 주장 입력창 ──
#   key="claim_text" 로 위 session_state 와 연결 → 예시 버튼 클릭이 즉시 반영된다.
claim = st.text_area(
    "검증할 주장을 입력하세요",
    key="claim_text",
    height=80,
    placeholder="예: 한국은행이 최근 기준금리를 동결했다.",
)

# ── 실행 버튼 ──
run = st.button("✅ 팩트체크 실행", type="primary", use_container_width=True)


# ============================================================
# 4단계: 버튼을 눌렀을 때 — 파이프라인 실행 & 결과 표시
# ============================================================
if run:
    # 빈 입력 방어
    if not claim.strip():
        st.warning("주장을 먼저 입력해 주세요.")
        st.stop()

    app = build_app()                    # 캐시된 그래프를 가져온다(최초 1회만 조립)

    # 단계별 진행 상황을 사용자가 볼 수 있도록 status 위젯을 쓴다.
    #   - 각 노드를 직접 호출하지 않고, 전체 그래프를 invoke 하면 결과만 한 번에 나온다.
    #   - 학습용으로 "어떤 단계가 도는 중인지"를 보여주기 위해 단계 메시지를 함께 띄운다.
    with st.status("파이프라인 실행 중...", expanded=True) as status:
        try:
            st.write("① 검색어 추출 → ② 뉴스 검색 → ③ 근거 요약 → ④ 진위 판정")
            # invoke: START부터 END까지 파이프라인을 한 번 실행하고 최종 상태를 돌려준다.
            #   비어 있는 칸(query/evidence/summary/verdict)을 노드들이 채워 나간다.
            result = app.invoke({
                "claim": claim,
                "query": "",
                "evidence": "",
                "summary": "",
                "verdict": "",
            })
            status.update(label="완료!", state="complete", expanded=False)
        except Exception as e:
            # API 키 오류, 네트워크 오류 등은 여기서 잡아 화면에 보여준다.
            status.update(label="오류 발생", state="error")
            st.exception(e)
            st.stop()

    # ── 결과 출력 ──
    st.subheader("⚖️ 팩트체크 결과")

    verdict_text = result["verdict"]
    # 판정 키워드에 따라 색이 다른 박스로 강조한다(사실=초록, 거짓=빨강, 그 외=노랑).
    if "사실" in verdict_text and "거짓" not in verdict_text:
        st.success(verdict_text)
    elif "거짓" in verdict_text:
        st.error(verdict_text)
    else:
        st.warning(verdict_text)

    # 근거 요약은 접이식 박스로 함께 제공 → 판정의 '투명성' 확보.
    with st.expander("📰 근거로 쓴 뉴스 요약 보기", expanded=True):
        st.write(result["summary"])

    # 검색어와 원문 근거도 펼쳐 볼 수 있게 둔다(디버깅/학습용).
    with st.expander("🔧 자세히 보기 (검색어 · 뉴스 원문)"):
        st.markdown(f"**추출된 검색어:** `{result['query']}`")
        st.text(result["evidence"])


# ============================================================
# 학습 포인트 (원본 CLI 버전의 정리를 화면 하단에 그대로 보존)
# ============================================================
st.divider()
with st.expander("📝 이 실습에서 배운 것"):
    st.markdown(
        """
        1. **에이전트 vs 워크플로우**: 에이전트는 LLM이 도구를 스스로 고르고,
           워크플로우는 우리가 흐름(검색→요약→판정)을 미리 고정한다.
           흐름이 정해져 있으면 워크플로우가 더 예측 가능하고 안정적이다.
        2. **외부 API 활용**: 네이버 뉴스를 노드 안에서 호출해 LLM의 '기억'이 아닌
           '최신 뉴스'를 근거로 삼는다(RAG의 기본 아이디어).
        3. **상태 누적**: `claim → query → evidence → summary → verdict` 순으로
           상태의 빈 칸이 채워지며 정보가 풍부해진다.
        4. **투명성**: '요약을 근거로 판정'하는 구조라 사람이 판단 근거를 함께 확인할 수 있다.
        """
    )