yuvrajyadav commited on
Commit
50e4c4e
·
verified ·
1 Parent(s): 2bbb5b7

Upload 8 files

Browse files
Files changed (8) hide show
  1. .env.template +7 -0
  2. README.md +85 -0
  3. app.py +96 -0
  4. models.py +47 -0
  5. requirements.txt +7 -0
  6. search.py +60 -0
  7. utils.py +35 -0
  8. 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}