fix: trust summary resilience, Unicode in reason strings, full GitHub README
Browse files- GITHUB_README.md +150 -0
- README.md +2 -0
- src/agents/critic.py +10 -1
- src/agents/synthesizer.py +2 -0
- src/reliability.py +3 -3
GITHUB_README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RECON -- Temporally-Aware Scientific Literature Retrieval
|
| 2 |
+
|
| 3 |
+
> A multi-agent RAG system that asks not just "what is relevant?" but "what should I trust, right now?"
|
| 4 |
+
|
| 5 |
+
**Live demo:** https://huggingface.co/spaces/MukulRay/recon
|
| 6 |
+
**Status:** Active development -- v2 (edge reliability) deployed
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## The Problem
|
| 11 |
+
|
| 12 |
+
Standard RAG retrieves the most semantically similar papers. It has no mechanism to detect when those papers have been superseded by newer work.
|
| 13 |
+
|
| 14 |
+
A 2019 paper with 800 citations scores high on cosine similarity and high on authority. If a 2023 paper explicitly refutes its central claims, retrieving the 2019 paper produces a confident but stale answer. RECON detects this -- and explains why.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## What's New in v2
|
| 19 |
+
|
| 20 |
+
v2 replaces the age-based staleness threshold with a three-signal **edge reliability formula**:
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
edge_reliability = (citation_centrality x 0.4)
|
| 24 |
+
+ (recency_signal x 0.3)
|
| 25 |
+
+ (content_coherence x 0.3)
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
A 2003 paper with 10,000 citations scores **FOUNDATIONAL** -- high centrality overrides age.
|
| 29 |
+
A 2020 paper with 5 citations, superseded by newer work, scores **SUPERSEDED**.
|
| 30 |
+
Pure age-based detection cannot make this distinction.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## Architecture
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
session_loader -> planner -> retriever -> critic -> synthesizer -> END
|
| 38 |
+
|
|
| 39 |
+
retry_retriever (max 2)
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
| Agent | Role |
|
| 43 |
+
|---|---|
|
| 44 |
+
| **Planner** | Decomposes query into 2-3 temporally-typed sub-questions (foundational / recent / open) |
|
| 45 |
+
| **Retriever** | Fetches papers from Semantic Scholar + OpenAlex, deduplicated by DOI. Hybrid scoring: semantic x 0.5 + recency x 0.3 + authority x 0.2 |
|
| 46 |
+
| **Critic** | Computes edge reliability scores, then issues verdict: PASS / STALE / CONTRADICTED / INSUFFICIENT / FORCED_PASS. On non-PASS: rewrites sub-questions with failure-specific strategy |
|
| 47 |
+
| **Synthesizer** | Four-section brief (Overview / Key Findings / Active Debates / Outlook) with per-claim citations and per-paper trust summary |
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Edge Reliability Scoring (`src/reliability.py`)
|
| 52 |
+
|
| 53 |
+
Each retrieved paper receives a `ReliabilityScore` with:
|
| 54 |
+
|
| 55 |
+
- `score` -- composite [0, 1]
|
| 56 |
+
- `centrality` -- `min(1.0, log1p(cited_by_count) / log1p(10000))` from OpenAlex
|
| 57 |
+
- `recency` -- `max(0, 1 - age/20)` linear decay
|
| 58 |
+
- `coherence` -- LLM batch check: does this paper's abstract still represent current consensus?
|
| 59 |
+
- `dominant_signal` -- `FOUNDATIONAL` / `CURRENT` / `DECLINING` / `SUPERSEDED`
|
| 60 |
+
- `reason` -- one-line explanation
|
| 61 |
+
|
| 62 |
+
The synthesizer appends a trust summary to every response so domain experts can verify verdict reasoning.
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## Evaluation
|
| 67 |
+
|
| 68 |
+
130-question benchmark across three categories: consensus claims (Cat A), superseded claims (Cat B), contested claims (Cat C). Ground truth sourced from real ML survey paper supersession chains.
|
| 69 |
+
|
| 70 |
+
| Architecture | Staleness Catch Rate | Position Accuracy | False Positives |
|
| 71 |
+
|---|---|---|---|
|
| 72 |
+
| Single-pass RAG (baseline) | 0% | 32.3% | -- |
|
| 73 |
+
| Naive multi-agent | 0% | 44.6% | -- |
|
| 74 |
+
| RECON v1 (age-based STALE) | 52% | 43.9% | 8% |
|
| 75 |
+
| **RECON v2 (edge reliability)** | **44%** | **44.6%** | **2%** |
|
| 76 |
+
|
| 77 |
+
v2 trades some staleness recall for substantially lower false-positive rate. The reliability formula correctly preserves foundational papers that v1 would incorrectly flag as stale.
|
| 78 |
+
|
| 79 |
+
**Known limitation:** Contradiction catch rate is 0% -- the retriever returns topically adjacent papers rather than opposing-camp papers. This is a retrieval problem, not a critic problem. Addressed in future work.
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## Repository Structure
|
| 84 |
+
|
| 85 |
+
```
|
| 86 |
+
src/
|
| 87 |
+
agents/
|
| 88 |
+
planner.py -- query decomposition, temporally-typed sub-questions
|
| 89 |
+
retriever.py -- S2 + OpenAlex fetch, hybrid scoring, DOI dedup
|
| 90 |
+
critic.py -- edge reliability scoring, verdict logic, retry
|
| 91 |
+
synthesizer.py -- synthesis, trust summary, claim extraction
|
| 92 |
+
openalex_utils.py -- OpenAlex API (search, DOI lookup, citation centrality)
|
| 93 |
+
reliability.py -- three-signal edge reliability scorer
|
| 94 |
+
retriever_utils.py -- hybrid_score, recency_score, authority_score, S2 API
|
| 95 |
+
state.py -- ResearchState TypedDict, Paper/Claim dataclasses
|
| 96 |
+
memory.py -- SQLite session persistence
|
| 97 |
+
graph.py -- LangGraph state machine, node wiring
|
| 98 |
+
app.py -- Gradio UI
|
| 99 |
+
eval/
|
| 100 |
+
run_eval.py -- 5-architecture evaluation harness, LLM-as-judge
|
| 101 |
+
questions.json -- 130-question benchmark
|
| 102 |
+
ground_truth.json -- ground truth for Cat A/B
|
| 103 |
+
results/ -- eval CSVs
|
| 104 |
+
archived/ -- patch_contradiction.py (archived, not used in reported metrics)
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## Setup
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
git clone https://github.com/MukulRay1603/project-recon
|
| 113 |
+
cd project-recon
|
| 114 |
+
pip install -r requirements.txt
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Create a `.env` file:
|
| 118 |
+
|
| 119 |
+
```
|
| 120 |
+
GROQ_API_KEY=...
|
| 121 |
+
OPENALEX_API_KEY=... # free at openalex.org/settings/api
|
| 122 |
+
S2_API_KEY=... # optional but recommended
|
| 123 |
+
TAVILY_API_KEY=... # optional fallback web search
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python app.py
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## Tech Stack
|
| 133 |
+
|
| 134 |
+
| Component | Choice |
|
| 135 |
+
|---|---|
|
| 136 |
+
| Orchestration | LangGraph |
|
| 137 |
+
| LLM | Llama 3.3 70B via Groq |
|
| 138 |
+
| Embeddings | all-MiniLM-L6-v2 (sentence-transformers) |
|
| 139 |
+
| Paper APIs | Semantic Scholar + OpenAlex |
|
| 140 |
+
| Web search | DuckDuckGo (Tavily fallback) |
|
| 141 |
+
| Session memory | SQLite |
|
| 142 |
+
| UI | Gradio |
|
| 143 |
+
| Deployment | Hugging Face Spaces |
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## Author
|
| 148 |
+
|
| 149 |
+
Mukul Ray -- MS Applied ML, University of Maryland College Park
|
| 150 |
+
GitHub: [@MukulRay1603](https://github.com/MukulRay1603)
|
README.md
CHANGED
|
@@ -11,6 +11,8 @@ license: mit
|
|
| 11 |
short_description: Multi-agent ML literature research with staleness detection
|
| 12 |
---
|
| 13 |
|
|
|
|
|
|
|
| 14 |
# RECON β Temporally-Aware Scientific Retrieval
|
| 15 |
|
| 16 |
A multi-agent RAG system that detects when retrieved scientific evidence has been superseded by newer work.
|
|
|
|
| 11 |
short_description: Multi-agent ML literature research with staleness detection
|
| 12 |
---
|
| 13 |
|
| 14 |
+
> For full documentation, architecture details, and eval results: [GITHUB_README.md](./GITHUB_README.md)
|
| 15 |
+
|
| 16 |
# RECON β Temporally-Aware Scientific Retrieval
|
| 17 |
|
| 18 |
A multi-agent RAG system that detects when retrieved scientific evidence has been superseded by newer work.
|
src/agents/critic.py
CHANGED
|
@@ -163,6 +163,7 @@ def critic_node(state: ResearchState) -> ResearchState:
|
|
| 163 |
"rewritten_questions": [],
|
| 164 |
"retry_count": retry_count,
|
| 165 |
"calibration_bin": Verdict.FORCED_PASS,
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
# INSUFFICIENT β not enough papers
|
|
@@ -175,6 +176,7 @@ def critic_node(state: ResearchState) -> ResearchState:
|
|
| 175 |
"rewritten_questions": rewritten,
|
| 176 |
"retry_count": retry_count + 1,
|
| 177 |
"calibration_bin": Verdict.INSUFFICIENT,
|
|
|
|
| 178 |
}
|
| 179 |
|
| 180 |
# INSUFFICIENT β scores too low
|
|
@@ -188,11 +190,18 @@ def critic_node(state: ResearchState) -> ResearchState:
|
|
| 188 |
"rewritten_questions": rewritten,
|
| 189 |
"retry_count": retry_count + 1,
|
| 190 |
"calibration_bin": Verdict.INSUFFICIENT,
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
# --- Phase 2.4: Compute edge reliability scores for all papers ---
|
| 194 |
original_query = state.get("original_query", "")
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
# --- Run STALE and CONTRADICTED checks in parallel (both always run) ---
|
| 198 |
mean_age = _mean_age_months(papers)
|
|
|
|
| 163 |
"rewritten_questions": [],
|
| 164 |
"retry_count": retry_count,
|
| 165 |
"calibration_bin": Verdict.FORCED_PASS,
|
| 166 |
+
"paper_reliability_scores": {},
|
| 167 |
}
|
| 168 |
|
| 169 |
# INSUFFICIENT β not enough papers
|
|
|
|
| 176 |
"rewritten_questions": rewritten,
|
| 177 |
"retry_count": retry_count + 1,
|
| 178 |
"calibration_bin": Verdict.INSUFFICIENT,
|
| 179 |
+
"paper_reliability_scores": {},
|
| 180 |
}
|
| 181 |
|
| 182 |
# INSUFFICIENT β scores too low
|
|
|
|
| 190 |
"rewritten_questions": rewritten,
|
| 191 |
"retry_count": retry_count + 1,
|
| 192 |
"calibration_bin": Verdict.INSUFFICIENT,
|
| 193 |
+
"paper_reliability_scores": {},
|
| 194 |
}
|
| 195 |
|
| 196 |
# --- Phase 2.4: Compute edge reliability scores for all papers ---
|
| 197 |
original_query = state.get("original_query", "")
|
| 198 |
+
try:
|
| 199 |
+
reliability_scores = score_papers(papers, query=original_query, use_llm=True)
|
| 200 |
+
if not reliability_scores:
|
| 201 |
+
logger.warning("score_papers() returned empty dict β falling back to no reliability scores")
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.warning(f"score_papers() failed entirely: {e} β trust summary will be skipped")
|
| 204 |
+
reliability_scores = {}
|
| 205 |
|
| 206 |
# --- Run STALE and CONTRADICTED checks in parallel (both always run) ---
|
| 207 |
mean_age = _mean_age_months(papers)
|
src/agents/synthesizer.py
CHANGED
|
@@ -286,6 +286,8 @@ Synthesize a research position on this query using the evidence above."""
|
|
| 286 |
|
| 287 |
# --- Phase 2.6: Trust summary block ---
|
| 288 |
reliability_scores = state.get("paper_reliability_scores", {})
|
|
|
|
|
|
|
| 289 |
if reliability_scores and papers:
|
| 290 |
trust_lines = ["\n\n---\n## Evidence Trust Summary\n"]
|
| 291 |
for p in papers[:8]: # same top-8 window synthesizer already uses
|
|
|
|
| 286 |
|
| 287 |
# --- Phase 2.6: Trust summary block ---
|
| 288 |
reliability_scores = state.get("paper_reliability_scores", {})
|
| 289 |
+
if not reliability_scores:
|
| 290 |
+
logger.warning("paper_reliability_scores is empty β trust summary skipped. Check if score_papers() ran in critic.")
|
| 291 |
if reliability_scores and papers:
|
| 292 |
trust_lines = ["\n\n---\n## Evidence Trust Summary\n"]
|
| 293 |
for p in papers[:8]: # same top-8 window synthesizer already uses
|
src/reliability.py
CHANGED
|
@@ -185,13 +185,13 @@ def _build_reason(dominant: str, centrality: float, recency: float,
|
|
| 185 |
age_str = f"{age}yr old" if age is not None else "unknown age"
|
| 186 |
|
| 187 |
if dominant == "FOUNDATIONAL":
|
| 188 |
-
return f"High citation centrality ({centrality:.2f}), {age_str}
|
| 189 |
elif dominant == "CURRENT":
|
| 190 |
-
return f"Recent ({age_str}), coherence={coherence:.2f}
|
| 191 |
elif dominant == "DECLINING":
|
| 192 |
return f"Mixed signals: centrality={centrality:.2f}, recency={recency:.2f}, coherence={coherence:.2f}"
|
| 193 |
else:
|
| 194 |
-
return f"Low reliability: {age_str}, centrality={centrality:.2f}, coherence={coherence:.2f}
|
| 195 |
|
| 196 |
|
| 197 |
def score_papers(papers: list, query: str, use_llm: bool = True) -> dict[str, ReliabilityScore]:
|
|
|
|
| 185 |
age_str = f"{age}yr old" if age is not None else "unknown age"
|
| 186 |
|
| 187 |
if dominant == "FOUNDATIONAL":
|
| 188 |
+
return f"High citation centrality ({centrality:.2f}), {age_str} - foundational work still current"
|
| 189 |
elif dominant == "CURRENT":
|
| 190 |
+
return f"Recent ({age_str}), coherence={coherence:.2f} - aligns with current consensus"
|
| 191 |
elif dominant == "DECLINING":
|
| 192 |
return f"Mixed signals: centrality={centrality:.2f}, recency={recency:.2f}, coherence={coherence:.2f}"
|
| 193 |
else:
|
| 194 |
+
return f"Low reliability: {age_str}, centrality={centrality:.2f}, coherence={coherence:.2f} - likely superseded"
|
| 195 |
|
| 196 |
|
| 197 |
def score_papers(papers: list, query: str, use_llm: bool = True) -> dict[str, ReliabilityScore]:
|