CoolFace
Apppublic

DevPatel0611/TruthLens

sourceHugging Faceupdated 6mo agoView on Hugging Face
1likes
app.py642 linesDownload Raw Back to root
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