claim-ai / src /retrieval.py
minoola's picture
Update src/retrieval.py
199fe8c verified
Raw
History Blame Contribute Delete
14.3 kB
"""
retrieval.py β€” v3
Semantic Scholar RAG layer for the Claim-Evidence Consistency Checker.
v3 = v1 base logic (proven correct) + query extraction from v2 (genuine improvement)
Changes from v1:
- extract_query() : extracts 5-6 keywords from claim before querying
Semantic Scholar β€” fixes zero-result failures caused
by passing full claim sentences to a keyword search API
- extract_fallback_query(): 4-term grounded fallback (subject + key scientific terms)
fires ONLY when primary query returns zero papers
- TOP_K raised from 3 to 7: fetch more, score all, return best 3
Everything else is identical to v1:
- No pre-filter (removed from v2 β€” caused irrelevant papers to rank above relevant ones)
- No label priority sort (removed from v2 β€” caused SUPPORT to rank above CONTRADICT)
- Sort purely by confidence descending β€” NLI model decides relevance
- Cache, score_papers, retrieve, FastAPI endpoint β€” all unchanged from v1
"""
import os
import re
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Dict, Any, Optional
import requests
import nltk
nltk.download("punkt", quiet=True)
nltk.download("punkt_tab", quiet=True)
from nltk.tokenize import sent_tokenize
from fastapi import HTTPException
from pydantic import BaseModel
from infer import app, predict, ID2LABEL, LABEL2ID
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════════
S2_API_KEY = os.getenv("S2_API_KEY")
S2_ENDPOINT = "https://api.semanticscholar.org/graph/v1/paper/search"
S2_FIELDS = "title,abstract,authors,year"
TOP_K_FETCH = 7 # fetch 7, score all, return best 3 by confidence
RETURN_K = 3
WINNER_THRESHOLD = 0.65
SLEEP_AUTHENTICATED = 1.10
SLEEP_UNAUTHENTICATED = 1.10
CACHE_PATH = Path("data/retrieval_cache.json")
# Stopwords for query extraction
_STOPWORDS = {
"a","an","the","is","are","was","were","be","been","being",
"have","has","had","do","does","did","will","would","could",
"should","may","might","must","shall","can","need","dare",
"of","in","on","at","to","for","with","by","from","up",
"about","into","through","during","before","after","above",
"below","between","out","off","over","under","again","further",
"then","once","and","but","or","nor","not","so","yet","both",
"either","neither","than","that","this","these","those",
"also","its","it","which","who","whom","what","where","when",
"how","all","each","every","both","few","more","most","other",
"some","such","no","only","same","as","just","because","if",
"while","although","though","since","unless","until","whether",
"significantly","associated","increased","decreased","elevated",
"reduced","shown","found","suggest","evidence","study","result",
"results","effect","effects","using","used","based","compared",
}
# ══════════════════════════════════════════════════════════════════════════════
# QUERY EXTRACTION
# ══════════════════════════════════════════════════════════════════════════════
def extract_query(claim: str) -> str:
"""Extract 5-6 meaningful keywords from a claim for Semantic Scholar search."""
tokens = re.findall(r"[a-zA-Z0-9]+", claim)
keywords = [t for t in tokens if t.lower() not in _STOPWORDS and len(t) > 2]
return " ".join(keywords[:6])
def extract_fallback_query(claim: str) -> str:
"""
4-term fallback: first 2 content tokens + top 2 longest tokens.
Keeps the fallback grounded in the specific claim subject.
Only fires when primary query returns zero papers.
"""
tokens = re.findall(r"[a-zA-Z0-9]+", claim)
keywords = [t for t in tokens if t.lower() not in _STOPWORDS and len(t) > 2]
if not keywords:
return claim[:50]
short = keywords[:2]
sorted_by_len = sorted(keywords, key=len, reverse=True)
long_tokens = [t for t in sorted_by_len[:2] if t not in short]
combined = short + long_tokens
return " ".join(combined[:4])
# ══════════════════════════════════════════════════════════════════════════════
# CACHE HELPERS (identical to v1)
# ══════════════════════════════════════════════════════════════════════════════
def load_cache() -> dict:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
if not CACHE_PATH.exists():
CACHE_PATH.write_text("{}", encoding="utf-8")
return {}
try:
return json.loads(CACHE_PATH.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
def save_cache(cache: dict) -> None:
try:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
CACHE_PATH.write_text(
json.dumps(cache, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError:
pass
# ══════════════════════════════════════════════════════════════════════════════
# SEMANTIC SCHOLAR API FETCH
# ══════════════════════════════════════════════════════════════════════════════
def _fetch_with_query(query: str, limit: int) -> List[Dict[str, Any]]:
"""Single Semantic Scholar API call."""
headers = {}
if S2_API_KEY:
headers["x-api-key"] = S2_API_KEY
params = {"query": query, "fields": S2_FIELDS, "limit": limit}
try:
response = requests.get(S2_ENDPOINT, headers=headers, params=params, timeout=15)
response.raise_for_status()
data = response.json()
except requests.RequestException as exc:
print(f"[retrieval] Semantic Scholar API error: {exc}")
return []
finally:
sleep_time = SLEEP_AUTHENTICATED if S2_API_KEY else SLEEP_UNAUTHENTICATED
time.sleep(sleep_time)
papers = data.get("data", [])
return [p for p in papers if p.get("abstract") and p["abstract"].strip()]
def fetch_papers(claim: str) -> List[Dict[str, Any]]:
"""
Fetch papers using extracted query keywords.
Fallback to 4-term query only if primary returns zero papers.
"""
primary_query = extract_query(claim)
print(f"[retrieval] Primary query: '{primary_query}'")
papers = _fetch_with_query(primary_query, TOP_K_FETCH)
if len(papers) == 0:
fallback_query = extract_fallback_query(claim)
print(f"[retrieval] Zero results β€” fallback query: '{fallback_query}'")
papers = _fetch_with_query(fallback_query, TOP_K_FETCH)
return papers
# ══════════════════════════════════════════════════════════════════════════════
# PAPER SCORING (identical to v1 β€” sort by confidence descending, no label priority)
# ══════════════════════════════════════════════════════════════════════════════
def score_papers(claim: str, papers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Score all fetched papers, select best sentence per paper, sort by confidence."""
candidates = []
for paper in papers:
abstract = paper.get("abstract", "").strip()
paper_id = paper.get("paperId", "")
title = paper.get("title", "")
authors = [a["name"] for a in paper.get("authors", []) if a.get("name")]
year = paper.get("year", None)
sentences = sent_tokenize(abstract)
sentences = [s.strip() for s in sentences if s.strip()]
if not sentences:
continue
scored = []
for sentence in sentences:
result = predict(claim, sentence)
scored.append({
"sentence": sentence,
"label": result["label"],
"confidence": result["confidence"],
})
scored.sort(key=lambda x: x["confidence"], reverse=True)
confident_non_nei = [
s for s in scored
if s["label"] != "NOT_ENOUGH_INFO"
and s["confidence"] >= WINNER_THRESHOLD
]
nei_sentences = [s for s in scored if s["label"] == "NOT_ENOUGH_INFO"]
fallback = nei_sentences[0] if nei_sentences else scored[0]
winner = confident_non_nei[0] if confident_non_nei else fallback
candidates.append({
"paper_title": title,
"paper_id": paper_id,
"sentence": winner["sentence"],
"label": winner["label"],
"confidence": winner["confidence"],
"abstract": abstract,
"authors": authors,
"year": year,
})
# Sort by confidence descending β€” NLI model decides relevance, no label priority
candidates.sort(key=lambda x: x["confidence"], reverse=True)
return candidates[:RETURN_K]
# ══════════════════════════════════════════════════════════════════════════════
# MAIN RETRIEVE FUNCTION (identical to v1)
# ══════════════════════════════════════════════════════════════════════════════
def retrieve(claim: str) -> List[Dict[str, Any]]:
cache_key = claim.lower().strip()
cache = load_cache()
if cache_key in cache:
print(f"[retrieval] Cache hit for: {cache_key[:60]}...")
return cache[cache_key]["results"]
print(f"[retrieval] Cache miss β€” querying for: {cache_key[:60]}...")
papers = fetch_papers(claim)
if not papers:
print("[retrieval] No usable papers returned.")
return []
print(f"[retrieval] Fetched {len(papers)} papers. Scoring all...")
results = score_papers(claim, papers)
print(f"[retrieval] Done. Top: {results[0]['label']} ({results[0]['confidence']:.4f})" if results else "[retrieval] No candidates.")
cache[cache_key] = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"results": results,
}
save_cache(cache)
return results
# ══════════════════════════════════════════════════════════════════════════════
# FASTAPI ENDPOINT
# ══════════════════════════════════════════════════════════════════════════════
class RetrieveRequest(BaseModel):
claim: str
class RetrieveCandidate(BaseModel):
paper_title: str
paper_id: str
sentence: str
label: str
confidence: float
abstract: str
authors: List[str]
year: Optional[int]
@app.post("/retrieve", response_model=List[RetrieveCandidate])
def api_retrieve(req: RetrieveRequest):
if not req.claim.strip():
raise HTTPException(status_code=422, detail="claim cannot be empty")
results = retrieve(req.claim)
return [RetrieveCandidate(**r) for r in results]
# ══════════════════════════════════════════════════════════════════════════════
# SMOKE TEST
# ══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
_claim = "Aspirin inhibits the production of thromboxane A2."
print("\n" + "=" * 60)
print("SMOKE TEST β€” retrieval.py v3")
print("=" * 60)
print(f" Claim : {_claim}")
print(f" TOP_K_FETCH : {TOP_K_FETCH}")
print(f" RETURN_K : {RETURN_K}")
print(f" Auth : {'yes' if S2_API_KEY else 'no'}")
print(f" Extracted query: '{extract_query(_claim)}'")
print(f" Fallback query : '{extract_fallback_query(_claim)}'")
print("\n Running retrieve()...\n")
_results = retrieve(_claim)
if not _results:
print(" No results returned.")
else:
for i, r in enumerate(_results, 1):
title_short = r["paper_title"][:50] + "..." if len(r["paper_title"]) > 50 else r["paper_title"]
print(f" {i}. {r['label']:14} {r['confidence']:.4f} {title_short}")
print(f"\n Top sentence: {_results[0]['sentence'][:100]}...")
print("\n All assertions passed." if _results else "")
print("=" * 60)