| import os |
| import sys |
| import json |
| import streamlit as st |
|
|
| |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
|
|
| |
| st.set_page_config( |
| page_title="Privacy Policy Auditor", |
| page_icon="π", |
| layout="wide", |
| ) |
|
|
| |
| |
| if os.environ.get("SPACE_ID") and not os.environ.get("HF_TOKEN"): |
| st.error( |
| "β **HF_TOKEN is not configured.**\n\n" |
| "Go to **Space Settings β Secrets** and add `HF_TOKEN` with your " |
| "HuggingFace access token. The app cannot start without it." |
| ) |
| st.stop() |
|
|
|
|
| |
|
|
| def _resolve_vector_store_path() -> str: |
| candidates = [ |
| os.path.join( |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))), |
| "data", "vector_store", |
| ), |
| "/app/data/vector_store", |
| os.path.join(os.getcwd(), "data", "vector_store"), |
| ] |
| for path in candidates: |
| if os.path.exists(path): |
| return path |
| return candidates[-1] |
|
|
|
|
| |
|
|
| @st.cache_resource(show_spinner=False) |
| def _load_pipeline(): |
| from embeddings.embedding_manager import get_embedding_model |
| from embeddings.vector_store import load_vector_store |
| from agents.planner import PlannerAgent |
| from agents.auditor import AuditorAgent |
|
|
| vector_store_dir = _resolve_vector_store_path() |
| embeddings = get_embedding_model() |
| vectorstore = load_vector_store(embeddings, vector_store_dir) |
| planner = PlannerAgent(vectorstore) |
| auditor = AuditorAgent() |
| return planner, auditor, vectorstore |
|
|
|
|
| def get_pipeline(): |
| try: |
| return _load_pipeline(), None |
| except Exception as e: |
| return None, str(e) |
|
|
|
|
| |
|
|
| @st.cache_data(show_spinner=False) |
| def _get_corpus_sites(_vectorstore) -> list[str]: |
| """Return sorted list of unique URLs from the vector store metadata.""" |
| try: |
| all_meta = _vectorstore.get()["metadatas"] |
| sites = sorted({ |
| m.get("url", "").strip() |
| for m in all_meta |
| if m.get("url", "").strip() not in ("", "unknown") |
| }) |
| return sites |
| except Exception: |
| return [] |
|
|
|
|
| |
| |
| |
|
|
| _RESTRICTED_INTENT_PATTERNS = [ |
| |
| r"\bwrite\s+(a\s+)?(python|javascript|js|sql|bash|script|code|program|function|class)\b", |
| r"\b(generate|create|give me)\s+(a\s+)?(script|code|scraper|crawler|bot|program)\b", |
| r"\bscrape\b", r"\bcrawler?\b", r"\bbeautifulsoup\b", r"\brequests\.get\b", |
| r"\bimport\s+(requests|bs4|scrapy|selenium|playwright)\b", |
| |
| |
| |
| r"\bignore\s+(?:\w+\s+){0,3}instructions?\b", |
| r"\byou\s+are\s+now\s+a\b", |
| r"\byou\s+are\s+no\s+longer\b", |
| r"\bact\s+as\s+(a\s+)?(different|new|another)\b", |
| r"\bforget\s+(everything|all|your)\b", |
| r"\bpretend\s+(?:you\s+are|to\s+be)\b", |
| r"\bstop\s+being\s+a\b", |
| r"\bdan\s+mode\b", r"\bjailbreak\b", |
| |
| r"\bsend\s+(an?\s+)?email\b", r"\bpost\s+(to|on)\s+(twitter|reddit|slack)\b", |
| ] |
|
|
| |
| _PRIVACY_KEYWORDS = [ |
| "privacy", "policy", "data", "collect", "share", "sell", "retain", "deletion", |
| "tracking", "cookie", "consent", "opt", "third party", "personal information", |
| "security", "encrypt", "gdpr", "ccpa", "do not track", "advertising", |
| ] |
|
|
| import re as _re |
|
|
| _HARDCODED_REFUSAL = ( |
| "π« **I am a legal compliance assistant and can only answer questions about privacy policies.**\n\n" |
| "This tool does **not**:\n" |
| "- Write code, scripts, or web scrapers\n" |
| "- Execute instructions unrelated to privacy policy analysis\n" |
| "- Respond to persona-override or prompt injection attempts\n\n" |
| "Please ask a question about a specific website's privacy policy." |
| ) |
|
|
|
|
| def _check_restricted_intent(question: str) -> tuple[bool, bool]: |
| """ |
| Returns (has_restricted, has_valid_privacy) tuple. |
| - has_restricted: True if question contains a restricted intent pattern |
| - has_valid_privacy: True if question also contains a valid privacy question |
| This enables Q9 multi-intent split: answer the valid part, refuse only the restricted part. |
| """ |
| q = question.lower() |
| has_restricted = any(_re.search(p, q) for p in _RESTRICTED_INTENT_PATTERNS) |
| has_valid_privacy = any(kw in q for kw in _PRIVACY_KEYWORDS) |
| return has_restricted, has_valid_privacy |
|
|
|
|
| def _strip_restricted_part(question: str) -> str: |
| """ |
| Q9 fix: for multi-intent queries, extract just the privacy-policy question |
| by splitting on common conjunctions and keeping only the valid sentence(s). |
| """ |
| |
| import re |
| parts = re.split( |
| r"\.\s+(?:also|additionally|then|finally|next|after that)\b" |
| r"|\band\s+(?:also\s+)?(?:write|create|generate|give me|scrape|make)\b" |
| r"|\.\s+(?:write|create|generate|give me|scrape|make)\b", |
| question, |
| flags=re.IGNORECASE, |
| ) |
| |
| valid_parts = [p.strip() for p in parts if any(kw in p.lower() for kw in _PRIVACY_KEYWORDS)] |
| return " ".join(valid_parts) if valid_parts else question |
|
|
|
|
| |
|
|
| def _show_corpus_warnings(plan: dict) -> None: |
| if plan.get("corpus_miss"): |
| site = plan.get("corpus_miss_site", "that website") |
| st.warning( |
| f"β οΈ **'{site}' is not in the OPP-115 corpus.**\n\n" |
| f"The dataset covers 115 specific websites collected in 2015β2016. " |
| f"The answer below is drawn from semantically similar policies and may " |
| f"**not** reflect **{site}**'s actual privacy policy.", |
| ) |
|
|
| compare_misses = plan.get("compare_misses", []) |
| if compare_misses: |
| missing_str = ", ".join(f"**{s}**" for s in compare_misses) |
| st.warning( |
| f"β οΈ The following site(s) are **not in the OPP-115 corpus**: {missing_str}.\n\n" |
| f"The comparison below only covers the site(s) that were found.", |
| ) |
|
|
| if plan.get("year_filter"): |
| st.info(f"βΉοΈ Results pre-filtered to policies collected in **{plan['year_filter']}**.") |
|
|
| if plan.get("quote_query") and plan.get("section_filter"): |
| st.info(f"βΉοΈ Section-targeted retrieval applied: `{plan['section_filter']}`") |
|
|
|
|
| |
|
|
| _OPP115_INFO = """ |
| The **OPP-115 Corpus** contains privacy policies from **115 real websites**, |
| collected in **2015β2016** and fully annotated by legal experts across 8 categories: |
| |
| | Category | What it covers | |
| |---|---| |
| | First Party Collection/Use | What data the site collects and how it uses it | |
| | Third Party Sharing | Data shared with or collected by third parties | |
| | User Choice/Control | Opt-out, consent, and preference options | |
| | User Access, Edit & Deletion | Rights to access, modify, or delete data | |
| | Data Retention | How long data is stored | |
| | Data Security | How data is protected | |
| | Policy Change | How users are notified of changes | |
| | Do Not Track | Response to browser DNT signals | |
| |
| β οΈ **Scope note:** Policies reflect 2015β2016 language. Sites not in the list below |
| cannot be accurately answered β the Auditor will warn you if a site is missing. |
| """ |
|
|
| |
| _OPP115_SITES = sorted([ |
| "aol.com", "apple.com", "att.com", "cbsnews.com", "chase.com", |
| "cnet.com", "comcast.com", "craigslist.org", "ebay.com", "espn.com", |
| "facebook.com", "foxnews.com", "go.com", "huffingtonpost.com", "imdb.com", |
| "instagram.com", "linkedin.com", "live.com", "mapquest.com", "match.com", |
| "mediafire.com", "microsoft.com", "mlb.com", "msn.com", "myspace.com", |
| "nba.com", "netflix.com", "nfl.com", "nytimes.com", "paypal.com", |
| "pinterest.com", "reddit.com", "salesforce.com", "scribd.com", "shutterstock.com", |
| "snapchat.com", "spotify.com", "target.com", "theatlantic.com", "ticketmaster.com", |
| "time.com", "tmz.com", "tripadvisor.com", "tumblr.com", "twitter.com", |
| "usnews.com", "verizon.com", "vevo.com", "vimeo.com", "vine.co", |
| "washingtonpost.com", "weather.com", "webmd.com", "whitepages.com", "wikia.com", |
| "wikipedia.org", "wordpress.com", "yahoo.com", "yelp.com", "youtube.com", |
| "accuweather.com", "bankofamerica.com", "bbc.com", "bestbuy.com", "bing.com", |
| "blogger.com", "booking.com", "businessinsider.com", "buzzfeed.com", "capitalone.com", |
| "cars.com", "citibank.com", "classmates.com", "cnn.com", "coldwellbanker.com", |
| "costco.com", "creditkarma.com", "dailymotion.com", "dealnews.com", "deviantart.com", |
| "dictionary.com", "digg.com", "directv.com", "discovery.com", "dropbox.com", |
| "drugstore.com", "ehow.com", "etsy.com", "expedia.com", "fanfiction.net", |
| "fandango.com", "flickr.com", "foodnetwork.com", "foxsports.com", "gamefaqs.com", |
| "gamespot.com", "genius.com", "gofundme.com", "goodreads.com", "groupon.com", |
| "homedepot.com", "hotels.com", "houzz.com", "hulu.com", "icloud.com", |
| "iheartradio.com", "investopedia.com", "irs.gov", "kmart.com", "kohls.com", |
| "last.fm", "livestrong.com", "lowes.com", "macys.com", "mayoclinic.org", |
| "merriam-webster.com", "metacritic.com", "mlslistings.com", "monster.com", "msnbc.com", |
| "nhl.com", "npr.org", "opentable.com", "pandora.com", "pbs.org", |
| "photobucket.com", "pricerunner.com", "quora.com", "realtor.com", "reference.com", |
| "sci-news.com", "theweek.com", "vikings.com", "walgreens.com", "wellsfargo.com", |
| ]) |
|
|
|
|
| |
|
|
| with st.sidebar: |
| st.title("π Privacy Auditor") |
| st.markdown("**Agentic RAG** over 115 real website privacy policies.") |
| st.divider() |
|
|
| |
| _hf_available = bool(os.environ.get("HF_TOKEN")) |
| _local_mode = st.toggle( |
| "π₯οΈ Use Local Ollama", |
| value=not _hf_available, |
| help=( |
| "When ON: uses your local Ollama server (free, private, no quota).\n" |
| "When OFF: uses HuggingFace Inference API (requires HF_TOKEN).\n\n" |
| "Switch ON if you see quota or rate-limit errors." |
| ), |
| key="local_mode_toggle", |
| ) |
| if _local_mode: |
| os.environ["LOCAL_MODE"] = "1" |
| st.caption("π’ Local Ollama active β no API quota used.") |
| else: |
| os.environ.pop("LOCAL_MODE", None) |
| if _hf_available: |
| st.caption("βοΈ HuggingFace Inference API active.") |
| else: |
| st.caption("β οΈ No HF_TOKEN found β local Ollama will be used.") |
| st.divider() |
|
|
| st.markdown("### How it works") |
| st.markdown( |
| "1. **Planner** classifies your query\n" |
| "2. **Retriever** fetches relevant segments\n" |
| "3. **LLM** generates a grounded answer\n" |
| "4. **Auditor** validates for hallucinations" |
| ) |
| st.divider() |
|
|
| st.markdown("### Query Types") |
| st.markdown( |
| "- π’ **SIMPLE** β broad question across all policies\n" |
| "- π΅ **FILTERED** β mention a specific site\n" |
| "- π£ **COMPARE** β compare two named sites\n" |
| "- π‘ **AMBIGUOUS** β vague pronoun (will ask to clarify)" |
| ) |
| st.divider() |
|
|
| st.markdown("### Try these") |
| samples = [ |
| "What types of personal data do websites collect?", |
| "What does nytimes.com say about third-party data sharing?", |
| "How does washingtonpost.com handle cookies?", |
| "Compare how nytimes.com and theatlantic.com treat user data.", |
| "Which policies mention data retention periods?", |
| "Do they sell my data?", |
| ] |
| for sample in samples: |
| if st.button(sample, use_container_width=True, key=sample): |
| st.session_state["question_input"] = sample |
|
|
| st.divider() |
|
|
| |
| with st.expander("π Dataset Scope & Categories"): |
| st.markdown(_OPP115_INFO) |
|
|
| |
| with st.expander("π’ Companies in Corpus (115 sites)"): |
| st.caption( |
| "These are the 115 websites whose 2015β2016 privacy policies are in this dataset. " |
| "Questions about any other site will return a corpus-miss warning." |
| ) |
| |
| cols = st.columns(3) |
| for i, site in enumerate(_OPP115_SITES): |
| cols[i % 3].markdown(f"β’ {site}") |
|
|
| |
| with st.expander("π Browse & Pre-fill a Site"): |
| if "corpus_sites" in st.session_state: |
| sites = st.session_state["corpus_sites"] |
| if sites: |
| st.caption(f"{len(sites)} sites detected in vector store") |
| selected = st.selectbox( |
| "Pick a site to pre-fill question:", |
| ["β select β"] + sites, |
| key="site_browser", |
| ) |
| if selected and selected != "β select β": |
| if st.button("Use this site", key="use_site"): |
| st.session_state["question_input"] = ( |
| f"What does {selected} say about data collection?" |
| ) |
| else: |
| st.caption("Run a query first to load the site list.") |
| else: |
| st.caption("Run a query first to load the site list.") |
|
|
| st.divider() |
| st.markdown( |
| "Built by [Rohith Sundar](https://www.linkedin.com/in/rohithsundarj/) Β· " |
| "Dataset: [OPP-115 Corpus](https://usableprivacy.org/data)" |
| ) |
|
|
|
|
| |
|
|
| st.title("π Agentic Privacy & Compliance Auditor") |
| st.caption( |
| "Ask questions about 115 real website privacy policies β " |
| "answers are validated for hallucinations." |
| ) |
|
|
| question = st.text_input( |
| label="Your question", |
| placeholder="e.g. What does nytimes.com say about sharing data with third parties?", |
| key="question_input", |
| ) |
|
|
| analyze_clicked = st.button("π Analyze", type="primary") |
|
|
| |
|
|
| if analyze_clicked and not question.strip(): |
| st.warning("Please enter a question before clicking Analyze.") |
|
|
| elif analyze_clicked and question.strip(): |
|
|
| |
| has_restricted, has_valid_privacy = _check_restricted_intent(question) |
| if has_restricted: |
| if has_valid_privacy: |
| |
| st.warning( |
| "β οΈ **Part of your request is outside scope.**\n\n" |
| "I am a legal compliance assistant and **cannot** write code or scripts. " |
| "I will answer the privacy policy question only and ignore the rest." |
| ) |
| question = _strip_restricted_part(question) |
| if not question.strip(): |
| st.error(_HARDCODED_REFUSAL) |
| st.stop() |
| else: |
| |
| st.error(_HARDCODED_REFUSAL) |
| st.stop() |
|
|
| |
| with st.spinner("βοΈ Step 1/4 β Loading AI pipeline..."): |
| pipeline, load_error = get_pipeline() |
|
|
| if load_error: |
| st.error(f"β Failed to load pipeline: {load_error}") |
| st.markdown( |
| "**Possible causes:**\n" |
| "- Vector store not found β run `python src/ingestion/ingest.py` first\n" |
| "- Ollama not running β start with `ollama serve`\n" |
| f"\n**Vector store path checked:** `{_resolve_vector_store_path()}`" |
| ) |
| st.stop() |
|
|
| planner, auditor, vectorstore = pipeline |
|
|
| |
| if "corpus_sites" not in st.session_state: |
| st.session_state["corpus_sites"] = _get_corpus_sites(vectorstore) |
|
|
| |
| with st.spinner("π§ Step 2/4 β Planner routing and retrieving segments..."): |
| try: |
| result = planner.retrieve(question) |
| |
| if not isinstance(result, tuple) or len(result) != 2: |
| st.error(f"β Planner returned unexpected result type: {type(result)}") |
| st.stop() |
| docs, plan = result |
| except Exception as e: |
| st.error(f"β Planner failed: {e}") |
| st.stop() |
|
|
| query_type = plan.get("type", "SIMPLE") |
| badge = {"SIMPLE": "π’", "FILTERED": "π΅", "COMPARE": "π£", "AMBIGUOUS": "π‘"}.get(query_type, "βͺ") |
| st.markdown(f"**Query type:** {badge} `{query_type}` β {plan.get('reasoning', '')}") |
|
|
| |
| if plan.get("off_topic"): |
| st.error( |
| "π **Off-topic query detected.**\n\n" |
| "I'm a Privacy Policy Auditor. I can only answer questions about privacy policies.\n\n" |
| "Try asking something like:\n" |
| "- *What data does nytimes.com collect?*\n" |
| "- *Does google.com share data with third parties?*" |
| ) |
| st.stop() |
|
|
| |
| if plan.get("ambiguous"): |
| st.warning( |
| "π‘ **Please clarify your question.**\n\n" |
| "Your question uses a vague reference (e.g. 'they', 'it', 'the company') " |
| "without naming a specific website.\n\n" |
| "Try rephrasing with a site name, for example:\n" |
| "- *What does **nytimes.com** say about data collection?*\n" |
| "- *Does **washingtonpost.com** sell user data?*\n\n" |
| "You can browse the 115 available sites in the sidebar under **Browse Available Sites**." |
| ) |
| st.stop() |
|
|
| _show_corpus_warnings(plan) |
|
|
| |
| if query_type == "COMPARE": |
| all_sites = [sq.get("filters", {}).get("url", "") for sq in plan.get("sub_queries", [])] |
| all_sites = [s for s in all_sites if s] |
| misses = plan.get("compare_misses", []) |
| if all_sites and set(misses) >= set(all_sites): |
| st.error( |
| "β None of the requested sites are in the OPP-115 corpus. " |
| "No comparison can be generated." |
| ) |
| st.stop() |
|
|
| if not docs: |
| st.warning( |
| "No relevant policy segments found. " |
| "Try rephrasing or mentioning a specific website." |
| ) |
| st.stop() |
|
|
| |
| with st.spinner("π§ Step 3/4 β LLM generating grounded answer..."): |
| try: |
| from retrieval.generation import generate_answer |
| answer = generate_answer(question, docs) |
| except Exception as e: |
| st.error(f"β Generation failed: {e}") |
| st.stop() |
|
|
| |
| with st.spinner("β
Step 4/4 β Auditor validating faithfulness..."): |
| try: |
| from retrieval.generation import generate_answer |
| final_answer, audit = auditor.audit_and_regenerate( |
| question=question, |
| answer=answer, |
| docs=docs, |
| generate_fn=generate_answer, |
| ) |
| except Exception as e: |
| st.warning(f"β Auditor error: {e}. Showing unvalidated answer.") |
| final_answer = answer |
| audit = { |
| "faithfulness_score": 0.0, |
| "verdict": "UNKNOWN", |
| "unsupported_claims": [], |
| "reasoning": "Audit could not be completed.", |
| } |
|
|
| |
| if "history" not in st.session_state: |
| st.session_state["history"] = [] |
| st.session_state["history"].append({ |
| "question": question, |
| "answer": final_answer, |
| "score": audit.get("faithfulness_score"), |
| "verdict": audit.get("verdict", "UNKNOWN"), |
| "query_type": query_type, |
| "docs": docs, |
| "audit": audit, |
| }) |
|
|
| |
| st.divider() |
| st.subheader("π Answer") |
| |
| st.markdown(final_answer, unsafe_allow_html=False) |
|
|
| |
| score = audit.get("faithfulness_score") |
| verdict = audit.get("verdict", "PASS") |
| unsupported = audit.get("unsupported_claims", []) |
|
|
| col1, col2 = st.columns([1, 3]) |
| with col1: |
| if verdict == "PASS": |
| st.success("β
Audit: PASS") |
| elif verdict == "WARN": |
| st.warning("β οΈ Audit: WARN") |
| elif verdict == "FAIL": |
| st.error("β Audit: FAIL") |
| elif verdict == "UNVERIFIED": |
| st.warning("β οΈ Audit: UNVERIFIED") |
| else: |
| st.warning("β Audit: UNKNOWN") |
| if score is not None: |
| st.metric("Faithfulness Score", f"{score:.2f} / 1.00") |
| else: |
| st.caption("Score: N/A (auditor unavailable)") |
| with col2: |
| if verdict == "UNVERIFIED": |
| reason = audit.get("reasoning", "") |
| |
| display_reason = reason if reason.startswith("β οΈ") else "Auditor could not run β answer shown unvalidated." |
| st.warning(f"**{display_reason}**\n\nThe answer above was generated but could not be verified for faithfulness. Treat it with caution.") |
| elif verdict == "WARN": |
| st.markdown( |
| "**β οΈ Warning:** Some claims could not be fully verified against " |
| "the source documents. Use this answer with caution and consult " |
| "the source segments below." |
| ) |
| reasoning = audit.get("reasoning", "") |
| if reasoning: |
| st.markdown(f"**Auditor reasoning:** {reasoning}") |
| if unsupported: |
| st.markdown("**Unsupported claims flagged:**") |
| for claim in unsupported: |
| st.markdown(f"- β {claim}") |
|
|
| |
| export_data = { |
| "question": question, |
| "query_type": query_type, |
| "answer": final_answer, |
| "audit": audit, |
| "sources": [ |
| { |
| "policy_id": d.metadata.get("policy_id"), |
| "url": d.metadata.get("url"), |
| "section": d.metadata.get("section"), |
| "date": d.metadata.get("collection_date"), |
| "text": d.page_content, |
| } |
| for d in docs |
| ], |
| } |
| st.download_button( |
| label="π₯ Export Report (JSON)", |
| data=json.dumps(export_data, indent=2, ensure_ascii=False), |
| file_name="privacy_audit_report.json", |
| mime="application/json", |
| ) |
|
|
| |
| st.divider() |
| st.subheader(f"π Source Documents ({len(docs)} retrieved)") |
|
|
| for i, doc in enumerate(docs, 1): |
| m = doc.metadata |
| with st.expander( |
| f"[{i}] {m.get('policy_id', 'unknown')} β {m.get('url', '')}", |
| expanded=(i == 1), |
| ): |
| c1, c2, c3, c4 = st.columns(4) |
| c1.markdown(f"**Policy ID:** `{m.get('policy_id', 'N/A')}`") |
| c2.markdown(f"**Collection Date:** `{m.get('collection_date', 'N/A')}`") |
| c3.markdown(f"**Section:** `{m.get('section', 'N/A')}`") |
| c4.markdown(f"**Annotations:** `{m.get('annotation_count', '0')}`") |
|
|
| st.markdown("**Segment text:**") |
| preview = doc.page_content[:1500] |
| full = doc.page_content |
| st.markdown( |
| "<div style='" |
| "background:#f8f9fa;" |
| "padding:12px;" |
| "border-radius:6px;" |
| "font-size:0.9em;" |
| "border-left:3px solid #dee2e6;" |
| "color:#212529" |
| f"'>{preview}{'β¦' if len(full) > 1500 else ''}</div>", |
| unsafe_allow_html=True, |
| ) |
| |
| if len(full) > 1500: |
| with st.expander("Show full segment"): |
| st.text(full) |
|
|
|
|
| |
|
|
| if st.session_state.get("history"): |
| st.divider() |
| st.subheader("π Query History") |
| history = st.session_state["history"] |
| st.caption(f"{len(history)} queries this session") |
|
|
| for idx, entry in enumerate(reversed(history), 1): |
| v = entry["verdict"] |
| v_icon = {"PASS": "β
", "WARN": "β οΈ", "FAIL": "β", "UNVERIFIED": "β οΈ"}.get(v, "βͺ") |
| score_str = f"{entry['score']:.2f}" if entry.get("score") is not None else "N/A" |
| q_icon = {"SIMPLE": "π’", "FILTERED": "π΅", "COMPARE": "π£", "AMBIGUOUS": "π‘"}.get( |
| entry.get("query_type", ""), "βͺ" |
| ) |
| with st.expander( |
| f"{idx}. {q_icon} {entry['question'][:80]}{'β¦' if len(entry['question']) > 80 else ''} " |
| f"β {v_icon} {v} ({score_str})" |
| ): |
| st.markdown(f"**Q:** {entry['question']}") |
| st.markdown(f"**A:** {entry['answer']}") |
| st.caption(f"Faithfulness: {score_str} | Verdict: {v} | Type: {entry.get('query_type', 'β')}") |