{"slug": "show-hn-ebm-lens-searches-biomedical-papers-ranks-evidence-grounds-claims", "title": "Show HN: EBM Lens, searches biomedical papers, ranks evidence, grounds claims", "summary": "EBM Lens, an open-source evidence discovery tool for biomedical questions, searches 12 public databases, expands results via citation graphs, and ranks findings by relevance and study design, with every claim in its synthesis citing sources. Developer Mauro Forlin released the tool on GitHub, requiring Python 3.10+ and an OpenRouter API key, and it includes a read-only hosted demo with 15 precomputed runs. The tool originated as a feature in Forlin's lecture-transcription product Sbobby and is a full rewrite adding study-design ranking, citation verification, and a multi-round search loop.", "body_md": "[ Try the read-only demo](https://mauroforlin.github.io/ebm-lens/): fifteen\nrecorded runs, no signup, no API key. See\n\n`demo/README.md`\n\nfor how it's built.Evidence discovery for biomedical questions. Give it a topic and it searches twelve public biomedical databases, expands the result set through the citation graph, ranks what it found by relevance and by study design, and returns appraised sources plus an overview whose every claim cites the sources under it.\n\n``` php\nflowchart LR\n    Topic[\"Topic\"] --> Planner[\"LLM planner<br/>+ tools\"]\n    Planner --> Databases[\"12 databases<br/>+ citation graph\"]\n    Databases --> Scoring[\"Scoring & ranking<br/>relevance, study design\"]\n    Scoring --> Synthesis[\"Grounded synthesis<br/>claims [0][3], conflicts, gaps\"]\n```\n\nEvery evidence source is a free public API. The only account you need is for the LLM.\n\nEBM Lens started as a feature inside [Sbobby](https://www.sbobby.com), a\nlecture-transcription product I built. That feature, \"Approfondimenti\",\nsearches a similar number of sources for the topics covered in a lecture and\nhas its own basic query planning and reranking. This repository is a full\nrewrite of it: study-design ranking, citation verification and the\nmulti-round search loop are all new.\n\nCloning gets the full, live pipeline: free-text questions, any number of\nsources, real-time search. The [hosted demo](https://mauroforlin.github.io/ebm-lens/)\nis read-only, limited to fifteen precomputed questions, and good for a first\nlook.\n\nRequires Python 3.10+.\n\n```\ngit clone https://github.com/mauroforlin/ebm-lens.git\ncd ebm-lens\npython -m venv .venv\nsource .venv/bin/activate        # .venv\\Scripts\\activate on Windows\npip install -r requirements.txt\n\ncp .env.example .env             # then set OPENROUTER_API_KEY\nuvicorn app.main:app --reload\n```\n\nOpen [http://localhost:8000](http://localhost:8000) for the bundled UI, or call the API directly:\n\n```\ncurl -X POST http://localhost:8000/api/related-articles \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"topic\": \"GLP-1 agonists and cardiovascular risk reduction\",\n        \"max_sources\": 10,\n        \"summary_language\": \"en\"\n      }'\n```\n\n`/api/related-articles/stream`\n\ntakes the same body and returns Server-Sent\nEvents instead of a single JSON response, if you want progress as it happens.\n\nThere is no database, no Redis, no job queue and no container to build. A search runs synchronously inside the request.\n\nOnly `OPENROUTER_API_KEY`\n\nis required. Everything else has a working\ndefault, see `.env.example`\n\n. Worth knowing about:\n\n| Variable | Why you might set it |\n|---|---|\n`CONTACT_EMAIL` |\nGoes in the `User-Agent` . NCBI, Crossref and OpenAlex give identified clients a more generous rate limit. |\n`NCBI_API_KEY` |\nFree, raises PubMed from 3 to 10 req/s. |\n`OPENFDA_API_KEY` |\nFree, raises openFDA from 1,000 to 120,000 requests/day. |\n`API_KEY` |\nIf set, clients must send a matching `X-API-Key` . Set it before exposing the service beyond localhost. |\n`CORS_ALLOW_ORIGINS` |\nOnly needed for a frontend on another origin. The bundled UI is same-origin. |\n\nSending the same string to every provider wastes most of the calls: PubMed wants MeSH-flavoured English, DailyMed wants a bare drug name, and so on.\n\nAn LLM **plans the search instead**, wording each query for the provider\nit's going to, and checks its own work first. `probe_pubmed`\n\nruns a\ncandidate query and returns the hit count and PubMed's MeSH translation,\nsince PubMed expands the words it's given rather than searching them; a term\nit doesn't recognise expands to nothing, looking exactly like a topic with no\nliterature behind it. If planning fails, a deterministic router picks\nproviders from the topic type instead: worse-targeted, but the pipeline\nstill answers.\n\nBefore anything is searched, the topic is put into Population, Intervention, Comparison, Outcome form. Only the elements it actually states get filled in; an invented comparator would send the search after literature nobody asked about.\n\nA topic spanning several axes (\"CAR-T therapy, BBB disruption and ctDNA\nmonitoring in glioblastoma\") gets a **query per axis** instead of one query\nthat returns whatever its most-published axis happens to be, so the\nthinnest-literature axis isn't starved by the others.\n\nA single query pass fails frontier topics two ways: seminal papers often use vocabulary the phrasing doesn't contain, and a highly-cited paper about a different sense of the same words can outrank what's actually on-topic.\n\nSo discovery runs as a loop: a research brief, several diverse query variants, and only papers that clear a relevance gate seed citation-graph expansion (seeding on the most-cited hit just expands around the wrong paper). The loop reads its strongest results, extracts the vocabulary the literature actually uses, and refines, stopping as soon as a round stops surfacing new on-topic work.\n\nEmbeddings read meaning but compress a paper into one vector, so a rare decisive term counts for about as much as any other word; BM25 has the opposite blind spot. Both run, fused with citation authority, recency, provider tier and an LLM reranker.\n\n**Study design is scored separately**, since a case report and a\nmeta-analysis on the same subject are equally on-topic and not equally worth\nbelieving. It's checked against the provider's own classification first,\nthen NLM's publication types, and only then the wording of the abstract, so\nan unrecognised design scores neutral rather than low.\n\nThe overview comes back as claims, not prose: each one names the articles it rests on, and a claim citing nothing real is dropped before it reaches the response. Citation hallucination doesn't get fixed by a better prompt, so it's checked in code instead.\n\nWhere sources disagree, **that disagreement is the finding**: conflicts come\nback with the sources on each side named, rather than averaged into\nsomething that reads like settled science.\n\nA run takes 60-180 seconds: real multi-round search plus several LLM calls.\nThe streaming endpoint emits a frame per stage so the UI shows what's\nactually running instead of a bare spinner, and every response carries a\n`job_stats`\n\nbreakdown: real cost, tokens, per-stage timing, per-source calls.\n\nMost of the pipeline is fixed: the stages, the providers, the ranking. Two\nsteps aren't, because the right move depends on facts nobody's looked up\nyet, like which words a database indexes or what a brand name is actually\ncalled. Those two run as **tool loops** instead of fixed code.\n\n| Tool | Answers |\n|---|---|\n`probe_pubmed(query)` |\nHit count, PubMed's MeSH translation, the phrases it couldn't match. |\n`resolve_drug(name)` |\nThe active molecule behind a brand name, via RxNorm. |\n`search_guidelines(topic)` |\nReal clinical practice guideline titles, via Europe PMC. |\n`submit_brief(...)` / `submit_queries(queries)` |\nTerminal tools: the brief and query batch as typed arguments, not prose to re-parse. |\n\nTool calls issued in the same turn run concurrently, and identical repeated\ncalls are served from a memo. When the round budget runs low, the terminal\ntool is forced through `tool_choice`\n\n: \"explored too long\" turns into\n\"submits what it has.\" Every call is counted in `job_stats.tools`\n\n, so which\nlookups the model actually reaches for is visible in the response.\n\n```\napp/\n  main.py          FastAPI app, static frontend, startup config\n  config.py        settings (pydantic-settings, reads .env)\n  schemas.py       request/response models and the internal TopicSpec\n  api/             endpoints.py (sync + streaming routes), deps.py (API-key auth)\n\n  core/            infrastructure that knows nothing about medicine\n    llm_client.py    OpenRouter wrapper: model routing, retries, JSON repair,\n                     tool-calling loop, cost capture\n    embeddings.py    batched embeddings\n    events.py        SSE progress events for the streaming endpoint\n    cache.py         in-process TTL cache, LRU-bounded\n    ratelimiter.py   in-process token/request limiter\n    job_stats.py     per-run cost, token and timing accounting\n\n  sources/         everything that talks to the outside world\n    base.py          SourceProvider / SourceResult contract\n    blocklist.py     domain quality gate applied to every result\n    <12 providers>   one module each\n    citation_expander.py   Semantic Scholar + OpenAlex neighbours\n    content_extractor.py   full-text fetch, allowlisted hosts only\n\n  pipeline/        the discovery logic\n    orchestrator.py  entry point; the run, stage by stage\n    topic_analysis.py  domain detection and composite decomposition\n    searcher.py      the tiered multi-provider search\n    agentic.py       the multi-round discovery loop\n    dedup.py         source identity (DOI, then title, then URL)\n    ranking.py       pure scoring functions and weight profiles\n    relevance.py     builds per-candidate signal maps\n    lexical.py       BM25 over the candidate pool, no index, no dependency\n    evidence_grade.py  study design detection and the evidence hierarchy\n    evidence_cache.py  whole-topic result cache, keyed on the normalised query\n    selection.py     final ranking and selection policy\n    synthesis.py     per-source appraisal and the grounded overview\n    planner_tools.py the tools the model may call, and their dispatch\n\nfrontend/  index.html, app.js, style.css: plain files, no build step\n```\n\n**No persistence.** Caches are in-memory and reset on restart, which costs a slower first query afterwards and nothing else.`app/core/cache.py`\n\nexposes only`get`\n\n/`set`\n\n, so swapping in a Redis or SQLite backing is a single-module change.**Both caches match on exact hashes.**`evidence_cache.py`\n\n's topic lookup and the per-provider cache in`cache.py`\n\nboth normalise (lowercase, strip punctuation, sort words) before hashing, so word-order variants of the same question share an entry, but two genuinely different phrasings do not. Catching those needs embedding similarity over a vector store, the infrastructure this project deliberately does without. A miss just costs a slower response.**Single process.** Fine for local use or one container. Scaling horizontally means giving`cache.py`\n\nand`ratelimiter.py`\n\na shared backend, since both are per-process today.**Summaries are a reading aid.** They exist to help decide which sources are worth opening. Every result also links back to its source, with provider, study design and reliability tier shown alongside it.**The design tier maps to GRADE's first input, nothing more.** GRADE grades a body of evidence for a specific question, weighing risk of bias, imprecision and indirectness, none of which this pipeline can see. What the ranker produces is the design tier, GRADE's first and largest input, reported by name so the rest of the appraisal can be applied by hand.**Fetched source text is untrusted input.** The pipeline pulls full page text from allowlisted hosts, including a user-editable one (Wikipedia), and passes it to an LLM that has tools available. The domain allowlist is the only control; nothing sanitises page content. Hardening that path is open work.\n\nImportant\n\n**Citation checking stops at existence (working on it)**\nA claim citing an article that is not in the response gets dropped before the response is built. Whether a cited article actually supports the sentence citing it is left to the reader; the response links straight to the source for that check.\n\n`eval/`\n\ngrades each pipeline stage against a public, externally-labelled\ndataset, instead of one end-to-end judged score: retrieval and reranking\nagainst BioASQ's PubMed relevance judgments, per-source stance and citation\ngrounding against SciFact's expert-labelled claims, and PICO extraction\nagainst EBM-NLP's crowd-annotated abstracts.\n\n```\npython eval/retrieval_eval.py\npython eval/pool_relevance_eval.py\npython eval/pico_eval.py\npython eval/stance_eval.py\n```\n\nEach script writes a resumable run to `eval/results/`\n\nand appends a row to\n`eval/results/history.jsonl`\n\n, so a change to the pipeline can be compared\nagainst the run before it. See `eval/README.md`\n\nfor dataset provenance,\nlicensing and the caveats specific to each benchmark.", "url": "https://wpnews.pro/news/show-hn-ebm-lens-searches-biomedical-papers-ranks-evidence-grounds-claims", "canonical_source": "https://github.com/mauroforlin/ebm-lens", "published_at": "2026-08-26 13:28:57+00:00", "updated_at": "2026-08-26 13:46:28.092322+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-products", "natural-language-processing"], "entities": ["EBM Lens", "Mauro Forlin", "Sbobby", "OpenRouter", "PubMed", "NCBI", "Crossref", "OpenAlex"], "alternates": {"html": "https://wpnews.pro/news/show-hn-ebm-lens-searches-biomedical-papers-ranks-evidence-grounds-claims", "markdown": "https://wpnews.pro/news/show-hn-ebm-lens-searches-biomedical-papers-ranks-evidence-grounds-claims.md", "text": "https://wpnews.pro/news/show-hn-ebm-lens-searches-biomedical-papers-ranks-evidence-grounds-claims.txt", "jsonld": "https://wpnews.pro/news/show-hn-ebm-lens-searches-biomedical-papers-ranks-evidence-grounds-claims.jsonld"}}