For now, I can’t really say much in depth about the medical side itself, so I took a quick look at some of the other parts:
I looked through the Space source and ran a few small deterministic checks against the revision I inspected (1ecf1508783f7e6fe729b091a12e68e398a0ad64). Overall, I think there is a useful foundation here, and most of what I found looks more like opportunities to make the existing architecture easier to test and reason about than reasons to change its direction.
The three things I would probably look at first are:
- Keep the current medical MCQ benchmark, but treat it as a model-level baseline rather than an end-to-end application benchmark.
- Preserve the difference between “the literature search returned zero results” and “the literature provider failed”.
- Decide exactly what the factuality judge is supposed to measure, and give it the corresponding evidence view.
Those are relatively small changes, but they would make later stress-testing much easier.
1. I would separate the model benchmark from an end-to-end regression suite
From the revision I checked, benchmark_medico.py sends the question directly to the model. It does not appear to exercise the application’s SYSTEM_PROMPT, retrieval, citation layer, PDF context, risk routing, or most of the other application pipeline.
That does not make the benchmark useless at all. I would actually keep it: it gives you a reasonably clean model/MCQ baseline.
I would just avoid using changes in that score as evidence that a prompt/RAG/citation change improved or regressed the whole application.
For application changes, a second, very small deterministic regression set could probably give more information than expanding the MCQ benchmark. For example:
- one case where the answer exists in a supplied PDF;
- one where the PDF does not contain enough evidence;
- one with two conflicting pieces of evidence;
- one where the external literature provider fails;
- one prompt-injection-like PDF;
- a few clinical-risk routing paraphrases.
Then you can distinguish:
model quality → retrieval → evidence supplied to the model → generation → citation/judging
rather than collapsing all of them into one score.
This general separation is also consistent with how recent health-care RAG evaluation work is starting to frame the problem. A recent scoping review found that retrieval-layer evaluation, grounding/faithfulness, citation/source correctness, conflict handling, and end-to-end outcomes are often measured inconsistently or not separately at all:
Evaluation Methods for Inference-Time Retrieval-Augmented and Graph Retrieval-Augmented Large Language Models in Health Care
The older MedRAG/MIRAGE work is also useful background here because it shows how much results can move depending on the corpus/retriever/model combination rather than just the final LLM:
Benchmarking Retrieval-Augmented Generation for Medicine
2. I would preserve “zero results” and “provider failure” as different states
This one seems especially useful for debugging.
In the search code I checked, a genuine “no papers found” condition and several provider/network failure paths can eventually look very similar downstream: an empty result set.
If the application then falls back to general model knowledge, the final response may look perfectly normal even though two very different things happened:
- the search worked and genuinely found nothing;
- the search did not work.
I would probably carry a small explicit status object through the pipeline, something conceptually like:
ok
zero_results
partial_provider_failure
provider_error
The UI does not necessarily need to expose all of those loudly. The useful part is that the generation layer, logs, evaluation code, and future debugging can tell them apart.
Whether provider_error should completely block an answer is a product decision. For a study assistant it may be perfectly reasonable to say, in effect, “the literature lookup failed, so this answer is based only on the model’s general knowledge.”
The important bit is that this should be distinguishable from “I searched successfully and found no supporting literature.”
The OWASP RAG Security guidance is stricter because it is security-oriented, but its general idea of making retrieval-stage failures observable rather than silently treating them as normal retrieval is useful here too.
3. The factuality judge currently seems to have a different evidence view from the generator
This was probably the most interesting evaluation detail I found.
In the revision I checked, the normal paper context and the judge context are not constructed identically.
For papers, the generator-side context uses a shorter abstract slice, while the factuality judge can see a larger slice. At the same time, the judge has a global context limit, so later PDF fragments can disappear from the judge input when enough paper context comes first.
In a synthetic maximum-ish case, that produced both effects at once:
- the judge could see portions of paper abstracts that the generator never saw;
- some later PDF fragments that were available elsewhere in the pipeline did not make it into the judge context.
That is not necessarily “wrong”; it depends on what the judge is meant to measure.
I think there are two defensible interpretations:
A. “Is this answer factually compatible with all evidence available to the application?”
Then giving the judge extra evidence can make sense.
B. “Did the generator remain faithful to the evidence it actually received?”
Then I would give the judge the exact context payload used for generation, ideally preserving document/chunk IDs with it.
Those are different evaluation questions.
There is a nice terminology connection here: citation correctness (“does the cited source support the claim?”) is not the same as citation faithfulness (“did generation actually depend on that source/evidence?”).
Correctness is not Faithfulness in Retrieval Augmented Generation Attributions
So if I were building the regression harness, I would probably persist the exact generation evidence bundle and reuse that object for faithfulness judging. A broader corpus-level factuality check could then remain a separate metric if useful.
A few small regression cases that may be useful
Clinical risk router: metamorphic/paraphrase cases
I tried a few almost-equivalent strings against the rule-based router.
One simple example was roughly:
I have chest pain and I can't breathe right now. → emergency branch
I have chest pain and I cannot breathe right now. → educational branch
I would not interpret that as a medical-quality result; it is just a useful metamorphic test for the rule layer: a small semantic-preserving wording change changes the branch.
Similar fixtures could include:
- contractions vs expanded forms;
- punctuation changes;
- first person vs third person;
- quoted textbook cases;
- “hypothetical exam case: …”;
- spelling variants;
- Spanish paraphrases.
That would also help measure false positives. For example, a quoted sentence such as a textbook example containing first-person emergency language may reasonably need a different route from an actual user describing their current condition.
The source already treats the rules as a heuristic rather than a perfect classifier, so I think the useful next step is simply to accumulate these cases as regression fixtures rather than trying to make a single rule list cover arbitrary language.
PDF prompt-injection filter
I also tried a few harmless variants against the PDF sanitization layer.
A direct form like:
Ignore all previous instructions ...
was detected, while simple transformations such as line splitting, paraphrasing, or a Unicode homoglyph could evade the pattern match. There was also a benign sentence containing the suspicious phrase that was flagged.
Again, I would not treat this as “the defense is broken.” Pattern filters are useful as one layer, but there is normally a false-positive/false-negative tradeoff.
What seems valuable here is keeping a tiny corpus such as:
- obvious direct injection;
- split-across-lines injection;
- paraphrase;
- Unicode/homoglyph form;
- benign discussion of prompt injection;
- ordinary medical prose that happens to contain words such as “ignore previous instructions”.
Then every change to the filter can be tested against both detection and over-filtering.
The broader design principle in the OWASP RAG Security Cheat Sheet is also useful: retrieved documents remain untrusted data even after they have passed a scanner. I think that fits the architecture you already have better than trying to make the scanner itself a complete security boundary.
Two API / rate-limit details
NCBI API key
There is a small implementation detail in the PubMed path that may be worth checking.
The code I inspected changes its internal throttle from about 0.35s to 0.1s when NCBI_API_KEY exists, which looks like an attempt to switch from the unauthenticated ~3 requests/sec allowance to the API-key ~10 requests/sec allowance.
However, I did not see the key actually added to the E-utilities requests.
NCBI’s documentation says that when using the higher API-key rate, the key should be included in each E-utility request using the api_key parameter:
A General Introduction to the E-utilities — API Keys
It also documents tool and email as parameters software clients should normally include:
The E-utilities In-Depth: Parameters, Syntax and More
So the branch here seems to be:
- if the production Space does not currently set
NCBI_API_KEY: probably nothing urgent;
- if it does: I would pass it into each request before enabling the 0.1-second throttle.
That also makes the intent of the throttle self-documenting.
Per-user rate limit
The per-user limiter also has a small concurrency property worth being aware of.
The “check whether the user is still under the limit” step and the “record this request” step are separate database operations.
I synchronized two callers just below an 8/minute limit, and both were able to observe the remaining slot and then record a request, producing 9 recorded requests for an 8-request limit. That happened in all five synchronized trials.
Whether that matters depends entirely on what the limiter is for:
- soft fairness / casual abuse protection: a rare one-request overshoot may be completely acceptable;
- hard quota: the check + reservation should probably be one atomic transaction;
- protecting the upstream Groq quota: there is an additional shared boundary anyway, because Groq’s API limits apply at the organization level rather than independently to each end user.
Groq rate limits
If a hard SQLite-backed limit is desired, the important part is not “replace SQLite”; it is making the read/decision/write sequence atomic. SQLite itself can serialize writers, and BEGIN IMMEDIATE is one way to acquire the write transaction before doing the final check:
SQLite transaction isolation
Multi-user upload/storage notes — more conditional
These are less certain than the points above because I only reproduced the mechanisms locally; I did not try to provoke them against the live public Space.
Same-filename uploads
The upload path in the revision I checked is based on the original filename.
That means two concurrent users uploading something like notes.pdf can conceptually address the same temporary path.
With a simple filesystem interleaving I could reproduce the obvious sequence:
- user A writes
notes.pdf;
- user B writes another
notes.pdf;
- A reads the path and sees B’s content;
- A’s cleanup removes the path B was also using.
That does not prove this collision has happened on the live app, because real timing and Flet’s upload flow matter.
But if the Space is intended for concurrent public users, using a per-session/user/random namespace for temporary uploads seems like a cheap preventive change.
Flet’s own upload documentation shows the same pattern when uploads need to be separated per user:
Flet FilePicker — Separate uploads per user
For example, conceptually:
<session-or-user-id>/<random-id>/<original-name>
would keep the original filename for display while avoiding using it as the storage identity.
SQLite and Space persistence
This is also entirely dependent on intended behavior.
The application keeps several useful things in SQLite, including chat/history-related state. The normal Hugging Face Space filesystem is ephemeral, so files written there can be lost when the Space restarts or stops.
Current HF documentation recommends attaching a Storage Bucket as a volume when data needs to outlive the Space container:
Hugging Face Spaces — Disk usage and attached volumes
So:
- if the database is intentionally disposable/demo/session state, no issue;
- if accounts/history/PDF-derived data are expected to survive restarts, I would move that state onto an attached volume/bucket or another external store.
This is more of a deployment-policy question than a code defect.
Why I think the small layered regression suite is worth doing
One thing I like about this project is that it already has several explicit boundaries rather than asking the LLM to do everything: literature retrieval, PDF retrieval, deterministic clinical calculations, a risk layer, citation/evidence handling, and an evaluator.
That makes it relatively easy to test each boundary separately.
I would probably organize the tests roughly like this:
Ingestion
- native-text PDF;
- OCR PDF;
- two-column PDF;
- malformed/empty PDF;
- injection-like text.
Retrieval
- exactly one known answer;
- irrelevant document;
- conflicting documents;
- duplicate evidence;
- provider unavailable vs legitimate zero results.
Generation
- evidence sufficient;
- evidence insufficient;
- model prior conflicts with supplied evidence.
Evidence / citations
- cited source really contains the claim;
- remove the source and verify behavior changes appropriately;
- alter a synthetic source value and verify the answer follows it;
- preserve which exact chunks were sent to the model.
Risk routing
- semantic-preserving paraphrases;
- quoted educational cases;
- first-person real-world phrasing;
- contraction/spelling/punctuation variants.
Multi-user / operational
- simultaneous users;
- same filename;
- rate-limit boundary;
- restart/persistence behavior.
The nice thing is that most of those cases do not need expert medical questions.
Synthetic facts work well for plumbing tests. For example, a small PDF can define an intentionally fictional value such as:
The Zorlin index for specimen A is 314.159.
Then the test can answer objective engineering questions:
- Was the correct document retrieved?
- Was the exact chunk sent to generation?
- Was the answer grounded in it?
- Did the citation point back to it?
- What happens if the value changes to
271.828?
- What happens if the document is removed?
- What happens if another document gives a conflicting value?
That isolates the RAG machinery without accidentally turning an engineering regression test into an evaluation of medical expertise.
This kind of layer-specific testing also seems especially useful in medical RAG because current evaluation practice is still quite heterogeneous. The recent health-care RAG evaluation review above found that many studies evaluate end-to-end outputs without separately evaluating retrieval, fine-grained evidence support, citations, or contradiction handling:
Evaluation Methods for Inference-Time Retrieval-Augmented and Graph Retrieval-Augmented Large Language Models in Health Care
So even a very small reusable regression corpus here could be more informative during development than a much larger undifferentiated benchmark.
None of these seem to require changing the basic direction of the app. My default path would be:
keep the current MCQ benchmark as a model baseline → add a tiny end-to-end regression set → preserve retrieval/failure state explicitly → make the evaluator’s evidence contract explicit → gradually add real failures to the regression set as you encounter them.
That would give you a fairly cheap way to tell whether future retrieval, prompt, safety, citation, or provider changes improved the component you intended to change without accidentally moving another layer.