File size: 11,173 Bytes
f5a4962 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | """LLM-powered analysis: question decomposition, per-paper coverage, gaps, summaries.
All LLM calls go through Groq. Abstracts are untrusted text, so every quote the
model returns as evidence is checked against the abstract before we trust it.
"""
import json
import os
import re
from concurrent.futures import ThreadPoolExecutor
from groq import Groq
from retriever import count_works
# Qwen kept the question's core subject as an aspect and gave stable output
# across runs; gpt-oss-120b sometimes dropped it. Override with GROQ_MODEL.
GROQ_MODEL = os.environ.get("GROQ_MODEL", "qwen/qwen3.8-27b")
# gpt-oss is a reasoning model: its hidden reasoning tokens count toward
# max_tokens, so if selected we keep reasoning short and add headroom.
REASONING_HEADROOM = 1000
MAX_ABSTRACT_CHARS = 2500 # keeps prompts inside Groq free-tier token limits
PAPERS_PER_CALL = 5 # coverage is checked in batches of this many papers
STATUS_POINTS = {"covered": 1.0, "partial": 0.5, "missing": 0.0}
class LLMError(RuntimeError):
"""Raised when the LLM is unavailable or returns something unusable."""
_client: Groq | None = None
def _get_client() -> Groq:
global _client
if _client is None:
api_key = os.environ.get("GROQ_API_KEY")
if not api_key:
raise LLMError("GROQ_API_KEY is not configured.")
_client = Groq(api_key=api_key, max_retries=2, timeout=60)
return _client
def _chat(prompt: str, max_tokens: int, as_json: bool = False) -> str:
kwargs = {"response_format": {"type": "json_object"}} if as_json else {}
if "gpt-oss" in GROQ_MODEL:
kwargs["reasoning_effort"] = "low"
max_tokens += REASONING_HEADROOM
try:
resp = _get_client().chat.completions.create(
model=GROQ_MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0,
**kwargs,
)
except LLMError:
raise
except Exception as e:
raise LLMError(f"LLM request failed: {e}") from e
return resp.choices[0].message.content or ""
def chat_json(prompt: str, max_tokens: int) -> dict:
raw = _chat(prompt, max_tokens, as_json=True)
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise LLMError("LLM returned invalid JSON.") from e
def _normalize(text: str) -> str:
# Lowercase, unify hyphen variants, collapse whitespace β so a quote still
# matches when the model changes "β" to "-" or reflows spaces.
text = re.sub(r"[β-ββ]", "-", text.lower())
return re.sub(r"\s+", " ", text).strip()
# ββ 1. Decomposition ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def decompose(question: str) -> dict:
"""Split a research question into aspects and search sub-queries.
Returns {"aspects": [{"name", "keywords"}, ...], "sub_queries": [str, ...]}.
If the LLM fails, falls back to a single sub-query (the question itself)
and no aspects, so search still works without coverage analysis.
"""
prompt = f"""You help researchers search the academic literature.
Research question: {question}
1. Break the question into 2-5 distinct ASPECTS a relevant paper should address
(e.g. the core subject, the method, the target/organism, the condition,
the setting). Put the question's main topic first, named after the topic itself.
Alternatives joined by "or" form ONE aspect. Skip generic words that no
paper could fail to cover ("effects", "methods", "recent", "papers").
Stay at the question's own level of generality: if it says "delivery
methods" the aspect is delivery methods, not one specific method; if it
says "liver diseases", not one specific disease.
For each, give a short plain-English name saying what it is (e.g. "Target organ",
"Gene transfer"; never a generic label like "Core subject") and ONE
search KEYWORD: 1-3 normal words, taken from the question's wording where
possible (e.g. "delivery", "liver disease", "in vivo"). Never a list,
no commas, no boolean operators.
2. Write 3-4 SUB-QUERIES for semantic search. Each combines several aspects
in different ways or uses different terminology, so together they reach
different parts of the literature. Never search a single broad aspect alone.
Respond with JSON only:
{{"aspects": [{{"name": "...", "keyword": "..."}}],
"sub_queries": ["...", "..."]}}"""
try:
data = chat_json(prompt, max_tokens=500)
aspects = [
# Keep only the first term if the model still returns a list: counts
# AND all keywords together, so a list would match almost nothing.
{"name": str(a["name"]).strip(),
"keywords": re.split(r"[,;/]| or ", str(a.get("keyword") or a["name"]))[0].strip()}
for a in data.get("aspects", [])
if a.get("name")
][:5]
sub_queries = [str(q).strip() for q in data.get("sub_queries", []) if str(q).strip()][:4]
except (LLMError, KeyError, TypeError, AttributeError):
return {"aspects": [], "sub_queries": [question]}
return {"aspects": aspects, "sub_queries": sub_queries or [question]}
# ββ 2. Coverage βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _assess_batch(question: str, aspect_names: list[str], papers: list[dict]) -> list[dict]:
papers_text = "\n\n".join(
f"[{i}] Title: {p['title']}\nAbstract: {p['abstract'][:MAX_ABSTRACT_CHARS]}"
for i, p in enumerate(papers)
)
prompt = f"""Research question: {question}
Aspects: {json.dumps(aspect_names)}
For each paper below, judge each aspect using ONLY its title and abstract:
- "covered": the paper clearly addresses it
- "partial": touched on, indirectly or only in part
- "missing": not addressed
For covered/partial, "evidence" must be a short EXACT quote copied from the
abstract (max 25 words). For missing, evidence is "".
Also add "note": one short sentence on what the paper does NOT address
relative to the question ("" if nothing important is missing).
{papers_text}
Respond with JSON only:
{{"papers": [{{"index": 0, "aspects": [{{"name": "...", "status": "...", "evidence": "..."}}], "note": "..."}}]}}"""
data = chat_json(prompt, max_tokens=350 * len(papers))
by_index = {r.get("index"): r for r in data.get("papers", []) if isinstance(r, dict)}
results = []
for i, paper in enumerate(papers):
row = by_index.get(i, {})
given = {a.get("name"): a for a in row.get("aspects", []) if isinstance(a, dict)}
abstract_norm = _normalize(paper["title"] + " " + paper["abstract"])
aspects = []
for name in aspect_names:
a = given.get(name, {})
status = a.get("status") if a.get("status") in STATUS_POINTS else "missing"
evidence = str(a.get("evidence") or "").strip().strip('"')
# Trust a "covered" verdict only if its quote really is in the abstract.
if evidence and _normalize(evidence) not in abstract_norm:
evidence = ""
if status == "covered":
status = "partial"
aspects.append({"name": name, "status": status, "evidence": evidence})
score = sum(STATUS_POINTS[a["status"]] for a in aspects) / len(aspects)
results.append({
"aspects": aspects,
"coverage": round(score * 100),
"note": str(row.get("note") or "").strip(),
})
return results
def assess_coverage(question: str, aspects: list[dict], papers: list[dict]) -> list[dict]:
"""Check which aspects each paper covers.
Returns one dict per paper, in the same order:
{"aspects": [{"name", "status", "evidence"}], "coverage": 0-100, "note": str}.
Batches run in parallel; raises LLMError if any batch fails.
"""
if not aspects or not papers:
return [{"aspects": [], "coverage": None, "note": ""} for _ in papers]
names = [a["name"] for a in aspects]
batches = [papers[i:i + PAPERS_PER_CALL] for i in range(0, len(papers), PAPERS_PER_CALL)]
with ThreadPoolExecutor(max_workers=3) as pool:
batch_results = list(pool.map(lambda b: _assess_batch(question, names, b), batches))
return [r for batch in batch_results for r in batch]
# ββ 3. Gaps βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def find_gaps(aspects: list[dict], assessments: list[dict]) -> dict:
"""Find aspects the literature seems thin on.
Two signals:
- `uncovered`: aspects that no assessed paper fully covers.
- `bottlenecks`: leave-one-out keyword counts. If all aspects together match
7 papers but dropping aspect X gives 640, X is what makes the combination rare.
These are leads to verify, not proof (wording differences, abstracts only).
"""
uncovered = [
a["name"] for i, a in enumerate(aspects)
if not any(r["aspects"] and r["aspects"][i]["status"] == "covered" for r in assessments)
]
result = {"uncovered": uncovered, "full_count": None, "full_query": "", "bottlenecks": []}
if len(aspects) < 2:
return result
full_query = " ".join(a["keywords"] for a in aspects)
leave_one_out = [
" ".join(b["keywords"] for b in aspects if b is not a) for a in aspects
]
try:
with ThreadPoolExecutor(max_workers=4) as pool:
counts = list(pool.map(count_works, [full_query, *leave_one_out]))
except Exception:
return result # counts are a bonus; coverage-based gaps still stand
full_count, rest = counts[0], counts[1:]
result["full_count"] = full_count
result["full_query"] = full_query
result["bottlenecks"] = sorted(
(
{"aspect": a["name"], "query_without": q, "count_without": c}
for a, q, c in zip(aspects, leave_one_out, rest)
# Only interesting if dropping this aspect opens up much more literature.
if c >= max(3 * full_count, full_count + 20)
),
key=lambda b: -b["count_without"],
)
return result
# ββ 4. Summaries ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def summarize_paper(question: str, paper: dict) -> str:
"""2-3 sentence summary of the paper's contribution, focused on the question."""
prompt = f"""A researcher is asking: {question}
Summarize this paper in 2-3 sentences: its key contribution and findings,
and what it tells the researcher about their question. Use only the abstract.
Title: {paper['title']}
Abstract: {paper['abstract'][:MAX_ABSTRACT_CHARS]}"""
return _chat(prompt, max_tokens=200).strip()
|