DevPatel0611/TruthLens
1
1import os2import sys3import json4import time5import pandas as pd6import numpy as np7import streamlit as st8 9_ROOT = os.path.dirname(os.path.abspath(__file__))10if _ROOT not in sys.path:11 sys.path.insert(0, _ROOT)12 13# ── Page config ──────────────────────────────────────────────────────────────14st.set_page_config(15 page_title="TruthLens · Fake News Detector",16 page_icon="🔍",17 layout="wide",18 initial_sidebar_state="collapsed",19)20 21# ── Global CSS ───────────────────────────────────────────────────────────────22st.markdown("""23<style>24@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');25 26/* ── Reset ── */27html, body, [data-testid="stAppViewContainer"] {28 font-family: 'Inter', sans-serif;29 background: #f4f6fb;30 color: #1e293b;31}32[data-testid="stMain"] { background: #f4f6fb; }33.block-container {34 padding-top: 2.5rem !important;35 padding-bottom: 2rem !important;36 max-width: 920px;37}38 39/* ── Remove Streamlit chrome ── */40header[data-testid="stHeader"] { display: none; }41footer { display: none; }42#MainMenu { display: none; }43[data-testid="stSidebar"] { display: none; }44 45/* ── Predict button ── */46.stButton > button[kind="primary"] {47 background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%) !important;48 color: #fff !important;49 border: none !important;50 border-radius: 12px !important;51 font-weight: 700 !important;52 font-size: 1.05rem !important;53 letter-spacing: 0.02em;54 padding: 0.75rem 2rem !important;55 transition: transform 0.15s, box-shadow 0.2s;56 box-shadow: 0 4px 16px rgba(59,130,246,0.2);57}58.stButton > button[kind="primary"]:hover {59 transform: translateY(-1px);60 box-shadow: 0 6px 24px rgba(59,130,246,0.3) !important;61}62 63/* ── Tab styling ── */64[data-testid="stTabs"] button {65 color: #94a3b8 !important;66 font-size: 0.92rem !important;67 font-weight: 500 !important;68 padding: 10px 20px !important;69}70[data-testid="stTabs"] button[aria-selected="true"] {71 color: #1e293b !important;72 border-bottom: 2px solid #3b82f6 !important;73 font-weight: 600 !important;74}75 76/* ── Verdict banner ── */77.verdict-box {78 border-radius: 16px;79 padding: 32px 36px;80 margin-bottom: 28px;81 display: flex;82 align-items: center;83 gap: 24px;84 animation: fadeSlide 0.5s ease;85}86@keyframes fadeSlide {87 from { opacity: 0; transform: translateY(-16px); }88 to { opacity: 1; transform: translateY(0); }89}90.verdict-emoji { font-size: 3.5rem; line-height: 1; }91.verdict-label { font-size: 1.8rem; font-weight: 800; letter-spacing: -0.03em; }92.verdict-conf { font-size: 1rem; opacity: 0.85; margin-top: 6px; font-weight: 400; }93.verdict-explain { font-size: 0.88rem; color: #64748b; margin-top: 6px; line-height: 1.5; }94 95/* ── Info cards ── */96.info-card {97 background: #ffffff;98 border: 1px solid #e2e8f0;99 border-radius: 12px;100 padding: 20px 24px;101 margin: 12px 0;102 line-height: 1.6;103 color: #475569;104}105.info-card b { color: #1e293b; }106 107/* ── Freshness bar ── */108.fresh-track { background: #e2e8f0; border-radius: 8px; height: 12px; margin: 10px 0 6px; overflow: hidden; }109.fresh-fill { height: 100%; border-radius: 8px; transition: width 0.8s ease; }110 111/* ── Source card ── */112.source-card {113 background: #ffffff;114 border: 1px solid #e2e8f0;115 border-radius: 12px;116 padding: 18px 22px;117 margin: 10px 0;118 display: flex;119 justify-content: space-between;120 align-items: flex-start;121 gap: 16px;122}123.source-text { flex: 1; font-size: 0.88rem; line-height: 1.5; color: #475569; }124.source-score { text-align: center; min-width: 60px; }125.source-score-val { font-size: 1.4rem; font-weight: 700; font-family: 'Inter', sans-serif; }126.source-score-tag { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; margin-top: 4px; }127 128/* ── Hero ── */129.hero-wrap { text-align: center; padding: 60px 20px 40px; }130.hero-icon { font-size: 4rem; margin-bottom: 16px; }131.hero-title { font-size: 2.4rem; font-weight: 800; letter-spacing: -0.04em; color: #0f172a; }132.hero-sub { font-size: 1.05rem; color: #64748b; margin-top: 12px; line-height: 1.6; max-width: 520px; margin-left: auto; margin-right: auto; }133 134/* ── How-it-works ── */135.how-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 36px 0; }136.how-card {137 background: #ffffff;138 border: 1px solid #e2e8f0;139 border-radius: 12px;140 padding: 24px;141 text-align: center;142 box-shadow: 0 1px 3px rgba(0,0,0,0.04);143}144.how-num { font-size: 2rem; margin-bottom: 8px; }145.how-title { font-size: 0.95rem; font-weight: 600; margin-bottom: 6px; color: #0f172a; }146.how-desc { font-size: 0.82rem; color: #64748b; line-height: 1.5; }147 148/* ── Verdict legend ── */149.legend-row {150 display: flex;151 gap: 24px;152 justify-content: center;153 flex-wrap: wrap;154 margin: 20px 0;155}156.legend-item { font-size: 0.85rem; color: #64748b; }157 158/* ── Metric overrides ── */159[data-testid="stMetric"] {160 background: #ffffff;161 border: 1px solid #e2e8f0;162 border-radius: 10px;163 padding: 14px 18px !important;164 box-shadow: 0 1px 3px rgba(0,0,0,0.04);165}166[data-testid="stMetricLabel"] { color: #64748b !important; font-size: 0.78rem !important; }167[data-testid="stMetricValue"] { color: #0f172a !important; font-size: 1.3rem !important; }168 169/* ── Expander ── */170[data-testid="stExpander"] {171 background: #ffffff !important;172 border: 1px solid #e2e8f0 !important;173 border-radius: 10px !important;174}175 176/* ── Text inputs ── */177[data-testid="stTextInput"] input, [data-testid="stTextArea"] textarea {178 background: #ffffff !important;179 border: 1px solid #cbd5e1 !important;180 border-radius: 8px !important;181 color: #1e293b !important;182}183[data-testid="stTextInput"] input:focus, [data-testid="stTextArea"] textarea:focus {184 border-color: #3b82f6 !important;185 box-shadow: 0 0 0 2px rgba(59,130,246,0.15) !important;186}187 188/* ── Select slider / radio ── */189[data-testid="stSlider"] label, .stRadio label { color: #475569 !important; }190 191/* ── Progress bar ── */192[data-testid="stProgress"] > div > div > div > div { background: linear-gradient(90deg, #3b82f6, #6366f1) !important; }193</style>194""", unsafe_allow_html=True)195 196 197# ── Cached inference loader ──────────────────────────────────────────────────198@st.cache_resource(show_spinner=False)199def load_pipeline():200 from src.stage4_inference import predict_article, ModelNotTrainedError201 return predict_article, ModelNotTrainedError202 203 204# ── Session state ────────────────────────────────────────────────────────────205for k, v in [("analyzed", False), ("last_result", None), ("last_input", "")]:206 if k not in st.session_state:207 st.session_state[k] = v208 209 210# =============================================================================211# LANDING PAGE (shown before any analysis)212# =============================================================================213if not st.session_state["analyzed"]:214 215 # ── Hero section ──216 st.markdown("""217 <div class="hero-wrap">218 <div class="hero-icon">🔍</div>219 <div class="hero-title">TruthLens</div>220 <div class="hero-sub">221 Paste any news article or drop a URL — our AI will tell you222 if it's real, fake, or outdated in seconds.223 </div>224 </div>225 """, unsafe_allow_html=True)226 227 # ── How it works ──228 st.markdown("""229 <div class="how-grid">230 <div class="how-card">231 <div class="how-num">📋</div>232 <div class="how-title">Paste or Link</div>233 <div class="how-desc">Drop in the article text or a URL. We'll extract everything automatically.</div>234 </div>235 <div class="how-card">236 <div class="how-num">⚡</div>237 <div class="how-title">Instant Analysis</div>238 <div class="how-desc">Our AI analyzes language patterns, checks freshness, and searches live sources.</div>239 </div>240 <div class="how-card">241 <div class="how-num">✅</div>242 <div class="how-title">Get Your Verdict</div>243 <div class="how-desc">See a clear REAL / FAKE / OUTDATED verdict with a confidence score and explanation.</div>244 </div>245 </div>246 """, unsafe_allow_html=True)247 248 # ── Input area ──249 input_tab = st.radio("How would you like to provide the article?",250 ["✍️ Write or paste text", "🔗 Paste a URL"],251 horizontal=True, label_visibility="visible")252 253 input_text, input_title, input_url, input_date, input_domain = "", "", "", "", ""254 255 if input_tab == "✍️ Write or paste text":256 input_title = st.text_input("Headline (optional)",257 placeholder="e.g. Breaking: Scientists discover high-speed interstellar travel")258 input_text = st.text_area("Article content",259 height=180,260 placeholder="Paste the full article body here…")261 # ── Auto-extract title from pasted text if headline field is empty ──262 if not input_title.strip() and input_text.strip():263 if input_text.lower().startswith("title:"):264 lines = input_text.split("\n", 1)265 input_title = lines[0].replace("Title:", "").replace("title:", "").strip()266 input_text = lines[1].replace("Body:", "").replace("body:", "").strip() if len(lines) > 1 else ""267 else:268 # Fallback: first sentence is title269 input_title = input_text.split(".")[0].strip()270 271 else:272 input_url = st.text_input("Article URL",273 placeholder="https://www.example.com/news/breaking-story")274 st.caption("We'll automatically extract the title, body, and publish date.")275 276 # ── Analysis mode (kept minimal — user doesn't need to understand internals)277 speed = st.select_slider("Analysis depth",278 options=["Quick", "Standard", "Deep"],279 value="Deep",280 help="Quick ≈ 2 sec · Standard ≈ 10 sec · Deep ≈ 30 sec (most accurate)")281 speed_map = {"Quick": "fast", "Standard": "balanced", "Deep": "full"}282 selected_mode = speed_map[speed]283 284 # ── Predict button ──285 predict_clicked = st.button("🔍 Check this article", use_container_width=True, type="primary")286 287 # ── Verdict legend ──288 st.markdown("""289 <div class="legend-row">290 <div class="legend-item">🟢 Verified True</div>291 <div class="legend-item">🔴 Likely Fake</div>292 <div class="legend-item">🟡 Outdated</div>293 <div class="legend-item">🟠 Needs Review</div>294 </div>295 """, unsafe_allow_html=True)296 297 # ── Execute prediction ──298 if predict_clicked:299 # Validate300 if input_tab == "✍️ Write or paste text":301 if not input_text or len(input_text.split()) < 10:302 st.warning("⚠️ Please paste at least a few sentences so we can analyze it properly.")303 st.stop()304 else:305 if not input_url:306 st.warning("⚠️ Please enter a URL first.")307 st.stop()308 try:309 import newspaper310 from urllib.parse import urlparse311 art = newspaper.Article(input_url)312 art.download()313 art.parse()314 input_title = art.title or ""315 input_text = art.text or ""316 input_date = art.publish_date.isoformat() if art.publish_date else ""317 input_domain = urlparse(input_url).netloc318 if len(input_text.split()) < 10:319 st.warning("⚠️ Couldn't extract enough text from that URL. Try pasting the article directly.")320 st.stop()321 except Exception:322 st.error("❌ Couldn't fetch that URL. Please check the link or paste the text directly.")323 st.stop()324 325 predict_article, ModelNotTrainedError = load_pipeline()326 327 with st.status("🔍 Analyzing article…", expanded=True) as status:328 st.write("📖 Reading article…")329 time.sleep(0.3)330 st.write("🧠 Running AI analysis…")331 try:332 result = predict_article(333 title=input_title,334 text=input_text,335 source_domain=input_domain,336 published_date=input_date,337 mode=selected_mode,338 )339 st.write("🕐 Checking article freshness…")340 st.write("🌐 Searching live sources…")341 status.update(label="✅ Done!", state="complete")342 st.session_state["last_result"] = result343 st.session_state["last_input"] = input_text344 st.session_state["analyzed"] = True345 st.rerun()346 except ModelNotTrainedError:347 status.update(label="❌ Setup required", state="error")348 st.error("The AI models haven't been trained yet.")349 st.info("Ask your administrator to run: `python run_pipeline.py --stage 1 2 3`")350 st.stop()351 except Exception as e:352 status.update(label="❌ Error", state="error")353 st.error(f"Something went wrong: {e}")354 st.stop()355 356 357 358# =============================================================================359# RESULTS PAGE (shown after analysis)360# =============================================================================361else:362 res = st.session_state["last_result"]363 verdict = res.get("verdict", "UNKNOWN")364 final_score = res.get("final_score", 0.0)365 scores = res.get("scores", {})366 confidence = res.get("confidence", "MEDIUM")367 action = res.get("recommended_action", "Flag for review")368 top_reasons = res.get("top_reasons", [])369 missing_signals = res.get("missing_signals", [])370 adv_flags = res.get("adversarial_flags", [])371 wc = res.get("word_count", 0)372 probas = res.get("base_model_probas", {})373 votes = res.get("base_model_votes", {})374 fresh_case = res.get("freshness_case", "B")375 fresh_signals = res.get("freshness_signals_found", [])376 deductions = res.get("deductions_applied", [])377 entities = res.get("entities_found", [])378 379 # ── Map verdict to display ──380 V = {381 "TRUE": {"bg":"#f0fdf4", "bdr":"#86efac", "icon":"🟢", "label":"This appears to be true", "color":"#15803d",382 "explain":"Source, claims, language, and AI models all align with credible journalism."},383 "UNCERTAIN": {"bg":"#fff7ed", "bdr":"#fdba74", "icon":"🟠", "label":"Uncertain — needs review", "color":"#c2410c",384 "explain":"Mixed signals detected. We recommend verifying the sources yourself before sharing."},385 "LIKELY FALSE": {"bg":"#fef2f2", "bdr":"#fca5a5", "icon":"🔴", "label":"Likely false", "color":"#b91c1c",386 "explain":"Multiple signals indicate this content may be fabricated or misleading."},387 "FALSE": {"bg":"#fef2f2", "bdr":"#fca5a5", "icon":"⛔", "label":"This looks fake", "color":"#991b1b",388 "explain":"Strong evidence of misinformation. Do not share without independent verification."},389 }390 vc = V.get(verdict, {"bg":"#f8fafc","bdr":"#cbd5e1","icon":"⚪","label":verdict,"color":"#475569",391 "explain":"Analysis complete."})392 393 # ── Verdict banner ──394 score_pct = final_score * 100395 st.markdown(f"""396 <div class="verdict-box" style="background:{vc['bg']}; border:1px solid {vc['bdr']};">397 <div class="verdict-emoji">{vc['icon']}</div>398 <div>399 <div class="verdict-label" style="color:{vc['color']};">{vc['label']}</div>400 <div class="verdict-conf" style="color:{vc['color']};">Score: {score_pct:.0f}% · Confidence: {confidence}</div>401 <div class="verdict-explain">{vc['explain']}</div>402 </div>403 </div>404 """, unsafe_allow_html=True)405 406 # ── Recommended action badge ──407 action_colors = {408 "Publish": ("#f0fdf4", "#15803d"),409 "Flag for review": ("#fff7ed", "#c2410c"),410 "Suppress": ("#fef2f2", "#b91c1c"),411 "Escalate": ("#fef2f2", "#991b1b"),412 }413 abg, acol = action_colors.get(action, ("#f8fafc", "#475569"))414 st.markdown(f"""415 <div style="background:{abg}; border-radius:8px; padding:10px 16px; display:inline-block; margin-bottom:24px;">416 <span style="font-weight:600; color:{acol};">Recommended: {action}</span>417 </div>418 """, unsafe_allow_html=True)419 420 # ── Tabs ──421 tab_why, tab_fresh, tab_sources, tab_details = st.tabs(422 ["🧠 Why this verdict?", "🕐 Freshness", "🌐 Live sources", "📋 Details"]423 )424 425 # ── TAB 1: Why this verdict ──────────────────────────────────────────426 with tab_why:427 428 # ── 5-Signal Score Breakdown ──429 st.markdown("#### Signal Breakdown")430 SIGNAL_INFO = [431 ("Source", "source", "Is the outlet known and accountable?"),432 ("Claims", "claim", "Are facts verifiable with named entities?"),433 ("Language", "linguistic", "Is the writing neutral and attributed?"),434 ("Freshness", "freshness", "How recent is the content?"),435 ("AI Models", "model_vote", "What do the AI models think?"),436 ]437 WEIGHTS = {"source": "30%", "claim": "30%", "linguistic": "20%", "freshness": "10%", "model_vote": "10%"}438 439 cols = st.columns(5)440 for i, (label, key, desc) in enumerate(SIGNAL_INFO):441 val = scores.get(key, 0.0)442 pct = val * 100443 if pct >= 70:444 col_hex = "#15803d"445 elif pct >= 50:446 col_hex = "#ca8a04"447 else:448 col_hex = "#b91c1c"449 with cols[i]:450 st.markdown(f"""451 <div style="text-align:center; background:#ffffff; border:1px solid #e2e8f0;452 border-radius:10px; padding:16px 8px; box-shadow:0 1px 3px rgba(0,0,0,0.04);">453 <div style="font-size:1.6rem; font-weight:800; color:{col_hex};">{pct:.0f}%</div>454 <div style="font-size:0.85rem; font-weight:600; color:#0f172a; margin-top:4px;">{label}</div>455 <div style="font-size:0.7rem; color:#94a3b8; margin-top:2px;">Weight: {WEIGHTS[key]}</div>456 </div>457 """, unsafe_allow_html=True)458 459 st.markdown("")460 461 # ── Progress bars for each signal ──462 for label, key, desc in SIGNAL_INFO:463 val = scores.get(key, 0.0)464 st.caption(f"**{label}** — {desc}")465 st.progress(min(val, 1.0))466 467 st.markdown("---")468 469 # ── Top Reasons ──470 if top_reasons:471 st.markdown("#### Key Factors")472 for r in top_reasons:473 if any(neg in r.lower() for neg in ["fake", "false", "unknown", "not", "manipulation", "adversarial", "sensationalism", "reduces", "could not", "inconsistent", "missing"]):474 st.markdown(f"🔴 {r}")475 else:476 st.markdown(f"🟢 {r}")477 478 st.markdown("---")479 480 # ── What did each AI model think? ──481 st.markdown("#### AI Model Votes")482 MODEL_NAMES = [483 ("Statistical", "logistic", "lr_proba"),484 ("Language", "lstm", "lstm_proba"),485 ("Deep A", "distilbert", "distilbert_proba"),486 ("Deep B", "roberta", "roberta_proba"),487 ]488 mcols = st.columns(len(MODEL_NAMES))489 for i, (nice_name, vote_key, pk) in enumerate(MODEL_NAMES):490 vote_val = votes.get(vote_key)491 prob_val = probas.get(pk)492 with mcols[i]:493 if vote_val is None or prob_val is None or np.isnan(prob_val):494 st.metric(nice_name, "Skipped")495 else:496 lbl = "Real" if int(vote_val) == 1 else "Fake"497 st.metric(nice_name, lbl, f"{prob_val*100:.0f}%")498 499 if res.get("short_text_warning"):500 st.warning("⚠️ Short article (under 50 words) — confidence is dampened.")501 st.caption(f"Article length: {wc} words")502 503 # ── TAB 2: Freshness ─────────────────────────────────────────────────504 with tab_fresh:505 fresh_val = scores.get("freshness", 0.5)506 bar_pct = int(fresh_val * 100)507 508 if fresh_val >= 0.70:509 fbg, flbl, fdesc = "#f0fdf4", "🟢 Fresh", "This article appears to be recent."510 fbar = "#16a34a"511 elif fresh_val >= 0.40:512 fbg, flbl, fdesc = "#fefce8", "🟡 Moderate", "Article may not be very recent."513 fbar = "#ca8a04"514 else:515 fbg, flbl, fdesc = "#fef2f2", "🔴 Outdated", "This article appears to be old."516 fbar = "#dc2626"517 518 st.markdown(f"""519 <div style="background:{fbg}; border-radius:12px; padding:20px 24px; margin-bottom:20px;">520 <div style="font-size:1.2rem; font-weight:600;">{flbl}</div>521 <div style="font-size:0.88rem; color:#64748b; margin-top:8px;">{fdesc}</div>522 <div class="fresh-track">523 <div class="fresh-fill" style="width:{bar_pct}%; background:{fbar};"></div>524 </div>525 <div style="font-size:0.8rem; color:#6b7280; margin-top:4px;">Freshness: {fresh_val:.0%}</div>526 </div>527 """, unsafe_allow_html=True)528 529 # Case indicator530 case_label = "📅 Date-based scoring" if fresh_case == "A" else "🔎 Contextual signal scanning (no date found)"531 st.markdown(f"""532 <div class="info-card">533 <b>Method:</b> {case_label}534 </div>535 """, unsafe_allow_html=True)536 537 # Signals found (Case B)538 if fresh_case == "B" and fresh_signals:539 st.markdown("**Signals detected:**")540 for sig in fresh_signals:541 st.markdown(f"✅ {sig}")542 elif fresh_case == "B":543 st.caption("No contextual freshness signals were found in the article text.")544 545 # ── TAB 3: Live sources ──────────────────────────────────────────────546 with tab_sources:547 rag_data = res.get("rag_results")548 source_list = []549 if isinstance(rag_data, dict):550 source_list = rag_data.get("data", [])551 elif isinstance(rag_data, list):552 source_list = rag_data553 554 if not source_list:555 st.markdown("""556 <div class="info-card">557 <b>Live source check was not triggered</b><br><br>558 Live source verification runs when freshness is ambiguous.559 This analysis relied on the 5-signal scoring framework instead.560 </div>561 """, unsafe_allow_html=True)562 else:563 st.caption(f"Compared against {len(source_list)} live web results.")564 for item in source_list:565 snippet = item.get("snippet", "")566 sim = item.get("similarity", 0.0)567 if sim > 0.65:568 sc_col, sc_tag = "#16a34a", "Supports"569 elif sim < 0.30:570 sc_col, sc_tag = "#dc2626", "Conflicts"571 else:572 sc_col, sc_tag = "#ca8a04", "Neutral"573 574 st.markdown(f"""575 <div class="source-card">576 <div class="source-text">{snippet}</div>577 <div class="source-score">578 <div class="source-score-val" style="color:{sc_col};">{sim:.0%}</div>579 <div class="source-score-tag" style="color:{sc_col};">{sc_tag}</div>580 </div>581 </div>582 """, unsafe_allow_html=True)583 584 # ── TAB 4: Details ───────────────────────────────────────────────────585 with tab_details:586 587 # ── Missing Signals ──588 if missing_signals:589 st.markdown("#### ⚠️ Missing Signals")590 for ms in missing_signals:591 st.markdown(f"- {ms}")592 st.markdown("")593 594 # ── Adversarial Flags ──595 if adv_flags:596 st.markdown("#### 🚩 Adversarial Flags Triggered")597 for af in adv_flags:598 st.error(f"🚩 {af}")599 st.caption("Adversarial flags cap the final score at 25% maximum.")600 st.markdown("")601 602 # ── Linguistic Deductions ──603 if deductions:604 st.markdown("#### 📝 Linguistic Deductions")605 for d in deductions:606 st.markdown(f"- {d}")607 st.markdown("")608 609 # ── Named Entities Found ──610 if entities:611 st.markdown("#### 🏷️ Entities Detected")612 st.markdown(", ".join([f"`{e}`" for e in entities]))613 q_attr = res.get("quotes_attributed", 0)614 q_total = res.get("quotes_total", 0)615 if q_total > 0:616 st.caption(f"Quotes: {q_attr}/{q_total} attributed")617 st.markdown("")618 619 # ── Summary Table ──620 st.markdown("#### Analysis Summary")621 rows = [622 ("Verdict", vc["label"]),623 ("Final Score", f"{score_pct:.1f}%"),624 ("Confidence", confidence),625 ("Action", action),626 ("Word Count", str(wc)),627 ("Freshness", f"{scores.get('freshness', 0):.0%} (Case {fresh_case})"),628 ]629 df_rep = pd.DataFrame(rows, columns=["Field", "Value"])630 st.dataframe(df_rep, use_container_width=True, hide_index=True, height=240)631 632 with st.expander("🔧 Raw JSON (for developers)"):633 st.code(json.dumps(res, indent=2, default=str), language="json")634 635 # ── Analyze another ──636 st.markdown("---")637 if st.button("← Analyze another article", use_container_width=True):638 st.session_state["analyzed"] = False639 st.session_state["last_result"] = None640 st.rerun()641 642 