Spaces:
Configuration error
Configuration error
Upload 8 files
Browse files- .env.template +7 -0
- README.md +85 -0
- app.py +96 -0
- models.py +47 -0
- requirements.txt +7 -0
- search.py +60 -0
- utils.py +35 -0
- verifier.py +99 -0
.env.template
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fill for local development only. On HF Spaces, use Secrets instead.
|
| 2 |
+
OPENAI_API_KEY=
|
| 3 |
+
DEEPSEEK_API_KEY=
|
| 4 |
+
GOOGLE_API_KEY=
|
| 5 |
+
GOOGLE_CSE_ID=
|
| 6 |
+
OPENAI_MODEL=gpt-4o-mini
|
| 7 |
+
DEEPSEEK_MODEL=deepseek-chat
|
README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🔎 HF Space — Automated Requirement Citation Checker
|
| 2 |
+
|
| 3 |
+
This Space ingests a CSV with columns: **Prompt, Rubric, ID, Requirements, Important Evaluation Rules** and
|
| 4 |
+
verifies each atomic claim in the **Requirements** column against the public web.
|
| 5 |
+
|
| 6 |
+
It searches via **Google Custom Search (CSE)**, fetches top pages, and asks **both OpenAI** and **DeepSeek**
|
| 7 |
+
LLMs to decide if each claim is **SUPPORTED**, **CONTRADICTED**, or **UNVERIFIABLE**, returning citations and a concise reason.
|
| 8 |
+
A per-row conclusion is computed from all claims.
|
| 9 |
+
|
| 10 |
+
## Quick Start (Hugging Face Spaces)
|
| 11 |
+
|
| 12 |
+
1. Create a new Space → SDK: **Gradio** → Visibility: your choice.
|
| 13 |
+
2. Upload these files to the Space root:
|
| 14 |
+
- `app.py`
|
| 15 |
+
- `verifier.py`
|
| 16 |
+
- `search.py`
|
| 17 |
+
- `models.py`
|
| 18 |
+
- `utils.py`
|
| 19 |
+
- `requirements.txt`
|
| 20 |
+
- `.env.template` (optional for local dev)
|
| 21 |
+
3. In the Space page → **Settings** → **Secrets**, add:
|
| 22 |
+
- `OPENAI_API_KEY` — your OpenAI key (you mentioned `OAPI1`).
|
| 23 |
+
- `DEEPSEEK_API_KEY` — your DeepSeek key (you mentioned `DAPI`).
|
| 24 |
+
- `GOOGLE_API_KEY` — Google API key (you mentioned `API1`).
|
| 25 |
+
- `GOOGLE_CSE_ID` — your CSE ID (you mentioned `CX1`).
|
| 26 |
+
- (Optional) `OPENAI_MODEL` (default `gpt-4o-mini`)
|
| 27 |
+
- (Optional) `DEEPSEEK_MODEL` (default `deepseek-chat`)
|
| 28 |
+
4. Deploy. Open the Space, upload your CSV, and click **Run Verification**.
|
| 29 |
+
|
| 30 |
+
## Output
|
| 31 |
+
|
| 32 |
+
- **Table view** summarizing Supported/Contradicted/Unverifiable counts and overall conclusion per row.
|
| 33 |
+
- **JSON** with full detail (per-claim statuses, reasons, citations, and both models' raw decisions).
|
| 34 |
+
- **Markdown report** with human-readable results and links.
|
| 35 |
+
|
| 36 |
+
## CSV Format
|
| 37 |
+
|
| 38 |
+
Columns required (case-sensitive):
|
| 39 |
+
- `Prompt`
|
| 40 |
+
- `Rubric`
|
| 41 |
+
- `ID`
|
| 42 |
+
- `Requirements`
|
| 43 |
+
- `Important Evaluation Rules`
|
| 44 |
+
|
| 45 |
+
Only **Requirements** is fact-checked; other columns are passed through for context if you want to extend prompts later.
|
| 46 |
+
|
| 47 |
+
## How it works
|
| 48 |
+
|
| 49 |
+
- `utils.split_atomic_claims` heuristically splits Requirements text into atomic claims (lists + sentences).
|
| 50 |
+
- `search.google_cse` queries Google CSE (multiple queries per claim), collects unique result links.
|
| 51 |
+
- `search.fetch_page` downloads pages and extracts plain text + titles for LLM review.
|
| 52 |
+
- `verifier.verify_claim` builds a single packed prompt with all sources and asks **both** OpenAI & DeepSeek to return structured JSON:
|
| 53 |
+
```json
|
| 54 |
+
{
|
| 55 |
+
"status": "SUPPORTED|CONTRADICTED|UNVERIFIABLE",
|
| 56 |
+
"reason": "brief rationale",
|
| 57 |
+
"citations": [{"url": "...", "title": "...", "quote": "short"}]
|
| 58 |
+
}
|
| 59 |
+
```
|
| 60 |
+
- An ensemble chooses the final status; the other model’s output is preserved for transparency.
|
| 61 |
+
- `verifier.verify_requirement` aggregates claim-level results and computes a row-level conclusion:
|
| 62 |
+
- Any CONTRADICTED → **CONTRADICTED**
|
| 63 |
+
- Else if all supported → **SUPPORTED**
|
| 64 |
+
- Else if mixed → **PARTIALLY SUPPORTED**
|
| 65 |
+
- Else → **UNVERIFIABLE**
|
| 66 |
+
|
| 67 |
+
## Local Development
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
python -m venv .venv && source .venv/bin/activate
|
| 71 |
+
pip install -r requirements.txt
|
| 72 |
+
cp .env.template .env # fill keys
|
| 73 |
+
python app.py
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## Notes & Tips
|
| 77 |
+
|
| 78 |
+
- Be conservative: the models are instructed to mark **UNVERIFIABLE** when evidence is insufficient.
|
| 79 |
+
- You can tune the number of search results and page length limits in `search.py`.
|
| 80 |
+
- For highly technical domains, consider whitelisting official domains in your CSE for higher precision.
|
| 81 |
+
- If you hit rate limits, Tenacity will retry with exponential backoff.
|
| 82 |
+
|
| 83 |
+
## License
|
| 84 |
+
|
| 85 |
+
MIT
|
app.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, io, json, asyncio, pandas as pd, gradio as gr
|
| 2 |
+
from verifier import verify_requirement
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
INTRO_MD = """
|
| 8 |
+
# 🔎 Automatic Citation & Requirement Checker
|
| 9 |
+
|
| 10 |
+
Upload a CSV with columns: **Prompt, Rubric, ID, Requirements, Important Evaluation Rules**.
|
| 11 |
+
This Space will extract atomic claims from each **Requirements** cell, search the web (Google CSE), fetch pages, and ask **both OpenAI and DeepSeek** models to verify each claim with citations.
|
| 12 |
+
At the end of each row, you'll get a summary and conclusion.
|
| 13 |
+
|
| 14 |
+
**Secrets**: Configure in the Space settings (Repository → Settings → Secrets):
|
| 15 |
+
- `OPENAI_API_KEY` (your OpenAI key)
|
| 16 |
+
- `DEEPSEEK_API_KEY` (your DeepSeek key)
|
| 17 |
+
- `GOOGLE_API_KEY` (your Google API key)
|
| 18 |
+
- `GOOGLE_CSE_ID` (your Custom Search Engine ID)
|
| 19 |
+
(Optional) `OPENAI_MODEL`, `DEEPSEEK_MODEL`
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
async def process_csv(file_obj) -> tuple:
|
| 23 |
+
df = pd.read_csv(file_obj)
|
| 24 |
+
required_cols = ["Prompt","Rubric","ID","Requirements","Important Evaluation Rules"]
|
| 25 |
+
missing = [c for c in required_cols if c not in df.columns]
|
| 26 |
+
if missing:
|
| 27 |
+
raise gr.Error(f"Missing required columns: {missing}")
|
| 28 |
+
rows = []
|
| 29 |
+
for idx, row in df.iterrows():
|
| 30 |
+
req_text = str(row["Requirements"] or "").strip()
|
| 31 |
+
rid = row.get("ID", idx)
|
| 32 |
+
if not req_text:
|
| 33 |
+
rows.append({"ID": rid, "status":"SKIPPED (empty Requirements)", "result": {}})
|
| 34 |
+
continue
|
| 35 |
+
result = await verify_requirement(req_text)
|
| 36 |
+
rows.append({"ID": rid, "status": result["conclusion"], "result": result})
|
| 37 |
+
|
| 38 |
+
# Build outputs
|
| 39 |
+
# 1) table view
|
| 40 |
+
out_rows = []
|
| 41 |
+
for r in rows:
|
| 42 |
+
rid = r["ID"]
|
| 43 |
+
status = r["status"]
|
| 44 |
+
if not r["result"]:
|
| 45 |
+
out_rows.append({"ID": rid, "Conclusion": status, "Supported":0, "Contradicted":0, "Unverifiable":0, "Claims":0})
|
| 46 |
+
continue
|
| 47 |
+
counts = r["result"]["summary"]
|
| 48 |
+
out_rows.append({
|
| 49 |
+
"ID": rid,
|
| 50 |
+
"Conclusion": status,
|
| 51 |
+
"Supported": counts.get("SUPPORTED",0),
|
| 52 |
+
"Contradicted": counts.get("CONTRADICTED",0),
|
| 53 |
+
"Unverifiable": counts.get("UNVERIFIABLE",0),
|
| 54 |
+
"Claims": sum(counts.values())
|
| 55 |
+
})
|
| 56 |
+
table = pd.DataFrame(out_rows)
|
| 57 |
+
|
| 58 |
+
# 2) detailed JSON
|
| 59 |
+
detailed = {"rows": rows}
|
| 60 |
+
json_bytes = io.BytesIO(json.dumps(detailed, indent=2, ensure_ascii=False).encode("utf-8"))
|
| 61 |
+
json_bytes.name = "verification_results.json"
|
| 62 |
+
|
| 63 |
+
# 3) pretty markdown report
|
| 64 |
+
md_lines = ["# Results\n"]
|
| 65 |
+
for r in rows:
|
| 66 |
+
rid = r["ID"]
|
| 67 |
+
md_lines.append(f"## ID: {rid}\n**Conclusion**: {r['status']}\n")
|
| 68 |
+
if not r["result"]:
|
| 69 |
+
continue
|
| 70 |
+
for claimres in r["result"]["claims"]:
|
| 71 |
+
md_lines.append(f"- **Claim**: {claimres['claim']}\n - Status: {claimres['status']}\n - Reason: {claimres.get('reason','')}\n")
|
| 72 |
+
cites = claimres.get("citations", [])[:4]
|
| 73 |
+
if cites:
|
| 74 |
+
for c in cites:
|
| 75 |
+
md_lines.append(f" - [{c.get('title','source')}]({c.get('url','')}) — “{c.get('quote','')}”")
|
| 76 |
+
md_lines.append("")
|
| 77 |
+
report_md = "\n".join(md_lines)
|
| 78 |
+
report_bytes = io.BytesIO(report_md.encode("utf-8"))
|
| 79 |
+
report_bytes.name = "report.md"
|
| 80 |
+
return table, json_bytes, report_bytes
|
| 81 |
+
|
| 82 |
+
with gr.Blocks(fill_height=True) as demo:
|
| 83 |
+
gr.Markdown(INTRO_MD)
|
| 84 |
+
with gr.Row():
|
| 85 |
+
file = gr.File(label="Upload CSV", file_types=[".csv"], type="binary")
|
| 86 |
+
run = gr.Button("Run Verification", variant="primary")
|
| 87 |
+
with gr.Row():
|
| 88 |
+
table = gr.Dataframe(headers=["ID","Conclusion","Supported","Contradicted","Unverifiable","Claims"], interactive=False)
|
| 89 |
+
with gr.Row():
|
| 90 |
+
json_out = gr.File(label="Download JSON")
|
| 91 |
+
md_out = gr.File(label="Download Markdown Report")
|
| 92 |
+
|
| 93 |
+
run.click(fn=process_csv, inputs=[file], outputs=[table, json_out, md_out])
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
demo.launch()
|
models.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import Dict, Any, Optional, List
|
| 5 |
+
import httpx
|
| 6 |
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 7 |
+
|
| 8 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 9 |
+
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
|
| 10 |
+
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
|
| 11 |
+
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 12 |
+
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 13 |
+
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
|
| 14 |
+
|
| 15 |
+
HEADERS_OAI = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"}
|
| 16 |
+
HEADERS_DSK = {"Authorization": f"Bearer {DEEPSEEK_API_KEY}", "Content-Type": "application/json"}
|
| 17 |
+
|
| 18 |
+
class TransientError(Exception):
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
@retry(stop=stop_after_attempt(4), wait=wait_exponential(multiplier=1, min=1, max=20),
|
| 22 |
+
retry=retry_if_exception_type(TransientError))
|
| 23 |
+
async def call_openai(messages: List[Dict[str, str]], temperature: float = 0.0, max_tokens: int = 1200) -> str:
|
| 24 |
+
if not OPENAI_API_KEY:
|
| 25 |
+
raise RuntimeError("OPENAI_API_KEY not set")
|
| 26 |
+
payload = {"model": OPENAI_MODEL, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
|
| 27 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 28 |
+
r = await client.post(f"{OPENAI_BASE_URL}/chat/completions", headers=HEADERS_OAI, json=payload)
|
| 29 |
+
if r.status_code >= 500:
|
| 30 |
+
raise TransientError(f"OpenAI transient {r.status_code}")
|
| 31 |
+
r.raise_for_status()
|
| 32 |
+
data = r.json()
|
| 33 |
+
return data["choices"][0]["message"]["content"]
|
| 34 |
+
|
| 35 |
+
@retry(stop=stop_after_attempt(4), wait=wait_exponential(multiplier=1, min=1, max=20),
|
| 36 |
+
retry=retry_if_exception_type(TransientError))
|
| 37 |
+
async def call_deepseek(messages: List[Dict[str, str]], temperature: float = 0.0, max_tokens: int = 1200) -> str:
|
| 38 |
+
if not DEEPSEEK_API_KEY:
|
| 39 |
+
raise RuntimeError("DEEPSEEK_API_KEY not set")
|
| 40 |
+
payload = {"model": DEEPSEEK_MODEL, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
|
| 41 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 42 |
+
r = await client.post(f"{DEEPSEEK_BASE_URL}/chat/completions", headers=HEADERS_DSK, json=payload)
|
| 43 |
+
if r.status_code >= 500:
|
| 44 |
+
raise TransientError(f"DeepSeek transient {r.status_code}")
|
| 45 |
+
r.raise_for_status()
|
| 46 |
+
data = r.json()
|
| 47 |
+
return data["choices"][0]["message"]["content"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44.0
|
| 2 |
+
pandas>=2.2.0
|
| 3 |
+
python-dotenv>=1.0.1
|
| 4 |
+
httpx>=0.27.0
|
| 5 |
+
tenacity>=8.3.0
|
| 6 |
+
beautifulsoup4>=4.12.3
|
| 7 |
+
html5lib>=1.1
|
search.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, re, asyncio, html
|
| 2 |
+
from typing import List, Dict, Any, Tuple
|
| 3 |
+
import httpx
|
| 4 |
+
from bs4 import BeautifulSoup
|
| 5 |
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 6 |
+
|
| 7 |
+
GOOGLE_CSE_ID = os.getenv("GOOGLE_CSE_ID")
|
| 8 |
+
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
|
| 9 |
+
|
| 10 |
+
UA = "Mozilla/5.0 (X11; Linux x86_64) CitationChecker/1.0 (+https://hf.space)"
|
| 11 |
+
HEADERS = {"User-Agent": UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
|
| 12 |
+
|
| 13 |
+
class TransientError(Exception):
|
| 14 |
+
pass
|
| 15 |
+
|
| 16 |
+
def clean_text(t: str) -> str:
|
| 17 |
+
t = re.sub(r"\s+", " ", t or "").strip()
|
| 18 |
+
return t
|
| 19 |
+
|
| 20 |
+
@retry(stop=stop_after_attempt(4), wait=wait_exponential(min=1, max=20), retry=retry_if_exception_type(TransientError))
|
| 21 |
+
async def google_cse(query: str, num: int = 6) -> List[Dict[str, Any]]:
|
| 22 |
+
if not GOOGLE_CSE_ID or not GOOGLE_API_KEY:
|
| 23 |
+
raise RuntimeError("GOOGLE_CSE_ID/GOOGLE_API_KEY not set")
|
| 24 |
+
url = "https://www.googleapis.com/customsearch/v1"
|
| 25 |
+
params = {"q": query, "cx": GOOGLE_CSE_ID, "key": GOOGLE_API_KEY, "num": min(num, 10)}
|
| 26 |
+
async with httpx.AsyncClient(timeout=60.0, headers=HEADERS) as client:
|
| 27 |
+
r = await client.get(url, params=params)
|
| 28 |
+
if r.status_code >= 500:
|
| 29 |
+
raise TransientError(f"Google CSE transient {r.status_code}")
|
| 30 |
+
r.raise_for_status()
|
| 31 |
+
data = r.json()
|
| 32 |
+
items = data.get("items", [])
|
| 33 |
+
results = []
|
| 34 |
+
for it in items:
|
| 35 |
+
results.append({
|
| 36 |
+
"title": it.get("title"),
|
| 37 |
+
"link": it.get("link"),
|
| 38 |
+
"snippet": it.get("snippet"),
|
| 39 |
+
"displayLink": it.get("displayLink"),
|
| 40 |
+
})
|
| 41 |
+
return results
|
| 42 |
+
|
| 43 |
+
@retry(stop=stop_after_attempt(4), wait=wait_exponential(min=1, max=20), retry=retry_if_exception_type(TransientError))
|
| 44 |
+
async def fetch_page(url: str) -> Dict[str, str]:
|
| 45 |
+
async with httpx.AsyncClient(timeout=60.0, headers=HEADERS) as client:
|
| 46 |
+
r = await client.get(url, follow_redirects=True)
|
| 47 |
+
if r.status_code >= 500:
|
| 48 |
+
raise TransientError(f"Fetch transient {r.status_code}")
|
| 49 |
+
r.raise_for_status()
|
| 50 |
+
html_text = r.text
|
| 51 |
+
soup = BeautifulSoup(html_text, "html.parser")
|
| 52 |
+
title = soup.title.get_text(strip=True) if soup.title else ""
|
| 53 |
+
# Extract text content (limit size to avoid token bloat)
|
| 54 |
+
for s in soup(["script", "style", "noscript"]):
|
| 55 |
+
s.extract()
|
| 56 |
+
text = soup.get_text(separator=" ")
|
| 57 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 58 |
+
if len(text) > 25000:
|
| 59 |
+
text = text[:25000]
|
| 60 |
+
return {"url": url, "title": title, "text": text}
|
utils.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re, json
|
| 2 |
+
from typing import List, Dict
|
| 3 |
+
|
| 4 |
+
def split_atomic_claims(requirement_text: str) -> List[str]:
|
| 5 |
+
"""
|
| 6 |
+
Heuristics to split requirement text into atomic factual claims.
|
| 7 |
+
Falls back to sentence splitting and numbered/bulleted lists.
|
| 8 |
+
"""
|
| 9 |
+
text = requirement_text.strip()
|
| 10 |
+
# Split on list numbers/bullets
|
| 11 |
+
parts = re.split(r"(?:^\s*[-*]\s+|^\s*\d+\.\s+|\n\s*[-*]\s+|\n\s*\d+\.\s+)", text, flags=re.M)
|
| 12 |
+
# Fallback to sentences if needed
|
| 13 |
+
claims = []
|
| 14 |
+
for p in parts:
|
| 15 |
+
p = p.strip(" •;-–—")
|
| 16 |
+
if not p:
|
| 17 |
+
continue
|
| 18 |
+
# Split by sentence enders if the chunk is long
|
| 19 |
+
if len(p) > 220:
|
| 20 |
+
subs = re.split(r"(?<=[.!?])\s+", p)
|
| 21 |
+
for s in subs:
|
| 22 |
+
s = s.strip()
|
| 23 |
+
if 3 < len(s) < 240:
|
| 24 |
+
claims.append(s)
|
| 25 |
+
else:
|
| 26 |
+
claims.append(p)
|
| 27 |
+
# De-duplicate
|
| 28 |
+
seen = set()
|
| 29 |
+
uniq = []
|
| 30 |
+
for c in claims:
|
| 31 |
+
c2 = re.sub(r"\s+", " ", c).strip().rstrip(".")
|
| 32 |
+
if c2 and c2.lower() not in seen:
|
| 33 |
+
seen.add(c2.lower())
|
| 34 |
+
uniq.append(c2)
|
| 35 |
+
return uniq[:20] # cap to keep work bounded
|
verifier.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio, json, re
|
| 2 |
+
from typing import List, Dict, Any, Tuple
|
| 3 |
+
from search import google_cse, fetch_page, clean_text
|
| 4 |
+
from models import call_openai, call_deepseek
|
| 5 |
+
from utils import split_atomic_claims
|
| 6 |
+
|
| 7 |
+
VERIFY_SYSTEM = """You are a meticulous fact-checker. Given a CLAIM and a list of web SOURCES (title, url, excerpt),
|
| 8 |
+
decide if the claim is SUPPORTED, CONTRADICTED, or UNVERIFIABLE. Cite the strongest 1–4 sources that support or contradict.
|
| 9 |
+
If sources conflict, prefer primary/official sources and more recent data. Return strict JSON with keys:
|
| 10 |
+
{{
|
| 11 |
+
"status": "SUPPORTED|CONTRADICTED|UNVERIFIABLE",
|
| 12 |
+
"reason": "brief rationale",
|
| 13 |
+
"citations": [{{"url": "...", "title": "...", "quote": "verbatim short quote"}}]
|
| 14 |
+
}}
|
| 15 |
+
Keep quotes under 280 characters. Be conservative: if uncertain, UNVERIFIABLE.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def build_messages(claim: str, sources: List[Dict[str, str]]) -> List[Dict[str, str]]:
|
| 19 |
+
packed = []
|
| 20 |
+
for s in sources[:8]:
|
| 21 |
+
excerpt = s.get("text", "")[:1400]
|
| 22 |
+
packed.append(f"- {s.get('title','')} | {s.get('url','')}\n{excerpt}")
|
| 23 |
+
user = f"CLAIM: {claim}\n\nSOURCES:\n" + "\n\n".join(packed)
|
| 24 |
+
return [
|
| 25 |
+
{"role": "system", "content": VERIFY_SYSTEM},
|
| 26 |
+
{"role": "user", "content": user}
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
async def verify_claim(claim: str) -> Dict[str, Any]:
|
| 30 |
+
# 1) craft queries
|
| 31 |
+
queries = [claim, f"verify: {claim}", f"site:.gov OR site:.edu {claim[:80]}"]
|
| 32 |
+
# 2) search and fetch
|
| 33 |
+
search_tasks = [google_cse(q, num=6) for q in queries]
|
| 34 |
+
search_groups = await asyncio.gather(*search_tasks, return_exceptions=True)
|
| 35 |
+
candidates = []
|
| 36 |
+
seen = set()
|
| 37 |
+
for group in search_groups:
|
| 38 |
+
if isinstance(group, Exception):
|
| 39 |
+
continue
|
| 40 |
+
for item in group:
|
| 41 |
+
u = item["link"]
|
| 42 |
+
if u not in seen and not any(b in u for b in ["webcache.googleusercontent.com", "translate.google"]):
|
| 43 |
+
seen.add(u)
|
| 44 |
+
candidates.append({"title": item.get("title",""), "url": u, "snippet": item.get("snippet","")})
|
| 45 |
+
fetch_tasks = [fetch_page(c["url"]) for c in candidates[:10]]
|
| 46 |
+
pages = await asyncio.gather(*fetch_tasks, return_exceptions=True)
|
| 47 |
+
sources = []
|
| 48 |
+
for i, p in enumerate(pages):
|
| 49 |
+
if isinstance(p, Exception):
|
| 50 |
+
continue
|
| 51 |
+
# attach snippet for flavor
|
| 52 |
+
p["snippet"] = candidates[i].get("snippet","")
|
| 53 |
+
sources.append(p)
|
| 54 |
+
|
| 55 |
+
# 3) LLM verification with both vendors in parallel
|
| 56 |
+
messages = build_messages(claim, sources)
|
| 57 |
+
oai_task = call_openai(messages)
|
| 58 |
+
dsk_task = call_deepseek(messages)
|
| 59 |
+
oai_resp, dsk_resp = await asyncio.gather(oai_task, dsk_task)
|
| 60 |
+
|
| 61 |
+
def parse_json(s: str) -> Dict[str, Any]:
|
| 62 |
+
try:
|
| 63 |
+
# try to find the first JSON block
|
| 64 |
+
m = re.search(r"\{.*\}", s, re.S)
|
| 65 |
+
if m:
|
| 66 |
+
return json.loads(m.group(0))
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
return {"status":"UNVERIFIABLE","reason":"Could not parse model output","citations":[]}
|
| 70 |
+
|
| 71 |
+
o = parse_json(oai_resp or "")
|
| 72 |
+
d = parse_json(dsk_resp or "")
|
| 73 |
+
|
| 74 |
+
# Simple ensemble: prefer SUPPORTED/CONTRADICTED if both agree; else pick non-UNVERIFIABLE; else UNVERIFIABLE.
|
| 75 |
+
def score(st):
|
| 76 |
+
return {"SUPPORTED":2,"CONTRADICTED":2,"UNVERIFIABLE":1}.get(st.upper(),1)
|
| 77 |
+
pick = o if (o.get("status")==d.get("status") and score(o.get("status",""))>=2) else (o if score(o.get("status",""))>score(d.get("status","")) else d)
|
| 78 |
+
pick["models"] = {"openai": o, "deepseek": d}
|
| 79 |
+
return pick
|
| 80 |
+
|
| 81 |
+
async def verify_requirement(requirement_text: str) -> Dict[str, Any]:
|
| 82 |
+
claims = split_atomic_claims(requirement_text)
|
| 83 |
+
results = []
|
| 84 |
+
for c in claims:
|
| 85 |
+
res = await verify_claim(c)
|
| 86 |
+
results.append({"claim": c, **res})
|
| 87 |
+
# roll-up conclusion
|
| 88 |
+
counts = {"SUPPORTED":0, "CONTRADICTED":0, "UNVERIFIABLE":0}
|
| 89 |
+
for r in results:
|
| 90 |
+
counts[r.get("status","UNVERIFIABLE").upper()] = counts.get(r.get("status","UNVERIFIABLE").upper(),0)+1
|
| 91 |
+
if counts["CONTRADICTED"]>0:
|
| 92 |
+
overall = "CONTRADICTED"
|
| 93 |
+
elif counts["SUPPORTED"]>0 and counts["UNVERIFIABLE"]==0:
|
| 94 |
+
overall = "SUPPORTED"
|
| 95 |
+
elif counts["SUPPORTED"]>0:
|
| 96 |
+
overall = "PARTIALLY SUPPORTED"
|
| 97 |
+
else:
|
| 98 |
+
overall = "UNVERIFIABLE"
|
| 99 |
+
return {"claims": results, "summary": counts, "conclusion": overall}
|