| """ |
| qa.py — Evidence-grounded local RAG Q&A for Paper2Lab. |
| |
| This module returns extractive answers with evidence and source locations. |
| It does not call an LLM. Nemotron can later rewrite the answer using the same evidence. |
| |
| Design: |
| - Classify the question into a small intent taxonomy. |
| - Retrieve evidence with FAISS through indexer.py. |
| - Synthesize answers using intent-specific extractive logic. |
| - Avoid hardcoding known dataset names; discover entities from local evidence. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| from typing import Any, Dict, Iterable, List, Optional, Tuple |
|
|
| from paper2lab.rag.indexer import RagIndex, build_rag_index, search_rag_index |
|
|
|
|
| |
| |
| |
|
|
| _QUERY_INTENTS: Dict[str, List[str]] = { |
| "datasets": [ |
| "dataset", "datasets", "data", "corpus", "corpora", "benchmark", "benchmarks", |
| "source", "sources", "database", "databases", "training set", "test set", |
| "validation set", "dev set", "patients", "samples", "records", "articles", "studies", |
| ], |
| "methodology": [ |
| "method", "methods", "methodology", "procedure", "procedures", "steps", "approach", |
| "how", "trained", "training", "fine-tuned", "pretrained", "searched", "screened", |
| "selected", "included", "excluded", "implementation", "architecture", "pipeline", |
| ], |
| "evaluation": [ |
| "evaluate", "evaluated", "evaluation", "metric", "metrics", "score", "accuracy", |
| "precision", "recall", "f1", "auc", "bleu", "rouge", "perplexity", "result", |
| "results", "performance", "finding", "findings", "outcome", "outcomes", |
| ], |
| "figures": [ |
| "figure", "fig", "table", "caption", "diagram", "plot", "chart", "architecture", |
| "visual", "illustration", "show", "shows", |
| ], |
| "reproducibility": [ |
| "missing", "reproduce", "reproduction", "reproducibility", "hyperparameter", |
| "hyperparameters", "software", "code", "github", "repository", "settings", "requirements", |
| "seed", "hardware", "gpu", "implementation details", |
| ], |
| } |
|
|
| _INTENT_QUERY_EXPANSIONS: Dict[str, str] = { |
| "datasets": "dataset corpus benchmark training data test set validation data source database articles studies", |
| "methodology": "method methodology approach procedure steps training implementation experimental setup search screening inclusion exclusion", |
| "evaluation": "evaluation metrics results performance score accuracy f1 bleu rouge auc outcome analysis measured assessed", |
| "figures": "figure table caption diagram architecture plot shows illustrates", |
| "reproducibility": "reproducibility missing information hyperparameters dataset details software code hardware seed experimental settings", |
| "general": "paper evidence method results conclusion", |
| } |
|
|
|
|
| |
| |
| |
|
|
| def _clean(text: str) -> str: |
| text = text or "" |
| text = text.replace("\x00", " ").replace("\u00a0", " ") |
| text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text) |
| text = re.sub(r"\s+", " ", text) |
| return text.strip(" .;:\n\t") |
|
|
|
|
| def _is_noisy_sentence(sentence: str) -> bool: |
| s = _clean(sentence) |
| low = s.lower() |
|
|
| bad_fragments = [ |
| "corresponding author", "how to cite", "access this article online", "department of", |
| "university of", "medical sciences", "received:", "accepted:", "published:", |
| "copyright", "license", "all rights reserved", "gmail.com", "@", |
| "being accordingly", "endnote teachers", "the there", "resultsare", |
| "analysis of the resultsare", "need this systematic review", |
| ] |
| if any(x in low for x in bad_fragments): |
| return True |
| if len(re.findall(r"\[\d+", s)) >= 3: |
| return True |
| if s.count("|") >= 2 or s.count("%") >= 6: |
| return True |
| if len(s.split()) > 85: |
| return True |
| return False |
|
|
|
|
| def _split_sentences(text: str) -> List[str]: |
| text = _clean(text) |
| raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text) |
| out: List[str] = [] |
| for sent in raw: |
| sent = _clean(sent) |
| if 25 <= len(sent) <= 420 and not _is_noisy_sentence(sent): |
| out.append(sent) |
| if not out and text and not _is_noisy_sentence(text): |
| out = [text[:420]] |
| return out |
|
|
|
|
| def _query_terms(question: str) -> List[str]: |
| words = re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}", question.lower()) |
| stop = { |
| "what", "which", "where", "when", "how", "were", "was", "are", "the", "and", |
| "used", "use", "paper", "study", "does", "did", "for", "with", "from", "that", |
| "this", "these", "those", "show", "shows", "tell", "about", "explain", |
| } |
| return [w for w in words if w not in stop] |
|
|
|
|
| def _dedupe_strings(items: Iterable[str], limit: int = 10) -> List[str]: |
| seen: set[str] = set() |
| out: List[str] = [] |
| for item in items: |
| item = _clean(item) |
| if not item: |
| continue |
| key = re.sub(r"[^a-z0-9]+", " ", item.lower()).strip()[:180] |
| if key and key not in seen: |
| seen.add(key) |
| out.append(item) |
| if len(out) >= limit: |
| break |
| return out |
|
|
|
|
| def _intent(question: str) -> str: |
| low = question.lower() |
| scores: Dict[str, int] = {} |
| for intent, keys in _QUERY_INTENTS.items(): |
| score = 0 |
| for k in keys: |
| if k in low: |
| score += 2 if " " in k else 1 |
| scores[intent] = score |
| best = max(scores, key=scores.get) |
| return best if scores[best] > 0 else "general" |
|
|
|
|
| def _expanded_query(question: str, intent: str) -> str: |
| expansion = _INTENT_QUERY_EXPANSIONS.get(intent, "") |
| return _clean(f"{question} {expansion}") |
|
|
|
|
| |
| |
| |
|
|
| def _evidence_texts(hits: List[Any]) -> List[str]: |
| return [getattr(h, "text", "") for h in hits if getattr(h, "text", "")] |
|
|
|
|
| def _rank_sentences(question: str, evidence_texts: List[str], max_sentences: int = 4) -> List[str]: |
| terms = _query_terms(question) |
| candidates: List[Tuple[int, int, str]] = [] |
| for text in evidence_texts: |
| for sent in _split_sentences(text): |
| low = sent.lower() |
| lexical_score = sum(1 for t in terms if t in low) |
| length_penalty = max(0, len(sent.split()) - 45) |
| candidates.append((lexical_score, -length_penalty, sent)) |
| candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) |
|
|
| selected: List[str] = [] |
| seen: set[str] = set() |
| for score, _, sent in candidates: |
| key = re.sub(r"[^a-z0-9]+", " ", sent.lower()).strip()[:180] |
| if key in seen: |
| continue |
| if score == 0 and selected: |
| continue |
| seen.add(key) |
| selected.append(sent) |
| if len(selected) >= max_sentences: |
| break |
| return selected |
|
|
|
|
| |
| |
| |
|
|
| _DATA_CONTEXT_WORDS = [ |
| "dataset", "datasets", "corpus", "corpora", "benchmark", "benchmarks", "training data", |
| "training set", "test set", "validation set", "dev set", "data source", "databases", |
| "database", "articles", "studies", "patients", "samples", "records", "images", |
| "sentences", "tokens", "documents", "cases", "examples", "instances", |
| ] |
|
|
| _KNOWN_DATABASE_GENERIC = [ |
| "PubMed", "Scopus", "Web of Knowledge", "ERIC", "Educational Resources and Information Center", |
| "Cochrane", "IEEE Xplore", "ACM Digital Library", "Google Scholar", "MEDLINE", "Embase", |
| ] |
|
|
| _DATASET_REJECT_TERMS = [ |
| "parser", "berkeleyparser", "berkleyparser", "rnn", "lstm", "gru", |
| "transformer", "recurrent neural network", "neural network grammar", |
| "model", "architecture", "baseline", "beam size", "during inference", |
| "dropout", "optimizer", "learning rate", "attention", "encoder", "decoder", |
| ] |
|
|
| _DATASET_ALLOW_TERMS = [ |
| "dataset", "corpus", "corpora", "benchmark", "treebank", "wsj", |
| "wmt", "penn treebank", "wall street journal", "sentence pairs", |
| "sentences", "tokens", "training set", "test set", "validation set", |
| "dev set", "patients", "samples", "records", "articles", "studies", |
| ] |
|
|
| def _extract_capitalized_entities_near_data_terms(sentence: str) -> List[str]: |
| """Discover likely dataset names from context without fixed known dataset list.""" |
| s = _clean(sentence) |
| low = s.lower() |
| if not any(w in low for w in _DATA_CONTEXT_WORDS): |
| return [] |
|
|
| found: List[str] = [] |
|
|
| |
| context_patterns = [ |
| r"(?:standard|larger|public|available|benchmark|the)\s+([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,6})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)", |
| r"(?:on|using|from|with)\s+(?:the\s+)?([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,7})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)", |
| r"([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,7})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)", |
| ] |
| for pat in context_patterns: |
| for m in re.finditer(pat, s): |
| cand = _clean(m.group(1)) |
| if _valid_dataset_candidate(cand): |
| found.append(cand) |
|
|
| |
| count_patterns = [ |
| r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*(?:k|m|million|billion|thousand)?\s+(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b", |
| r"\b\d+\s+(?:articles|studies|patients|samples|records)\s+(?:were\s+)?(?:included|enrolled|selected|used)\b", |
| ] |
| for pat in count_patterns: |
| for m in re.finditer(pat, s, flags=re.IGNORECASE): |
| found.append(_clean(m.group(0))) |
|
|
| |
| for db in _KNOWN_DATABASE_GENERIC: |
| if db.lower() in low: |
| found.append(db) |
|
|
| |
| for m in re.finditer(r"([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){1,5})\s*\(([A-Z0-9-]{2,10})\)", s): |
| cand = _clean(f"{m.group(1)} ({m.group(2)})") |
| if _valid_dataset_candidate(cand): |
| found.append(cand) |
|
|
| |
| for m in re.finditer( |
| r"\b[A-Z]{2,}[A-Za-z]*[- ]?\d{2,4}(?:[- ][A-Za-z]+)*\b", |
| s, |
| ): |
| cand = _clean(m.group(0)) |
| if _valid_dataset_candidate(cand): |
| found.append(cand) |
|
|
| |
| for m in re.finditer( |
| r"\b(?:Wall Street Journal|Penn Treebank|[A-Z]{2,6})\b(?:\s*\([A-Z0-9-]{2,10}\))?", |
| s, |
| ): |
| cand = _clean(m.group(0)) |
| if _valid_dataset_candidate(cand): |
| found.append(cand) |
| return _dedupe_strings(found, limit=12) |
|
|
|
|
| def _valid_dataset_candidate(candidate: str) -> bool: |
| cand = _clean(candidate) |
| low = cand.lower() |
|
|
| if not cand or len(cand) < 3 or len(cand.split()) > 10: |
| return False |
|
|
| if any(term in low for term in _DATASET_REJECT_TERMS): |
| return False |
|
|
| bad_exact = { |
| "the", "standard", "larger", "public", "available", "training", |
| "test", "validation", "we", "our", "this", "that", "section", |
| "table", "figure", "results", "parser", |
| } |
| if low in bad_exact: |
| return False |
|
|
| if any(x in low for x in ["section describes", "the following", "in this", "of the"]): |
| return False |
|
|
| |
| if re.fullmatch(r"[A-Z0-9-]{2,12}", cand): |
| return True |
|
|
| return True |
|
|
|
|
| def _looks_like_dataset_detail(text: str) -> bool: |
| low = _clean(text).lower() |
| return bool( |
| re.search( |
| r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*" |
| r"(?:k|m|million|billion|thousand)?\s+" |
| r"(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b", |
| low, |
| ) |
| or re.search(r"\b\d+\s*(?:k|m)?\s*tokens\b", low) |
| or re.search(r"\b\d+\s*(?:k|m)?\s*training sentences\b", low) |
| ) |
|
|
|
|
| def _answer_datasets(evidence_texts: List[str]) -> str: |
| dataset_names: List[str] = [] |
| dataset_sizes: List[str] = [] |
| vocabulary_details: List[str] = [] |
| support_sentences: List[str] = [] |
|
|
| for text in evidence_texts: |
| for sent in _split_sentences(text): |
| found = _extract_capitalized_entities_near_data_terms(sent) |
|
|
| for item in found: |
| if _looks_like_dataset_detail(item): |
| dataset_sizes.append(item) |
| else: |
| dataset_names.append(item) |
|
|
| |
| for m in re.finditer( |
| r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*" |
| r"(?:k|m|million|billion|thousand)?\s+" |
| r"(?:sentence pairs|sentences|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b", |
| sent, |
| flags=re.IGNORECASE, |
| ): |
| dataset_sizes.append(_clean(m.group(0))) |
|
|
| |
| for m in re.finditer( |
| r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*" |
| r"(?:k|m|million|billion|thousand)?\s+" |
| r"(?:tokens|word-piece vocabulary|vocabulary)\b", |
| sent, |
| flags=re.IGNORECASE, |
| ): |
| vocabulary_details.append(_clean(m.group(0))) |
|
|
| if found or dataset_sizes or vocabulary_details: |
| support_sentences.append(sent) |
|
|
| dataset_names = _dedupe_strings(dataset_names, limit=10) |
| dataset_sizes = _dedupe_strings(dataset_sizes, limit=10) |
| vocabulary_details = _dedupe_strings(vocabulary_details, limit=10) |
| support_sentences = _dedupe_strings(support_sentences, limit=3) |
| dataset_names = [ |
| x for x in dataset_names |
| if not any(bad in x.lower() for bad in _DATASET_REJECT_TERMS) |
| ] |
| if dataset_names or dataset_sizes or vocabulary_details: |
| parts: List[str] = [] |
|
|
| if dataset_names: |
| parts.append( |
| "Datasets / data sources:\n" |
| + "\n".join(f"- {x}" for x in dataset_names) |
| ) |
|
|
| if dataset_sizes: |
| parts.append( |
| "Dataset sizes:\n" |
| + "\n".join(f"- {x}" for x in dataset_sizes) |
| ) |
|
|
| if vocabulary_details: |
| parts.append( |
| "Vocabulary / tokenization details:\n" |
| + "\n".join(f"- {x}" for x in vocabulary_details) |
| ) |
|
|
| if support_sentences: |
| parts.append( |
| "Evidence snippets:\n" |
| + "\n".join(f"- {s}" for s in support_sentences) |
| ) |
|
|
| return "\n\n".join(parts) |
|
|
| fallback = _rank_sentences("datasets data corpus benchmark", evidence_texts, max_sentences=3) |
| if fallback: |
| return ( |
| "I could not confidently isolate dataset names, but the most relevant evidence is:\n" |
| + "\n".join(f"- {s}" for s in fallback) |
| ) |
|
|
| return "I could not find enough evidence about datasets or data sources in the extracted paper text." |
|
|
|
|
| |
| |
| |
|
|
| _METHOD_STEP_MARKERS = [ |
| "we trained", "we train", "trained", "fine-tuned", "pre-trained", "optimizer", "learning rate", |
| "batch", "epochs", "searched", "screened", "included", "excluded", "inclusion criteria", |
| "exclusion criteria", "data extraction", "preprocessed", "augmentation", "architecture", |
| ] |
|
|
| _EVAL_MARKERS = [ |
| "accuracy", "precision", "recall", "f1", "auc", "bleu", "rouge", "perplexity", |
| "loss", "rmse", "mae", "score", "performance", "outperform", "achieve", "result", |
| "evaluation", "measured", "assessed", "statistical", "p-value", "confidence interval", |
| ] |
|
|
| _REPRO_MARKERS = [ |
| "learning rate", "batch size", "epoch", "optimizer", "dropout", "weight decay", "seed", |
| "gpu", "hardware", "code", "github", "repository", "dataset", "split", "software", |
| "implementation", "inclusion criteria", "exclusion criteria", "screening", "quality assessment", |
| ] |
|
|
|
|
| def _answer_methodology(evidence_texts: List[str]) -> str: |
| steps: List[str] = [] |
| for text in evidence_texts: |
| for sent in _split_sentences(text): |
| low = sent.lower() |
| if any(m in low for m in _METHOD_STEP_MARKERS): |
| steps.append(sent) |
| steps = _dedupe_strings(steps, limit=6) |
| if not steps: |
| steps = _rank_sentences("methodology procedure steps approach", evidence_texts, max_sentences=4) |
| if not steps: |
| return "I could not find enough methodology evidence in the extracted paper text." |
| return "The paper describes these methodological elements:\n" + "\n".join(f"- {s}" for s in steps) |
|
|
|
|
| def _answer_evaluation(evidence_texts: List[str]) -> str: |
| items: List[str] = [] |
| for text in evidence_texts: |
| for sent in _split_sentences(text): |
| low = sent.lower() |
| if any(m in low for m in _EVAL_MARKERS) and (re.search(r"\d", sent) or "result" in low or "performance" in low): |
| items.append(sent) |
| items = _dedupe_strings(items, limit=6) |
| if not items: |
| items = _rank_sentences("evaluation metrics results performance", evidence_texts, max_sentences=4) |
| if not items: |
| return "I could not find enough evaluation evidence in the extracted paper text." |
| return "The paper reports these evaluation/result details:\n" + "\n".join(f"- {s}" for s in items) |
|
|
|
|
| def _answer_figures(evidence_texts: List[str]) -> str: |
| items: List[str] = [] |
| for text in evidence_texts: |
| for sent in _split_sentences(text): |
| low = sent.lower() |
| if any(x in low for x in ["figure", "fig.", "table", "caption", "shown", "illustrates"]): |
| items.append(sent) |
| items = _dedupe_strings(items, limit=5) |
| if not items: |
| items = _rank_sentences("figure table caption shows", evidence_texts, max_sentences=3) |
| if not items: |
| return "I could not find enough figure or table evidence in the extracted paper text." |
| return "The relevant figure/table evidence says:\n" + "\n".join(f"- {s}" for s in items) |
|
|
|
|
| def _answer_reproducibility(evidence_texts: List[str]) -> str: |
| found: List[str] = [] |
| for text in evidence_texts: |
| for sent in _split_sentences(text): |
| low = sent.lower() |
| if any(m in low for m in _REPRO_MARKERS): |
| found.append(sent) |
| found = _dedupe_strings(found, limit=6) |
| if not found: |
| found = _rank_sentences("reproducibility missing hyperparameters software code settings", evidence_texts, max_sentences=4) |
| if not found: |
| return "I could not find enough reproducibility evidence in the extracted paper text." |
| return "The reproducibility-relevant evidence is:\n" + "\n".join(f"- {s}" for s in found) |
|
|
|
|
| def _answer_general(question: str, evidence_texts: List[str]) -> str: |
| sents = _rank_sentences(question, evidence_texts, max_sentences=4) |
| if not sents: |
| return "I could not find enough evidence in the extracted paper text to answer this question." |
| return "Based on the retrieved evidence:\n" + "\n".join(f"- {s}" for s in sents) |
|
|
|
|
| def _synthesize_answer(question: str, evidence_texts: List[str], intent: str) -> str: |
| if intent == "datasets": |
| return _answer_datasets(evidence_texts) |
| if intent == "methodology": |
| return _answer_methodology(evidence_texts) |
| if intent == "evaluation": |
| return _answer_evaluation(evidence_texts) |
| if intent == "figures": |
| return _answer_figures(evidence_texts) |
| if intent == "reproducibility": |
| return _answer_reproducibility(evidence_texts) |
| return _answer_general(question, evidence_texts) |
|
|
|
|
| |
| |
| |
|
|
| def answer_question( |
| extracted: Dict[str, Any], |
| question: str, |
| rag_index: Optional[RagIndex] = None, |
| top_k: int = 5, |
| embedder_backend: str = "local", |
| embedder_model: Optional[str] = None, |
| ) -> Dict[str, Any]: |
| """Answer a question using retrieved chunks from one extracted paper. |
| |
| Parameters |
| ---------- |
| extracted: |
| Output of pdf_loader.extract_pdf(). |
| question: |
| User question. |
| rag_index: |
| Optional prebuilt index. If omitted, this function builds an in-memory index. |
| top_k: |
| Number of evidence chunks to retrieve. |
| embedder_backend: |
| "local" or "nvidia". Used only when rag_index is omitted. |
| embedder_model: |
| Optional embedding model name. |
| """ |
| question = _clean(question) |
| if not question: |
| return {"answer": "No question was provided.", "evidence": [], "query": question} |
|
|
| intent = _intent(question) |
| retrieval_query = _expanded_query(question, intent) |
|
|
| if rag_index is None: |
| rag_index = build_rag_index( |
| extracted, |
| embedder_backend=embedder_backend, |
| embedder_model=embedder_model, |
| ) |
|
|
| |
| internal_top_k = max(top_k, min(10, top_k + 3)) |
| hits = search_rag_index(rag_index, retrieval_query, top_k=internal_top_k) |
| answer = _synthesize_answer(question, _evidence_texts(hits), intent) |
|
|
| |
| evidence = [h.to_evidence() for h in hits[:top_k]] |
|
|
| return { |
| "query": question, |
| "intent": intent, |
| "retrieval_query": retrieval_query, |
| "answer": answer, |
| "evidence": evidence, |
| "rag": { |
| "top_k": top_k, |
| "embedder_backend": rag_index.embedder_backend, |
| "embedder_model": rag_index.embedder_model, |
| "num_chunks": len(rag_index.chunks), |
| }, |
| } |
|
|
|
|
| def answer_from_pipeline_result( |
| pipeline_result: Dict[str, Any], |
| question: str, |
| top_k: int = 5, |
| embedder_backend: str = "local", |
| embedder_model: Optional[str] = None, |
| ) -> Dict[str, Any]: |
| """Convenience helper for results returned by PaperPipeline.run().""" |
| extraction = pipeline_result.get("extraction") or {} |
| if not extraction: |
| return {"answer": "No extraction object was found in the pipeline result.", "evidence": [], "query": question} |
| return answer_question( |
| extraction, |
| question, |
| top_k=top_k, |
| embedder_backend=embedder_backend, |
| embedder_model=embedder_model, |
| ) |
|
|