Spaces:
Running
Running
Kshamaa S commited on
Commit ·
8dccdbf
1
Parent(s): 5c81160
Initial deployment: NeuroLens cognitive health screening pipeline
Browse files- README.md +191 -15
- app.py +355 -0
- biomarkers.py +350 -0
- data/corpus.json +146 -0
- rag_engine.py +325 -0
- requirements.txt +10 -3
README.md
CHANGED
|
@@ -1,20 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NeuroLens
|
| 2 |
+
|
| 3 |
+
### Cognitive Health Screening & Coaching Pipeline
|
| 4 |
+
|
| 5 |
+
NeuroLens is a three-stage system that takes a short conversational language
|
| 6 |
+
assessment, extracts linguistic biomarkers documented in the cognitive-aging
|
| 7 |
+
research literature, and generates a citation-grounded, retrieval-augmented
|
| 8 |
+
prevention coaching summary.
|
| 9 |
+
|
| 10 |
+
**Live demo:** _add your HuggingFace Spaces link here after deploying_
|
| 11 |
+
**Author:** Kshamaa
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Overview
|
| 16 |
+
|
| 17 |
+
| | |
|
| 18 |
+
|---|---|
|
| 19 |
+
| **Stage 1** | Conversational cognitive assessment — timed verbal fluency, narrative description, delayed recall |
|
| 20 |
+
| **Stage 2** | NLP-based linguistic biomarker extraction (spaCy / NLTK) |
|
| 21 |
+
| **Stage 3** | Retrieval-augmented generation over a curated literature corpus, with an automated faithfulness check |
|
| 22 |
+
|
| 23 |
+
The project was built to demonstrate applied NLP and RAG system design in a
|
| 24 |
+
health-adjacent domain, with explicit attention to scientific grounding,
|
| 25 |
+
citation discipline, and output evaluation — engineering practices that
|
| 26 |
+
matter disproportionately when a system touches health-related content.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Disclaimer
|
| 31 |
+
|
| 32 |
+
This is a research and engineering demonstration prototype, not a validated
|
| 33 |
+
clinical or diagnostic tool. It is inspired by published, peer-reviewed
|
| 34 |
+
cognitive-linguistic assessment paradigms (verbal fluency, narrative
|
| 35 |
+
description, delayed recall), with the following caveats:
|
| 36 |
+
|
| 37 |
+
- It has not been clinically validated against any diagnostic gold standard.
|
| 38 |
+
- Its reference ranges are rough midpoints drawn from published study
|
| 39 |
+
ranges, not a clinically derived normative dataset.
|
| 40 |
+
- Language samples are typed, not spoken, and collected in an unsupervised
|
| 41 |
+
setting — both differ materially from standardized clinical administration.
|
| 42 |
+
- A production deployment in this space would require IRB-reviewed
|
| 43 |
+
prospective studies, licensed clinical oversight, and regulatory review.
|
| 44 |
+
|
| 45 |
+
The system is designed to demonstrate AI engineering — NLP feature
|
| 46 |
+
extraction, retrieval-augmented generation, and faithfulness evaluation —
|
| 47 |
+
applied with genuine literacy in the underlying scientific domain.
|
| 48 |
+
|
| 49 |
---
|
| 50 |
+
|
| 51 |
+
## Architecture
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
Stage 1: Conversational Assessment (Streamlit)
|
| 55 |
+
→ semantic fluency, phonemic fluency, narrative description, delayed recall
|
| 56 |
+
↓
|
| 57 |
+
Stage 2: Linguistic Biomarker Extraction (spaCy / NLTK)
|
| 58 |
+
→ lexical diversity (TTR/MATTR), fluency counts, syntactic complexity,
|
| 59 |
+
disfluency rate, approximate idea density
|
| 60 |
+
↓
|
| 61 |
+
Stage 3: Grounded Prevention Coaching (RAG)
|
| 62 |
+
→ retrieval over a curated 18-paper corpus (e5-large-v2 + FAISS, with a
|
| 63 |
+
TF-IDF fallback) → generated summary (openai/gpt-oss-120b via Groq)
|
| 64 |
+
→ RAGAS-style faithfulness check against retrieved sources
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
## Repository Structure
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
.
|
| 71 |
+
├── app.py # Streamlit application — all three stages
|
| 72 |
+
├── biomarkers.py # Stage 2: NLP feature extraction
|
| 73 |
+
├── rag_engine.py # Stage 3: retrieval, generation, faithfulness check
|
| 74 |
+
├── data/
|
| 75 |
+
│ └── corpus.json # Curated literature corpus (18 entries)
|
| 76 |
+
├── requirements.txt
|
| 77 |
+
└── README.md
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
---
|
| 81 |
|
| 82 |
+
## Stage 2 — Linguistic Biomarkers
|
| 83 |
+
|
| 84 |
+
| Marker | What it measures | Research basis |
|
| 85 |
+
|---|---|---|
|
| 86 |
+
| Semantic (category) fluency | Unique animals named in 60 seconds | One of the most-replicated early markers of cognitive decline; shown to decline faster than letter fluency specifically in those at elevated Alzheimer's risk (longitudinal cohort, n=2,261; PMC7403823). Also shown to meaningfully discriminate normal aging / MCI / AD (PMC9153280). |
|
| 87 |
+
| Phonemic (letter) fluency | Unique F-words named in 60 seconds | Standard companion measure to semantic fluency; tends to remain stable until closer to dementia onset (PMC7403823). |
|
| 88 |
+
| Lexical diversity (TTR / MATTR) | Vocabulary variety, length-corrected | Reduced lexical diversity is associated with reduced vocabulary access in spontaneous speech (Covington & McFall, 2010). |
|
| 89 |
+
| Approximate idea density | Propositions per 10 words (POS-based approximation) | Modeled on the Nun Study: idea density in autobiographies written at ~age 22 predicted Alzheimer's neuropathology roughly 60 years later (Snowdon et al., 1996; retrospective analysis, PMC11852352). This implementation uses a simplified POS-tag-based approximation, not the original CPIDR scoring system. |
|
| 90 |
+
| Syntactic complexity | Subordination index, clause density | Simplified sentence structure has been associated with increased cognitive load during language production. |
|
| 91 |
+
| Delayed recall | Words correctly recalled after a distractor task | Standard episodic memory paradigm, simplified here. |
|
| 92 |
+
|
| 93 |
+
### Validation extension (planned, not yet executed)
|
| 94 |
+
|
| 95 |
+
Biomarker extraction could be validated against the **DementiaBank Pitt
|
| 96 |
+
Corpus** — transcribed Cookie Theft picture descriptions from healthy
|
| 97 |
+
controls and dementia patients (Becker et al., 1994). This corpus is
|
| 98 |
+
access-gated (approved-research-use only via TalkBank/DementiaBank), so it
|
| 99 |
+
remains a documented next step rather than something bundled into the repo.
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## Stage 3 — Literature Corpus & Retrieval
|
| 104 |
+
|
| 105 |
+
The corpus (`data/corpus.json`) contains 18 entries across six domains, each
|
| 106 |
+
summarized in original prose with source attribution and a link to the
|
| 107 |
+
original publication:
|
| 108 |
+
|
| 109 |
+
- **APOE4 genetics × lifestyle interaction** — meta-analysis of FINGER, MAPT,
|
| 110 |
+
and J-MINT (n>3,400) finding lifestyle intervention benefits APOE4 carriers
|
| 111 |
+
as much as or more than non-carriers (PMC12726239).
|
| 112 |
+
- **Exercise & cognitive reserve** — mechanistic and outcome evidence linking
|
| 113 |
+
physical activity to neuroplasticity, glymphatic clearance, and preserved
|
| 114 |
+
executive function.
|
| 115 |
+
- **Diet & nutrition** — the MIND diet RCT (NEJM, 2023) alongside a
|
| 116 |
+
field-wide caution piece (*Nature*, 2025) on the limits of diet-only effects
|
| 117 |
+
relative to combined multidomain interventions.
|
| 118 |
+
- **Cognitive training** — the FINGER trial and its global WW-FINGERS
|
| 119 |
+
adaptations (~70 countries), showing ~25% greater cognitive improvement in
|
| 120 |
+
the multidomain intervention group versus control.
|
| 121 |
+
- **Social engagement** — social activity as a structural component of
|
| 122 |
+
FINGER-style trials, including digitally-delivered versions.
|
| 123 |
+
- **Cognitive reserve / early-life enrichment** — the Nun Study's idea
|
| 124 |
+
density findings and related exercise results.
|
| 125 |
+
|
| 126 |
+
Retrieval is marker-driven: a Stage 2 score below the reference range
|
| 127 |
+
triggers retrieval from the literature domain most relevant to that specific
|
| 128 |
+
marker (e.g., low semantic fluency → cognitive training and social
|
| 129 |
+
engagement literature), rather than relying on raw text similarity alone.
|
| 130 |
+
|
| 131 |
+
### Faithfulness evaluation
|
| 132 |
+
|
| 133 |
+
Every generated coaching summary is checked with a second model call
|
| 134 |
+
(`openai/gpt-oss-120b` via Groq) that decomposes the text into individual
|
| 135 |
+
factual claims and verdicts each as SUPPORTED / PARTIAL / UNSUPPORTED
|
| 136 |
+
against the retrieved source excerpts — a RAGAS-style faithfulness check,
|
| 137 |
+
surfaced in the app's "Faithfulness check" panel. This is treated as the
|
| 138 |
+
most important evaluation step in the pipeline: given the health-adjacent
|
| 139 |
+
subject matter, demonstrating that the system catches its own unsupported
|
| 140 |
+
claims matters more here than in a typical RAG demo.
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## Setup
|
| 145 |
+
|
| 146 |
+
### Local installation
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
pip install -r requirements.txt
|
| 150 |
+
python -m spacy download en_core_web_sm # if not already pulled via requirements.txt
|
| 151 |
+
export GROQ_API_KEY=your_key_here
|
| 152 |
+
streamlit run app.py
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
A Groq API key is required for Stage 3 generation: create one at
|
| 156 |
+
[console.groq.com](https://console.groq.com) under **API Keys**.
|
| 157 |
+
|
| 158 |
+
### Deployment (HuggingFace Spaces)
|
| 159 |
+
|
| 160 |
+
1. Create a new Space (SDK: Streamlit).
|
| 161 |
+
2. Push `app.py`, `biomarkers.py`, `rag_engine.py`, `data/corpus.json`, and `requirements.txt`.
|
| 162 |
+
3. Add `GROQ_API_KEY` as a Space secret under **Settings → Variables and secrets**.
|
| 163 |
+
4. On first run, the app downloads the spaCy model and the `e5-large-v2`
|
| 164 |
+
sentence-embedding model. If `sentence-transformers` or its weights are
|
| 165 |
+
unavailable for any reason, the retriever automatically falls back to a
|
| 166 |
+
TF-IDF retriever (`rag_engine.py: LiteratureRetriever`), so the pipeline
|
| 167 |
+
degrades gracefully rather than failing.
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## Known Limitations
|
| 172 |
+
|
| 173 |
+
- Reference ranges are literature-informed approximations, not a validated
|
| 174 |
+
clinical normative sample — flagged explicitly in the app.
|
| 175 |
+
- Typed responses differ from spoken responses; several markers (disfluency,
|
| 176 |
+
timing) are conventionally derived from speech, not text.
|
| 177 |
+
- Fluency tasks use an enforced 60-second countdown (30 seconds for the
|
| 178 |
+
recall distractor), implemented via periodic Streamlit reruns rather than
|
| 179 |
+
a client-side timer — accurate to roughly one second, sufficient for this
|
| 180 |
+
use case.
|
| 181 |
+
- Idea density is a simplified POS-based approximation of the
|
| 182 |
+
Snowdon/Kemper method, not the original CPIDR scoring tool.
|
| 183 |
+
- The animal/F-word validity lists used for fluency scoring are compact,
|
| 184 |
+
curated lists rather than an exhaustive lexical resource; uncommon but
|
| 185 |
+
valid answers may be flagged as "possible intrusions" rather than counted.
|
| 186 |
+
- No clinical validation has been performed; DementiaBank validation is a
|
| 187 |
+
documented next step pending approved data access.
|
| 188 |
|
| 189 |
+
## Future Work
|
| 190 |
|
| 191 |
+
- Speech-to-text capture for analysis of true spoken language
|
| 192 |
+
- Client-side (JS-based) frame-accurate timing
|
| 193 |
+
- DementiaBank Pitt Corpus validation, reporting a basic separation result
|
| 194 |
+
(e.g. effect size) between healthy-control and dementia transcripts
|
| 195 |
+
- Expansion of the literature corpus and per-domain retrieval re-ranking
|
| 196 |
+
- Opt-in longitudinal tracking of results across multiple sessions
|
app.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py — NeuroLens: End-to-End Cognitive Health Screening & Coaching Pipeline
|
| 3 |
+
|
| 4 |
+
A three-stage Streamlit app:
|
| 5 |
+
1. Conversational cognitive assessment (typed language samples, enforced timers)
|
| 6 |
+
2. Linguistic biomarker extraction + dashboard (Stage 2)
|
| 7 |
+
3. Grounded, citation-backed prevention coaching via RAG (Stage 3)
|
| 8 |
+
|
| 9 |
+
IMPORTANT: This is a research/portfolio demonstration prototype inspired by
|
| 10 |
+
published cognitive assessment paradigms. It is NOT a validated clinical or
|
| 11 |
+
diagnostic instrument. See the "About this project" section in the sidebar
|
| 12 |
+
and README.md for full limitations and citations.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
import streamlit as st
|
| 18 |
+
from streamlit_autorefresh import st_autorefresh
|
| 19 |
+
|
| 20 |
+
from biomarkers import build_profile
|
| 21 |
+
from rag_engine import LiteratureRetriever, build_retrieved_context, generate_coaching, check_faithfulness
|
| 22 |
+
|
| 23 |
+
st.set_page_config(page_title="NeuroLens", page_icon="🧠", layout="wide")
|
| 24 |
+
|
| 25 |
+
RECALL_WORDS = ["apple", "river", "hammer", "candle", "bicycle", "lantern"]
|
| 26 |
+
FLUENCY_TIME_LIMIT = 60 # seconds, enforced — matches the standard 60s fluency-task paradigm
|
| 27 |
+
DISTRACTOR_TIME_LIMIT = 30 # seconds, enforced counting-backward distractor
|
| 28 |
+
|
| 29 |
+
TASK_FLOW = ["semantic", "phonemic", "narrative", "distractor", "recall"]
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Timer helper — enforces a hard time limit on a task using periodic reruns.
|
| 33 |
+
# The text typed so far is preserved (tied to session_state by widget key);
|
| 34 |
+
# once time is up the input is disabled and the user must continue.
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
def enforced_timer(task_key: str, limit_seconds: int):
|
| 37 |
+
start_key = f"{task_key}_start_time"
|
| 38 |
+
if start_key not in st.session_state:
|
| 39 |
+
st.session_state[start_key] = time.time()
|
| 40 |
+
|
| 41 |
+
elapsed = time.time() - st.session_state[start_key]
|
| 42 |
+
remaining = max(0, limit_seconds - elapsed)
|
| 43 |
+
timed_out = remaining <= 0
|
| 44 |
+
|
| 45 |
+
if not timed_out:
|
| 46 |
+
# Re-run this script once per second until the limit is hit, so the
|
| 47 |
+
# countdown visibly ticks down and the widget gets disabled the
|
| 48 |
+
# moment time expires — without needing any JS component.
|
| 49 |
+
st_autorefresh(interval=1000, limit=limit_seconds + 2, key=f"refresh_{task_key}")
|
| 50 |
+
|
| 51 |
+
mins, secs = divmod(int(remaining), 60)
|
| 52 |
+
bar_col, time_col = st.columns([4, 1])
|
| 53 |
+
with bar_col:
|
| 54 |
+
st.progress(min(1.0, elapsed / limit_seconds) if limit_seconds else 1.0)
|
| 55 |
+
with time_col:
|
| 56 |
+
st.markdown(f"**⏱️ 0:{secs:02d}**" if mins == 0 else f"**⏱️ {mins}:{secs:02d}**")
|
| 57 |
+
|
| 58 |
+
if timed_out:
|
| 59 |
+
st.warning("⏰ Time's up — your response has been locked in.", icon="⏰")
|
| 60 |
+
|
| 61 |
+
return timed_out
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def reset_task_timer(task_key: str):
|
| 65 |
+
st.session_state.pop(f"{task_key}_start_time", None)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Sidebar — framing & disclosure (lead with the limitation, not the capability)
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
with st.sidebar:
|
| 72 |
+
st.markdown("## 🧠 About NeuroLens")
|
| 73 |
+
st.warning(
|
| 74 |
+
"**This is a research / portfolio demonstration prototype**, not a "
|
| 75 |
+
"validated diagnostic or clinical tool. It is inspired by published "
|
| 76 |
+
"cognitive-linguistic assessment paradigms but has not been clinically "
|
| 77 |
+
"validated. Any real-world deployment would require IRB-reviewed "
|
| 78 |
+
"studies and licensed clinical oversight.",
|
| 79 |
+
icon="⚠️",
|
| 80 |
+
)
|
| 81 |
+
st.markdown(
|
| 82 |
+
"**What it actually demonstrates:**\n"
|
| 83 |
+
"- NLP feature extraction grounded in real cognitive-aging research\n"
|
| 84 |
+
"- Enforced, timed task administration (matching the standard 60s "
|
| 85 |
+
"fluency-task paradigm used in the literature)\n"
|
| 86 |
+
"- A retrieval-augmented generation (RAG) pipeline with a faithfulness check\n"
|
| 87 |
+
"- End-to-end product thinking applied to a health-adjacent domain"
|
| 88 |
+
)
|
| 89 |
+
st.markdown("---")
|
| 90 |
+
st.markdown(
|
| 91 |
+
"Built as a proof-of-concept exploring the kind of AI engineering + "
|
| 92 |
+
"domain literacy relevant to evidence-informed brain-health products. "
|
| 93 |
+
"See `README.md` for full citations and the linguistic-biomarker literature."
|
| 94 |
+
)
|
| 95 |
+
api_key_present = bool(os.environ.get("GROQ_API_KEY"))
|
| 96 |
+
if not api_key_present:
|
| 97 |
+
st.info("Set the `GROQ_API_KEY` environment variable to enable Stage 3 coaching generation.", icon="🔑")
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# Session state
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
if "stage" not in st.session_state:
|
| 103 |
+
st.session_state.stage = 1
|
| 104 |
+
if "task_idx" not in st.session_state:
|
| 105 |
+
st.session_state.task_idx = 0
|
| 106 |
+
if "responses" not in st.session_state:
|
| 107 |
+
st.session_state.responses = {}
|
| 108 |
+
if "profile" not in st.session_state:
|
| 109 |
+
st.session_state.profile = None
|
| 110 |
+
if "coaching" not in st.session_state:
|
| 111 |
+
st.session_state.coaching = None
|
| 112 |
+
|
| 113 |
+
st.title("🧠 NeuroLens")
|
| 114 |
+
st.caption("Research-prototype cognitive-linguistic screening → biomarker dashboard → grounded prevention coaching")
|
| 115 |
+
|
| 116 |
+
stage_labels = ["1. Assessment", "2. Biomarker Dashboard", "3. Prevention Coaching"]
|
| 117 |
+
st.progress((st.session_state.stage - 1) / 2, text=stage_labels[st.session_state.stage - 1])
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def goto_next_task():
|
| 121 |
+
st.session_state.task_idx += 1
|
| 122 |
+
if st.session_state.task_idx >= len(TASK_FLOW):
|
| 123 |
+
st.session_state.profile = build_profile(st.session_state.responses)
|
| 124 |
+
st.session_state.stage = 2
|
| 125 |
+
st.rerun()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
# STAGE 1 — Conversational Cognitive Assessment (each task individually timed)
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
if st.session_state.stage == 1:
|
| 132 |
+
st.header("Stage 1 · Short Language Assessment")
|
| 133 |
+
st.caption(
|
| 134 |
+
"Simplified, non-clinical versions of standard cognitive-linguistics research tasks. "
|
| 135 |
+
"The fluency tasks are timed (60s) to match the standard paradigm; just type freely — "
|
| 136 |
+
"there are no right or wrong answers."
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
current_task = TASK_FLOW[st.session_state.task_idx]
|
| 140 |
+
st.subheader(f"Task {st.session_state.task_idx + 1} of {len(TASK_FLOW)}")
|
| 141 |
+
|
| 142 |
+
# --- Semantic (category) fluency: 60s enforced ---
|
| 143 |
+
if current_task == "semantic":
|
| 144 |
+
st.markdown("### 🐾 Category Fluency")
|
| 145 |
+
st.write("List as many **animals** as you can before the timer runs out. Separate with commas or spaces.")
|
| 146 |
+
timed_out = enforced_timer("semantic", FLUENCY_TIME_LIMIT)
|
| 147 |
+
text = st.text_area("Animals", key="semantic_input", height=110,
|
| 148 |
+
placeholder="e.g. dog, cat, elephant, ...", disabled=timed_out,
|
| 149 |
+
label_visibility="collapsed")
|
| 150 |
+
c1, c2 = st.columns([1, 1])
|
| 151 |
+
with c1:
|
| 152 |
+
early = st.button("I'm done →", disabled=timed_out)
|
| 153 |
+
if timed_out or early:
|
| 154 |
+
st.session_state.responses["semantic_fluency_text"] = st.session_state.get("semantic_input", "")
|
| 155 |
+
reset_task_timer("semantic")
|
| 156 |
+
goto_next_task()
|
| 157 |
+
|
| 158 |
+
# --- Phonemic (letter) fluency: 60s enforced ---
|
| 159 |
+
elif current_task == "phonemic":
|
| 160 |
+
st.markdown("### 🔤 Letter Fluency")
|
| 161 |
+
st.write("List as many words as you can that start with the letter **F** (no names of people/places).")
|
| 162 |
+
timed_out = enforced_timer("phonemic", FLUENCY_TIME_LIMIT)
|
| 163 |
+
text = st.text_area("F words", key="phonemic_input", height=110,
|
| 164 |
+
placeholder="e.g. fish, fast, forest, ...", disabled=timed_out,
|
| 165 |
+
label_visibility="collapsed")
|
| 166 |
+
early = st.button("I'm done →", disabled=timed_out)
|
| 167 |
+
if timed_out or early:
|
| 168 |
+
st.session_state.responses["phonemic_fluency_text"] = st.session_state.get("phonemic_input", "")
|
| 169 |
+
st.session_state.responses["phonemic_letter"] = "F"
|
| 170 |
+
reset_task_timer("phonemic")
|
| 171 |
+
goto_next_task()
|
| 172 |
+
|
| 173 |
+
# --- Narrative description: open-ended, no enforced timer (per paradigm) ---
|
| 174 |
+
elif current_task == "narrative":
|
| 175 |
+
st.markdown("### 📝 Narrative Description")
|
| 176 |
+
st.write("Describe your **morning routine** in as much detail as you can. Take your time — this task isn't timed.")
|
| 177 |
+
text = st.text_area("Your morning routine", key="narrative_input", height=160,
|
| 178 |
+
placeholder="I usually wake up around...", label_visibility="collapsed")
|
| 179 |
+
if st.button("Continue →"):
|
| 180 |
+
st.session_state.responses["narrative_text"] = st.session_state.get("narrative_input", "")
|
| 181 |
+
goto_next_task()
|
| 182 |
+
|
| 183 |
+
# --- Distractor task: 30s enforced counting-backward, before recall ---
|
| 184 |
+
elif current_task == "distractor":
|
| 185 |
+
st.markdown("### 🔢 Quick Mental Task")
|
| 186 |
+
st.write("Try to keep the six words below in mind, then count backward from 100 by 7s "
|
| 187 |
+
"(100, 93, 86 ...) until the timer ends.")
|
| 188 |
+
st.markdown("#### " + " • ".join(w.upper() for w in RECALL_WORDS))
|
| 189 |
+
timed_out = enforced_timer("distractor", DISTRACTOR_TIME_LIMIT)
|
| 190 |
+
if timed_out:
|
| 191 |
+
if st.button("Continue to recall →"):
|
| 192 |
+
reset_task_timer("distractor")
|
| 193 |
+
goto_next_task()
|
| 194 |
+
else:
|
| 195 |
+
st.caption("The recall task will unlock automatically when the timer ends.")
|
| 196 |
+
|
| 197 |
+
# --- Delayed recall: open-ended, no enforced timer ---
|
| 198 |
+
elif current_task == "recall":
|
| 199 |
+
st.markdown("### 🧩 Delayed Recall")
|
| 200 |
+
st.write("Now list as many of the six words from a moment ago as you can remember:")
|
| 201 |
+
text = st.text_area("Recalled words", key="recall_input", height=90, label_visibility="collapsed")
|
| 202 |
+
if st.button("Submit assessment →", type="primary"):
|
| 203 |
+
st.session_state.responses["recall_response_text"] = st.session_state.get("recall_input", "")
|
| 204 |
+
st.session_state.responses["recall_target_words"] = RECALL_WORDS
|
| 205 |
+
goto_next_task()
|
| 206 |
+
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
# STAGE 2 — Linguistic Biomarker Dashboard
|
| 209 |
+
# ---------------------------------------------------------------------------
|
| 210 |
+
elif st.session_state.stage == 2:
|
| 211 |
+
st.header("Stage 2 · Linguistic Biomarker Dashboard")
|
| 212 |
+
st.caption(
|
| 213 |
+
"Computed from your timed/typed responses using standard NLP feature-extraction techniques "
|
| 214 |
+
"(spaCy/NLTK), compared against rough, literature-informed reference ranges — "
|
| 215 |
+
"**not a clinically derived normative dataset.** See README for the limitation."
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
profile = st.session_state.profile
|
| 219 |
+
bands = profile["bands"]
|
| 220 |
+
|
| 221 |
+
band_icon = {"below_typical_range": "🔻", "within_typical_range": "✅", "above_typical_range": "🔺"}
|
| 222 |
+
band_label = {"below_typical_range": "Below typical range", "within_typical_range": "Within typical range",
|
| 223 |
+
"above_typical_range": "Above typical range"}
|
| 224 |
+
|
| 225 |
+
cols = st.columns(3)
|
| 226 |
+
metrics = [
|
| 227 |
+
("Semantic fluency", profile["semantic_fluency"]["unique_valid_count"], "unique animals named",
|
| 228 |
+
bands["semantic_fluency"]),
|
| 229 |
+
("Phonemic fluency", profile["phonemic_fluency"]["unique_valid_count"], "unique F-words named",
|
| 230 |
+
bands["phonemic_fluency"]),
|
| 231 |
+
("Lexical diversity (MATTR)", profile["lexical_diversity"]["mattr"], "vocabulary variety score",
|
| 232 |
+
bands["lexical_diversity"]),
|
| 233 |
+
("Idea density (approx.)", profile["idea_density"]["approx_idea_density_per_10_words"], "propositions per 10 words",
|
| 234 |
+
bands["idea_density"]),
|
| 235 |
+
("Syntactic complexity", profile["syntactic_complexity"]["subordination_index"], "subordinate clauses / sentence",
|
| 236 |
+
bands["syntactic_complexity"]),
|
| 237 |
+
("Delayed recall", f"{profile['delayed_recall']['correct_count']}/{profile['delayed_recall']['total_targets']}",
|
| 238 |
+
"words correctly recalled", None),
|
| 239 |
+
]
|
| 240 |
+
for i, (label, value, sublabel, band) in enumerate(metrics):
|
| 241 |
+
with cols[i % 3]:
|
| 242 |
+
badge = f"{band_icon.get(band,'')} {band_label.get(band,'')}" if band else ""
|
| 243 |
+
st.metric(label, value, help=sublabel)
|
| 244 |
+
if badge:
|
| 245 |
+
st.caption(badge)
|
| 246 |
+
|
| 247 |
+
with st.expander("📋 See full extracted profile (raw output)"):
|
| 248 |
+
st.json(profile)
|
| 249 |
+
|
| 250 |
+
with st.expander("ℹ️ What do these markers mean? (with citations)"):
|
| 251 |
+
st.markdown("""
|
| 252 |
+
- **Semantic fluency** (animal naming): one of the most-replicated early markers in the
|
| 253 |
+
cognitive-aging literature; semantic fluency has been shown to decline faster than letter
|
| 254 |
+
fluency specifically in those at elevated Alzheimer's risk (PMC7403823).
|
| 255 |
+
- **Lexical diversity (MATTR)**: a length-corrected measure of vocabulary variety; reduced
|
| 256 |
+
lexical diversity is associated with reduced vocabulary access in spontaneous speech.
|
| 257 |
+
- **Idea density**: a simplified, POS-based approximation of the propositional idea-density
|
| 258 |
+
measure made famous by the Nun Study, where idea density in essays written in early
|
| 259 |
+
adulthood predicted Alzheimer's pathology roughly 60 years later (Snowdon et al., 1996;
|
| 260 |
+
PMC11852352).
|
| 261 |
+
- **Syntactic complexity**: simplified sentence structure can reflect increased cognitive load
|
| 262 |
+
during language production.
|
| 263 |
+
- **Delayed recall**: a classic episodic memory measure, included here in simplified form.
|
| 264 |
+
|
| 265 |
+
Full citation list is in `README.md`.
|
| 266 |
+
""")
|
| 267 |
+
|
| 268 |
+
st.warning(
|
| 269 |
+
"Reference ranges shown above are **rough, literature-informed approximations**, "
|
| 270 |
+
"not a validated clinical normative dataset. A single below-range score on a short, "
|
| 271 |
+
"typed task says very little on its own — performance varies widely with mood, "
|
| 272 |
+
"fatigue, typing speed, and unfamiliarity with the task.",
|
| 273 |
+
icon="⚠️",
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
col_a, col_b = st.columns(2)
|
| 277 |
+
with col_a:
|
| 278 |
+
if st.button("← Retake assessment"):
|
| 279 |
+
st.session_state.stage = 1
|
| 280 |
+
st.session_state.task_idx = 0
|
| 281 |
+
st.session_state.responses = {}
|
| 282 |
+
st.session_state.profile = None
|
| 283 |
+
for t in TASK_FLOW:
|
| 284 |
+
reset_task_timer(t)
|
| 285 |
+
st.rerun()
|
| 286 |
+
with col_b:
|
| 287 |
+
if st.button("Continue to personalized coaching →", type="primary"):
|
| 288 |
+
st.session_state.stage = 3
|
| 289 |
+
st.rerun()
|
| 290 |
+
|
| 291 |
+
# ---------------------------------------------------------------------------
|
| 292 |
+
# STAGE 3 — Grounded Prevention Coaching (RAG)
|
| 293 |
+
# ---------------------------------------------------------------------------
|
| 294 |
+
elif st.session_state.stage == 3:
|
| 295 |
+
st.header("Stage 3 · Personalized Prevention Coaching")
|
| 296 |
+
st.caption(
|
| 297 |
+
"Retrieves relevant brain-health prevention literature based on your Stage 2 profile, "
|
| 298 |
+
"then generates a grounded summary — with a faithfulness check against the retrieved sources."
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
api_key = os.environ.get("GROQ_API_KEY")
|
| 302 |
+
if not api_key:
|
| 303 |
+
st.error(
|
| 304 |
+
"No `GROQ_API_KEY` found in the environment. Set it (e.g. as a HuggingFace "
|
| 305 |
+
"Spaces secret) to generate coaching text — see README.md for setup.",
|
| 306 |
+
icon="🔑",
|
| 307 |
+
)
|
| 308 |
+
else:
|
| 309 |
+
if st.session_state.coaching is None:
|
| 310 |
+
with st.spinner("Retrieving relevant literature and generating your summary..."):
|
| 311 |
+
retriever = LiteratureRetriever()
|
| 312 |
+
retrieved = build_retrieved_context(retriever, st.session_state.profile)
|
| 313 |
+
coaching = generate_coaching(st.session_state.profile, retrieved)
|
| 314 |
+
faithfulness = check_faithfulness(coaching["text"], coaching["sources_used"])
|
| 315 |
+
st.session_state.coaching = {"coaching": coaching, "faithfulness": faithfulness,
|
| 316 |
+
"retriever_backend": retriever.backend}
|
| 317 |
+
|
| 318 |
+
result = st.session_state.coaching
|
| 319 |
+
st.markdown("### Your personalized summary")
|
| 320 |
+
st.markdown(result["coaching"]["text"])
|
| 321 |
+
|
| 322 |
+
if result["coaching"]["sources_used"]:
|
| 323 |
+
with st.expander(f"📚 Sources used ({len(result['coaching']['sources_used'])})"):
|
| 324 |
+
for s in result["coaching"]["sources_used"]:
|
| 325 |
+
st.markdown(f"**{s['title']}** \n*{s['source']}* \n{s['summary']} \n[Link]({s['url']})")
|
| 326 |
+
st.markdown("---")
|
| 327 |
+
|
| 328 |
+
with st.expander("✅ Faithfulness check (RAGAS-style evaluation)"):
|
| 329 |
+
f = result["faithfulness"]
|
| 330 |
+
score = f.get("faithfulness_score")
|
| 331 |
+
if score is not None:
|
| 332 |
+
st.metric("Faithfulness score", f"{score:.0%}",
|
| 333 |
+
help="Fraction of generated claims directly supported by retrieved sources")
|
| 334 |
+
for c in f.get("claims", []):
|
| 335 |
+
icon = {"SUPPORTED": "✅", "PARTIAL": "🟡", "UNSUPPORTED": "❌"}.get(c["verdict"], "❓")
|
| 336 |
+
st.write(f"{icon} **{c['verdict']}** — {c['claim']}")
|
| 337 |
+
st.caption(c.get("reason", ""))
|
| 338 |
+
st.caption(f"Retrieval backend used: `{result['retriever_backend']}`")
|
| 339 |
+
|
| 340 |
+
st.markdown("---")
|
| 341 |
+
col_a, col_b = st.columns(2)
|
| 342 |
+
with col_a:
|
| 343 |
+
if st.button("← Back to dashboard"):
|
| 344 |
+
st.session_state.stage = 2
|
| 345 |
+
st.rerun()
|
| 346 |
+
with col_b:
|
| 347 |
+
if st.button("Start over"):
|
| 348 |
+
st.session_state.stage = 1
|
| 349 |
+
st.session_state.task_idx = 0
|
| 350 |
+
st.session_state.responses = {}
|
| 351 |
+
st.session_state.profile = None
|
| 352 |
+
st.session_state.coaching = None
|
| 353 |
+
for t in TASK_FLOW:
|
| 354 |
+
reset_task_timer(t)
|
| 355 |
+
st.rerun()
|
biomarkers.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
biomarkers.py — Stage 2 of NeuroLens: Linguistic Biomarker Extraction
|
| 3 |
+
|
| 4 |
+
Computes computational approximations of linguistic markers documented in the
|
| 5 |
+
cognitive-aging / dementia literature from short, typed language samples.
|
| 6 |
+
|
| 7 |
+
IMPORTANT: These are simplified, non-clinical approximations of published
|
| 8 |
+
research measures, computed from typed text rather than clinically
|
| 9 |
+
standardized administration and scoring. They are NOT validated diagnostic
|
| 10 |
+
or clinical assessment tools. See README.md for citations and limitations.
|
| 11 |
+
|
| 12 |
+
Design notes:
|
| 13 |
+
- Uses spaCy if a model is installed (richer dependency-based syntax metrics).
|
| 14 |
+
Falls back to NLTK POS tagging if spaCy/its model isn't available, so the
|
| 15 |
+
pipeline still runs in restricted environments. On deployment (HF Spaces),
|
| 16 |
+
install en_core_web_sm for the full feature set.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
import math
|
| 21 |
+
from collections import Counter
|
| 22 |
+
|
| 23 |
+
import nltk
|
| 24 |
+
from nltk.tokenize import word_tokenize, sent_tokenize
|
| 25 |
+
|
| 26 |
+
# Ensure required NLTK resources are present (no-op if already downloaded)
|
| 27 |
+
for pkg in ["punkt_tab", "punkt", "averaged_perceptron_tagger_eng",
|
| 28 |
+
"averaged_perceptron_tagger", "stopwords"]:
|
| 29 |
+
try:
|
| 30 |
+
nltk.data.find(pkg)
|
| 31 |
+
except LookupError:
|
| 32 |
+
try:
|
| 33 |
+
nltk.download(pkg, quiet=True)
|
| 34 |
+
except Exception:
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
# Try to load spaCy for richer syntactic parsing; degrade gracefully if unavailable.
|
| 38 |
+
_SPACY_NLP = None
|
| 39 |
+
try:
|
| 40 |
+
import spacy
|
| 41 |
+
try:
|
| 42 |
+
_SPACY_NLP = spacy.load("en_core_web_sm")
|
| 43 |
+
except OSError:
|
| 44 |
+
_SPACY_NLP = None
|
| 45 |
+
except ImportError:
|
| 46 |
+
_SPACY_NLP = None
|
| 47 |
+
|
| 48 |
+
FILLER_WORDS = {"um", "uh", "er", "erm", "hmm", "uhh", "umm"}
|
| 49 |
+
|
| 50 |
+
SUBORDINATING_MARKERS = {
|
| 51 |
+
"because", "although", "though", "since", "unless", "while", "whereas",
|
| 52 |
+
"if", "when", "whenever", "after", "before", "until", "as", "that",
|
| 53 |
+
"which", "who", "whom", "whose", "where", "so that", "even though",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Curated, non-exhaustive validity lists for fluency-task scoring.
|
| 57 |
+
# In production these should be swapped for a larger lexical resource;
|
| 58 |
+
# kept compact here for transparency and easy auditing.
|
| 59 |
+
COMMON_ANIMALS = set("""
|
| 60 |
+
dog cat horse cow pig sheep goat chicken duck goose turkey rabbit mouse rat
|
| 61 |
+
squirrel deer elk moose bear wolf fox lion tiger leopard cheetah elephant
|
| 62 |
+
giraffe zebra hippopotamus hippo rhinoceros rhino monkey gorilla chimpanzee
|
| 63 |
+
chimp orangutan kangaroo koala panda camel llama alpaca otter beaver raccoon
|
| 64 |
+
skunk badger hedgehog bat whale dolphin shark seal walrus penguin eagle hawk
|
| 65 |
+
owl falcon vulture sparrow robin crow raven pigeon parrot peacock ostrich
|
| 66 |
+
flamingo swan pelican woodpecker hummingbird snake lizard turtle tortoise
|
| 67 |
+
crocodile alligator frog toad salamander newt fish trout salmon tuna catfish
|
| 68 |
+
goldfish shrimp crab lobster octopus squid jellyfish starfish bee wasp ant
|
| 69 |
+
butterfly moth spider scorpion beetle ladybug grasshopper cricket mosquito
|
| 70 |
+
fly cockroach worm snail slug donkey mule buffalo bison antelope gazelle
|
| 71 |
+
hyena jaguar puma lynx wolverine porcupine armadillo sloth anteater platypus
|
| 72 |
+
mole shrew weasel ferret mink stoat chinchilla guinea pig hamster gerbil
|
| 73 |
+
parakeet canary cockatoo toucan stork heron crane ibis kiwi emu cassowary
|
| 74 |
+
iguana gecko chameleon python cobra viper boa anaconda komodo dragon manatee
|
| 75 |
+
narwhal orca dugong stingray eel pufferfish swordfish marlin barracuda
|
| 76 |
+
""".split())
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _tokenize_words(text: str):
|
| 80 |
+
return [w for w in word_tokenize(text.lower()) if re.search(r"[a-z]", w)]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _pos_tags(text: str):
|
| 84 |
+
tokens = word_tokenize(text)
|
| 85 |
+
return nltk.pos_tag(tokens)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
# 1. Lexical diversity — TTR and MATTR (Covington & McFall, 2010)
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
def lexical_diversity(text: str, window: int = 30) -> dict:
|
| 92 |
+
words = _tokenize_words(text)
|
| 93 |
+
n = len(words)
|
| 94 |
+
if n == 0:
|
| 95 |
+
return {"ttr": 0.0, "mattr": 0.0, "token_count": 0}
|
| 96 |
+
|
| 97 |
+
ttr = len(set(words)) / n
|
| 98 |
+
|
| 99 |
+
if n <= window:
|
| 100 |
+
mattr = ttr
|
| 101 |
+
else:
|
| 102 |
+
ratios = []
|
| 103 |
+
for i in range(n - window + 1):
|
| 104 |
+
seg = words[i:i + window]
|
| 105 |
+
ratios.append(len(set(seg)) / window)
|
| 106 |
+
mattr = sum(ratios) / len(ratios)
|
| 107 |
+
|
| 108 |
+
return {"ttr": round(ttr, 3), "mattr": round(mattr, 3), "token_count": n}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
# 2. Semantic / phonemic fluency scoring
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
def score_semantic_fluency(raw_response: str) -> dict:
|
| 115 |
+
"""Animal-naming (category) fluency: unique valid exemplars generated."""
|
| 116 |
+
candidates = [w.strip(".,;") for w in re.split(r"[,\s]+", raw_response.lower()) if w.strip(".,;")]
|
| 117 |
+
seen, valid_unique, intrusions = set(), [], []
|
| 118 |
+
for w in candidates:
|
| 119 |
+
if w in seen:
|
| 120 |
+
continue
|
| 121 |
+
seen.add(w)
|
| 122 |
+
if w in COMMON_ANIMALS:
|
| 123 |
+
valid_unique.append(w)
|
| 124 |
+
else:
|
| 125 |
+
intrusions.append(w)
|
| 126 |
+
return {
|
| 127 |
+
"unique_valid_count": len(valid_unique),
|
| 128 |
+
"items": valid_unique,
|
| 129 |
+
"possible_intrusions": intrusions, # words not in animal list — not necessarily errors, may just be missing from our list
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def score_phonemic_fluency(raw_response: str, target_letter: str) -> dict:
|
| 134 |
+
"""Letter (phonemic) fluency, e.g. words starting with 'F'."""
|
| 135 |
+
target_letter = target_letter.lower()
|
| 136 |
+
candidates = [w.strip(".,;") for w in re.split(r"[,\s]+", raw_response.lower()) if w.strip(".,;")]
|
| 137 |
+
seen, valid_unique, invalid = set(), [], []
|
| 138 |
+
for w in candidates:
|
| 139 |
+
if w in seen:
|
| 140 |
+
continue
|
| 141 |
+
seen.add(w)
|
| 142 |
+
if w.startswith(target_letter) and len(w) > 1:
|
| 143 |
+
valid_unique.append(w)
|
| 144 |
+
else:
|
| 145 |
+
invalid.append(w)
|
| 146 |
+
return {
|
| 147 |
+
"unique_valid_count": len(valid_unique),
|
| 148 |
+
"items": valid_unique,
|
| 149 |
+
"excluded": invalid,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
# 3. Syntactic complexity — MLU + subordination index
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
def syntactic_complexity(text: str) -> dict:
|
| 157 |
+
sentences = [s for s in sent_tokenize(text) if s.strip()]
|
| 158 |
+
if not sentences:
|
| 159 |
+
return {"mean_length_utterance": 0.0, "subordination_index": 0.0, "clause_density": 0.0}
|
| 160 |
+
|
| 161 |
+
words_per_sentence = [len(_tokenize_words(s)) for s in sentences]
|
| 162 |
+
mlu = sum(words_per_sentence) / len(sentences)
|
| 163 |
+
|
| 164 |
+
total_words = sum(words_per_sentence) or 1
|
| 165 |
+
sub_count = 0
|
| 166 |
+
clause_markers = 0
|
| 167 |
+
|
| 168 |
+
if _SPACY_NLP is not None:
|
| 169 |
+
doc = _SPACY_NLP(text)
|
| 170 |
+
# Dependency-based clause count: finite verbs governed by mark/advcl/relcl/ccomp
|
| 171 |
+
clause_markers = sum(1 for tok in doc if tok.dep_ in
|
| 172 |
+
{"advcl", "relcl", "ccomp", "acl", "csubj"})
|
| 173 |
+
sub_count = sum(1 for tok in doc if tok.dep_ == "mark")
|
| 174 |
+
else:
|
| 175 |
+
text_lower = text.lower()
|
| 176 |
+
for marker in SUBORDINATING_MARKERS:
|
| 177 |
+
sub_count += len(re.findall(rf"\b{re.escape(marker)}\b", text_lower))
|
| 178 |
+
clause_markers = sub_count # fallback approximation
|
| 179 |
+
|
| 180 |
+
subordination_index = sub_count / len(sentences)
|
| 181 |
+
clause_density = clause_markers / total_words * 100 # clauses per 100 words
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
"mean_length_utterance": round(mlu, 2),
|
| 185 |
+
"subordination_index": round(subordination_index, 2),
|
| 186 |
+
"clause_density": round(clause_density, 2),
|
| 187 |
+
"engine": "spacy" if _SPACY_NLP is not None else "nltk_fallback",
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# 4. Disfluency markers
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
def disfluency_markers(text: str) -> dict:
|
| 195 |
+
words = _tokenize_words(text)
|
| 196 |
+
n = len(words) or 1
|
| 197 |
+
|
| 198 |
+
filler_count = sum(1 for w in words if w in FILLER_WORDS)
|
| 199 |
+
|
| 200 |
+
# immediate word repetitions ("the the", "I I")
|
| 201 |
+
repetitions = sum(1 for i in range(1, len(words)) if words[i] == words[i - 1])
|
| 202 |
+
|
| 203 |
+
# false starts: dash/ellipsis-truncated tokens, common in typed self-correction
|
| 204 |
+
false_starts = len(re.findall(r"\b\w+-\s", text)) + text.count("...")
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
"filler_rate_per_100_words": round(filler_count / n * 100, 2),
|
| 208 |
+
"repetition_rate_per_100_words": round(repetitions / n * 100, 2),
|
| 209 |
+
"false_start_count": false_starts,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
# 5. Idea density (approximation of Snowdon & Kemper's propositional idea
|
| 215 |
+
# density / CPIDR method). True CPIDR uses fine-grained proposition rules;
|
| 216 |
+
# this is a POS-based approximation: count of content + relational words
|
| 217 |
+
# that typically introduce a proposition (verbs, adjectives, adverbs,
|
| 218 |
+
# prepositions, conjunctions) per 10 words.
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
def idea_density(text: str) -> dict:
|
| 221 |
+
tagged = _pos_tags(text)
|
| 222 |
+
words = [w for w, t in tagged if re.search(r"[a-zA-Z]", w)]
|
| 223 |
+
n = len(words) or 1
|
| 224 |
+
|
| 225 |
+
proposition_tags = {
|
| 226 |
+
"VB", "VBD", "VBG", "VBN", "VBP", "VBZ", # verbs
|
| 227 |
+
"JJ", "JJR", "JJS", # adjectives
|
| 228 |
+
"RB", "RBR", "RBS", # adverbs
|
| 229 |
+
"IN", # prepositions/subordinators
|
| 230 |
+
"CC", # coordinating conjunctions
|
| 231 |
+
}
|
| 232 |
+
proposition_count = sum(1 for w, t in tagged if t in proposition_tags)
|
| 233 |
+
|
| 234 |
+
density_per_10 = proposition_count / n * 10
|
| 235 |
+
|
| 236 |
+
return {
|
| 237 |
+
"approx_idea_density_per_10_words": round(density_per_10, 2),
|
| 238 |
+
"proposition_count": proposition_count,
|
| 239 |
+
"word_count": n,
|
| 240 |
+
"note": "POS-based approximation of Snowdon & Kemper idea density, not full CPIDR scoring",
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ---------------------------------------------------------------------------
|
| 245 |
+
# 6. Delayed recall scoring
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
def score_delayed_recall(recalled_text: str, target_words: list) -> dict:
|
| 248 |
+
recalled = set(_tokenize_words(recalled_text))
|
| 249 |
+
targets = set(w.lower() for w in target_words)
|
| 250 |
+
correct = recalled & targets
|
| 251 |
+
intrusions = recalled - targets
|
| 252 |
+
return {
|
| 253 |
+
"correct_count": len(correct),
|
| 254 |
+
"total_targets": len(targets),
|
| 255 |
+
"correct_words": sorted(correct),
|
| 256 |
+
"intrusions": sorted(intrusions),
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ---------------------------------------------------------------------------
|
| 261 |
+
# Rough published / literature-informed comparison baselines.
|
| 262 |
+
# These are NOT a clinically derived normative dataset — they are coarse
|
| 263 |
+
# midpoints drawn from ranges reported across the cited fluency-task
|
| 264 |
+
# literature, intended only to give the user a directional sense of where a
|
| 265 |
+
# score falls. This limitation is stated explicitly in the UI and README.
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
ROUGH_NORMS = {
|
| 268 |
+
"semantic_fluency_count": {"low": 13, "mid": 19, "high": 25},
|
| 269 |
+
"phonemic_fluency_count": {"low": 9, "mid": 14, "high": 19},
|
| 270 |
+
"mattr": {"low": 0.55, "high": 0.75},
|
| 271 |
+
"approx_idea_density_per_10_words": {"low": 3.0, "high": 5.5},
|
| 272 |
+
"subordination_index": {"low": 0.3, "high": 1.2},
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def percentile_band(value: float, low: float, high: float) -> str:
|
| 277 |
+
if value < low:
|
| 278 |
+
return "below_typical_range"
|
| 279 |
+
if value > high:
|
| 280 |
+
return "above_typical_range"
|
| 281 |
+
return "within_typical_range"
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def build_profile(assessment_responses: dict) -> dict:
|
| 285 |
+
"""
|
| 286 |
+
assessment_responses keys expected:
|
| 287 |
+
'semantic_fluency_text', 'phonemic_fluency_text', 'phonemic_letter',
|
| 288 |
+
'narrative_text', 'recall_response_text', 'recall_target_words'
|
| 289 |
+
Returns a structured biomarker profile across all four tasks.
|
| 290 |
+
"""
|
| 291 |
+
sem = score_semantic_fluency(assessment_responses.get("semantic_fluency_text", ""))
|
| 292 |
+
phon = score_phonemic_fluency(
|
| 293 |
+
assessment_responses.get("phonemic_fluency_text", ""),
|
| 294 |
+
assessment_responses.get("phonemic_letter", "F"),
|
| 295 |
+
)
|
| 296 |
+
narrative = assessment_responses.get("narrative_text", "")
|
| 297 |
+
lex = lexical_diversity(narrative)
|
| 298 |
+
syn = syntactic_complexity(narrative)
|
| 299 |
+
disf = disfluency_markers(narrative)
|
| 300 |
+
idea = idea_density(narrative)
|
| 301 |
+
recall = score_delayed_recall(
|
| 302 |
+
assessment_responses.get("recall_response_text", ""),
|
| 303 |
+
assessment_responses.get("recall_target_words", []),
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
norms = ROUGH_NORMS
|
| 307 |
+
bands = {
|
| 308 |
+
"semantic_fluency": percentile_band(
|
| 309 |
+
sem["unique_valid_count"], norms["semantic_fluency_count"]["low"], norms["semantic_fluency_count"]["high"]),
|
| 310 |
+
"phonemic_fluency": percentile_band(
|
| 311 |
+
phon["unique_valid_count"], norms["phonemic_fluency_count"]["low"], norms["phonemic_fluency_count"]["high"]),
|
| 312 |
+
"lexical_diversity": percentile_band(
|
| 313 |
+
lex["mattr"], norms["mattr"]["low"], norms["mattr"]["high"]),
|
| 314 |
+
"idea_density": percentile_band(
|
| 315 |
+
idea["approx_idea_density_per_10_words"],
|
| 316 |
+
norms["approx_idea_density_per_10_words"]["low"],
|
| 317 |
+
norms["approx_idea_density_per_10_words"]["high"]),
|
| 318 |
+
"syntactic_complexity": percentile_band(
|
| 319 |
+
syn["subordination_index"], norms["subordination_index"]["low"], norms["subordination_index"]["high"]),
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
return {
|
| 323 |
+
"semantic_fluency": sem,
|
| 324 |
+
"phonemic_fluency": phon,
|
| 325 |
+
"lexical_diversity": lex,
|
| 326 |
+
"syntactic_complexity": syn,
|
| 327 |
+
"disfluency": disf,
|
| 328 |
+
"idea_density": idea,
|
| 329 |
+
"delayed_recall": recall,
|
| 330 |
+
"bands": bands,
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
if __name__ == "__main__":
|
| 335 |
+
# Smoke test
|
| 336 |
+
sample = {
|
| 337 |
+
"semantic_fluency_text": "dog cat horse cow pig sheep goat lion tiger bear bear fox wolf",
|
| 338 |
+
"phonemic_fluency_text": "fox fish fan friend funny fast forest",
|
| 339 |
+
"phonemic_letter": "F",
|
| 340 |
+
"narrative_text": (
|
| 341 |
+
"I wake up around seven and I, um, make coffee while I check my email. "
|
| 342 |
+
"Then I usually take a shower and get dressed before I head out, although "
|
| 343 |
+
"sometimes I eat breakfast first if I have time."
|
| 344 |
+
),
|
| 345 |
+
"recall_response_text": "apple table river hammer",
|
| 346 |
+
"recall_target_words": ["apple", "table", "river", "hammer", "candle", "bicycle"],
|
| 347 |
+
}
|
| 348 |
+
import json
|
| 349 |
+
profile = build_profile(sample)
|
| 350 |
+
print(json.dumps(profile, indent=2))
|
data/corpus.json
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "fingers_multidomain_2015",
|
| 4 |
+
"domain": "cognitive_training",
|
| 5 |
+
"title": "FINGER: Finnish Geriatric Intervention Study to Prevent Cognitive Impairment and Disability",
|
| 6 |
+
"source": "Kivipelto et al., The Lancet, 2015; long-term follow-up analyses 2025-2026",
|
| 7 |
+
"url": "https://www.sciencedirect.com/science/article/pii/S2666245025000091",
|
| 8 |
+
"summary": "FINGER was the first large randomized controlled trial (n=1,260) to show that a combined two-year program of healthy diet, structured exercise, cognitive training, social activity, and vascular risk management produced meaningfully better cognitive outcomes than general health advice in older adults at risk for decline. The intervention group improved roughly 25% more on a composite cognition score, with gains across executive function, processing speed, and complex memory. The control group's risk of cognitive decline was about 30% higher after two years. Eleven-year follow-up work has found that both intervention participation and the degree of actual lifestyle change achieved were linked to better long-term cognitive trajectories, suggesting durability beyond the active trial period."
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"id": "ww_fingers_global_network",
|
| 12 |
+
"domain": "cognitive_training",
|
| 13 |
+
"title": "World-Wide FINGERS network and digital cognitive training tools",
|
| 14 |
+
"source": "Multiple WW-FINGERS protocol and outcome papers, 2025-2026",
|
| 15 |
+
"url": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12762639/",
|
| 16 |
+
"summary": "The FINGER model has since been adapted by research teams in roughly 70 countries through the World-Wide FINGERS network, with computer-based and app-delivered cognitive training as a consistent component across adaptations such as MIND-ADmini, AgeWell.de, J-MINT, and MIND-CHINA. Across these trials, digital cognitive training is most often combined with physical activity and social engagement components rather than used in isolation, and several programs have demonstrated that internet-delivered, multidomain interventions can produce measurable cognitive and health benefits even without in-person delivery."
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"id": "us_pointer_2025",
|
| 20 |
+
"domain": "cognitive_training",
|
| 21 |
+
"title": "U.S. POINTER: testing FINGER-style multidomain intervention in a U.S. population",
|
| 22 |
+
"source": "U.S. POINTER design and outcome papers, 2025",
|
| 23 |
+
"url": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12726325/",
|
| 24 |
+
"summary": "U.S. POINTER was designed to test whether FINGER's positive cognitive findings generalize to a larger, more diverse American population at risk for cognitive decline. It combined the same core domains -- physical activity, nutritional guidance, cognitive and social engagement, and cardiometabolic risk management -- delivered through a structured, coached, multi-year program, reinforcing that combined lifestyle interventions (rather than any single habit change) tend to outperform single-domain approaches."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "apoe4_lifestyle_metaanalysis",
|
| 28 |
+
"domain": "apoe4_genetics",
|
| 29 |
+
"title": "Effect of ApoE genotype on efficacy of multidomain lifestyle interventions: meta-analysis of FINGER, MAPT, and J-MINT",
|
| 30 |
+
"source": "Alzheimer's & Dementia, PMC12726239, 2025",
|
| 31 |
+
"url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC12726239/",
|
| 32 |
+
"summary": "Pooling three randomized controlled trials (FINGER, MAPT, and J-MINT, combined n > 3,400), this meta-analysis found that multidomain lifestyle interventions benefited APOE4 carriers at least as much as -- and in some trials more than -- non-carriers, with a statistically significant genotype-by-intervention interaction. Effect sizes were largest in J-MINT (0.36), followed by FINGER (0.15) and MAPT (0.12). This is described as the first robust evidence that carrying the strongest common genetic risk factor for Alzheimer's does not blunt the benefit of lifestyle intervention, and may even amplify it."
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "pensa_apoe4_egcg",
|
| 36 |
+
"domain": "apoe4_genetics",
|
| 37 |
+
"title": "PENSA: personalized multimodal lifestyle intervention in APOE4 carriers with subjective cognitive decline",
|
| 38 |
+
"source": "World-Wide FINGERS PENSA trial, PMC11714784, 2025",
|
| 39 |
+
"url": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC11714784/",
|
| 40 |
+
"summary": "PENSA enrolled APOE4 carriers experiencing subjective cognitive decline in a 12-month personalized program of diet, physical activity, and cognitive and social stimulation. Adherence was notably high across components (around 90% for nutrition guidance, over 70% for cognitive training and psychoeducation), and the trial used continuous ecological momentary assessment via mobile tools, illustrating that intensive, personalized multidomain coaching is feasible to sustain in an at-risk genetic population over a full year."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "apoe4_lifestyle_adi_summary",
|
| 44 |
+
"domain": "apoe4_genetics",
|
| 45 |
+
"title": "Healthy lifestyle changes and cognition in APOE4 carriers (FINGER subgroup analysis)",
|
| 46 |
+
"source": "Solomon et al., JAMA Neurology, 2018; ADDF summary",
|
| 47 |
+
"url": "https://www.alzdiscovery.org/cognitive-vitality/blog/healthy-lifestyle-changes-may-benefit-cognition-in-older-people-with-apoe4",
|
| 48 |
+
"summary": "A subgroup analysis of the original FINGER trial focusing on the roughly 360 participants carrying at least one APOE4 allele found that the multidomain lifestyle program improved cognitive function and memory in this genetically at-risk group, similar to or better than the benefit seen in non-carriers. This was an early and influential signal that genetic risk for Alzheimer's is not fixed destiny -- modifiable lifestyle factors still meaningfully shift outcomes."
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": "exercise_glymphatic_review",
|
| 52 |
+
"domain": "exercise_cognitive_reserve",
|
| 53 |
+
"title": "Physical activity, neuroplasticity, and glymphatic clearance in aging",
|
| 54 |
+
"source": "Narrative review, MDPI, 2025",
|
| 55 |
+
"url": "https://www.mdpi.com/2308-3417/10/6/143",
|
| 56 |
+
"summary": "This review synthesizes evidence that regular physical activity improves executive function, memory, and processing speed in older adults, including those with mild cognitive impairment or APOE4 genetic risk. Mechanistically, exercise is associated with increased BDNF, IGF-1, and irisin signaling that support neuroplasticity, along with improved glymphatic clearance -- the brain's waste-clearance system implicated in amyloid and tau removal -- and reduced neuroinflammation. The authors note physical activity sits within a broader multidomain framework alongside diet, sleep, and social engagement rather than acting as a standalone protective factor."
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"id": "lifestyle_executive_function_memory",
|
| 60 |
+
"domain": "exercise_cognitive_reserve",
|
| 61 |
+
"title": "Cognitive reserve, physical activity, diet, and sleep in healthy older adults",
|
| 62 |
+
"source": "Aging Clinical and Experimental Research, Springer, 2026",
|
| 63 |
+
"url": "https://link.springer.com/article/10.1007/s40520-026-03362-y",
|
| 64 |
+
"summary": "In a cross-sectional study of 204 cognitively healthy older adults, researchers examined how cognitive reserve, lifelong physical activity, dietary habits, and sleep duration related independently to executive function and long-term memory, controlling for smoking, alcohol use, and chronic conditions. The work reinforces that these four modifiable factors each contribute distinct, only partially overlapping protective associations with different cognitive domains, supporting a multidomain rather than single-factor approach to brain health maintenance."
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"id": "mind_diet_rct_nejm",
|
| 68 |
+
"domain": "diet_nutrition",
|
| 69 |
+
"title": "Randomized controlled trial of the MIND diet for cognitive decline prevention",
|
| 70 |
+
"source": "New England Journal of Medicine, 2023",
|
| 71 |
+
"url": "https://www.nejm.org/doi/full/10.1056/NEJMoa2302368",
|
| 72 |
+
"summary": "This two-site RCT randomized older adults with a family history of dementia, elevated BMI, and a suboptimal baseline diet to either the MIND diet (a hybrid of the Mediterranean and DASH diets emphasizing leafy greens, berries, nuts, olive oil, and whole grains) or a calorie-matched control diet for three years, with cognition assessed via a 12-test battery. It is among the more rigorous diet-cognition trials to date, and findings have been interpreted cautiously by the field -- later commentary (see SMARRT/FINGER caution entry) notes that while plausible, diet-only effects on cognition have been less consistent across studies than combined multidomain interventions."
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"id": "lifestyle_caution_nature",
|
| 76 |
+
"domain": "diet_nutrition",
|
| 77 |
+
"title": "Can diet and exercise really prevent Alzheimer's? A field-wide caution",
|
| 78 |
+
"source": "Nature News Feature, 2025",
|
| 79 |
+
"url": "https://www.nature.com/articles/d41586-025-02925-9",
|
| 80 |
+
"summary": "This field overview is useful precisely because it tempers enthusiasm: it notes that while trials like FINGER and SMARRT (a two-year RCT treating modifiable risk factors such as uncontrolled hypertension) show encouraging signals, most positive results come from people with mild cognitive decline rather than established dementia, where intervention effects are far weaker. The MIND diet specifically is flagged as showing initially promising but not consistently replicated effects across subsequent studies, and researchers caution against overstating any single intervention's individual contribution outside a combined, monitored program."
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "lifestyle_asia_west_review",
|
| 84 |
+
"domain": "diet_nutrition",
|
| 85 |
+
"title": "Diet and physical activity for dementia risk reduction: Western and Asian approaches compared",
|
| 86 |
+
"source": "ScienceDirect review, 2025",
|
| 87 |
+
"url": "https://www.sciencedirect.com/science/article/pii/S2274580724006204",
|
| 88 |
+
"summary": "This comparative review finds consistent evidence that aerobic, resistance, balance, and dual-task exercise enhance neurogenesis and cerebral blood flow and are linked to delayed cognitive decline across cultural contexts, while dietary approaches differ by region -- structured plans like MIND/Mediterranean diets in Western literature versus traditional mind-body practices (e.g., Tai Chi) and antioxidant- and polyphenol-rich diets in Asian literature. The authors argue that culturally tailored delivery of the same underlying principles (movement plus nutrient-dense, anti-inflammatory eating) likely matters more for adherence than which specific named diet is used."
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"id": "nun_study_idea_density",
|
| 92 |
+
"domain": "cognitive_reserve_early_life",
|
| 93 |
+
"title": "Linguistic ability in early life and Alzheimer's disease in late life: the Nun Study",
|
| 94 |
+
"source": "Snowdon, Kemper, et al., JAMA, 1996; 30-year retrospective, Alzheimer's & Dementia, 2025",
|
| 95 |
+
"url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC11852352/",
|
| 96 |
+
"summary": "In one of the most cited findings in cognitive aging research, autobiographies written by Catholic nuns in their early twenties were scored decades later for 'idea density' -- the number of distinct ideas expressed per ten words. Low idea density in young adulthood was strongly associated with both clinical dementia and confirmed Alzheimer's neuropathology assessed via brain autopsy roughly 60 years later, with researchers reporting they could predict eventual Alzheimer's pathology with notably high accuracy from these early writings alone. Subsequent analysis found this association held even among nuns who did not carry the APOE4 risk allele, suggesting idea density captures something about early-life cognitive and neurological development distinct from genetic risk -- interpreted by the field as evidence that early intellectual engagement contributes to 'cognitive reserve.'"
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"id": "nun_study_exercise_overview",
|
| 100 |
+
"domain": "cognitive_reserve_early_life",
|
| 101 |
+
"title": "The Nun Study: lifestyle and longitudinal findings overview",
|
| 102 |
+
"source": "Nun Study retrospective summaries, 2025-2026",
|
| 103 |
+
"url": "https://en.wikipedia.org/wiki/Nun_Study",
|
| 104 |
+
"summary": "Beyond linguistic ability, the Nun Study's homogeneous-lifestyle cohort (678 sisters with similar diets, routines, and no smoking/alcohol confounds) allowed researchers to isolate other protective associations, including that habitual daily exercise was inversely associated with developing Alzheimer's, and that this benefit appeared even among participants who only began exercising later in life -- suggesting it is not solely a marker of pre-existing health but a modifiable protective behavior at any age."
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"id": "semantic_fluency_neurodegeneration",
|
| 108 |
+
"domain": "cognitive_training",
|
| 109 |
+
"title": "Semantic fluency decline tracks early Alzheimer's-related brain changes",
|
| 110 |
+
"source": "PMC7403823, longitudinal cohort study (n=2,261)",
|
| 111 |
+
"url": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7403823/",
|
| 112 |
+
"summary": "Tracking over 2,000 non-demented older adults over several years, this study found that semantic (category) fluency declined fastest specifically in those at elevated Alzheimer's risk -- APOE4 carriers and those who later progressed to MCI or dementia -- while phonemic (letter) fluency stayed comparatively stable except very close to dementia onset. Lower semantic fluency was further linked to faster cortical thinning and reduced metabolic activity in temporal and parietal regions associated with Alzheimer's specifically, distinguishing it from general age-related decline."
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"id": "verbal_fluency_mci_screening",
|
| 116 |
+
"domain": "cognitive_training",
|
| 117 |
+
"title": "Verbal fluency as a screening tool for mild cognitive impairment",
|
| 118 |
+
"source": "PMC9153280, McDonnell et al.",
|
| 119 |
+
"url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC9153280/",
|
| 120 |
+
"summary": "In a sample of 332 participants spanning healthy aging, amnestic MCI, and Alzheimer's disease, both semantic (animal) and phonemic (letter) fluency significantly predicted diagnostic group, but semantic fluency carried substantially more discriminating power. The authors conclude semantic fluency testing is a practically efficient screening signal in time-limited settings, useful for flagging people who may benefit from more thorough follow-up assessment -- though explicitly not a stand-alone diagnostic tool."
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"id": "social_engagement_fingers_component",
|
| 124 |
+
"domain": "social_engagement",
|
| 125 |
+
"title": "Social activity as a structural component of multidomain dementia-prevention trials",
|
| 126 |
+
"source": "Synthesized from FINGER/WW-FINGERS trial designs, 2025",
|
| 127 |
+
"url": "https://www.alz.org/wwfingers",
|
| 128 |
+
"summary": "Across FINGER and its global WW-FINGERS adaptations, structured social activity is included as one of the five core intervention domains alongside diet, exercise, cognitive training, and vascular risk management -- not as an afterthought but as a load-bearing component shown to contribute to the roughly 25% greater cognitive improvement seen in intervention versus control groups. Trial designers note social engagement components also improve program adherence and retention, which in turn supports better outcomes across the other domains."
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"id": "digital_tools_social_engagement",
|
| 132 |
+
"domain": "social_engagement",
|
| 133 |
+
"title": "Digital delivery of social and cognitive engagement in dementia prevention",
|
| 134 |
+
"source": "PMC12762639, WW-FINGERS digital tools review, 2025",
|
| 135 |
+
"url": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12762639/",
|
| 136 |
+
"summary": "A review of digital tool adoption across 19 published WW-FINGERS protocols found social engagement components delivered via web platforms and apps (not just in-person) enhanced usability and participation, with the Maintain Your Brain (Australia) trial demonstrating that a fully internet-based multidomain lifestyle program -- including social and cognitive engagement elements -- can still produce measurable cognitive and health benefits, broadening access beyond clinic-based delivery."
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"id": "verbal_fluency_qualitative_clusters",
|
| 140 |
+
"domain": "cognitive_training",
|
| 141 |
+
"title": "Qualitative verbal fluency patterns differentiate subjective decline from MCI",
|
| 142 |
+
"source": "PMC12738191, Cherkasov et al., 2025",
|
| 143 |
+
"url": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12738191/",
|
| 144 |
+
"summary": "Comparing 40 patients with subjective cognitive decline to 51 with diagnosed MCI, this study found that beyond raw fluency word counts, qualitative clustering patterns -- how words group into semantic categories and how often a speaker switches between them -- added meaningful discriminative information beyond the simple count, with significant differences in total fluency score and several cluster-pattern measures between the two groups. This supports analyzing the structure of a fluency response, not just the tally, which NeuroLens's clustering-adjacent metrics (lexical diversity, idea density) aim to approximate in a simplified form."
|
| 145 |
+
}
|
| 146 |
+
]
|
rag_engine.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
rag_engine.py — Stage 3 of NeuroLens: Grounded, Personalized Prevention Coaching
|
| 3 |
+
|
| 4 |
+
Retrieves relevant literature from a curated corpus (data/corpus.json) based on
|
| 5 |
+
which linguistic biomarkers from Stage 2 stood out, then generates a citation-
|
| 6 |
+
grounded coaching summary via Groq's inference API. Includes a RAGAS-style
|
| 7 |
+
faithfulness check on the generated text against the retrieved sources.
|
| 8 |
+
|
| 9 |
+
Embedding backend:
|
| 10 |
+
- Primary (production / HuggingFace Spaces): sentence-transformers
|
| 11 |
+
'intfloat/e5-large-v2' + FAISS, for high-quality semantic retrieval.
|
| 12 |
+
- Fallback (offline / restricted environments, e.g. sandboxed dev): scikit-learn
|
| 13 |
+
TF-IDF + cosine similarity. Automatically used if sentence-transformers or
|
| 14 |
+
its model weights aren't available, so the pipeline still runs end-to-end
|
| 15 |
+
without internet access to the Hugging Face Hub.
|
| 16 |
+
|
| 17 |
+
This keeps app.py agnostic to which backend is active.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
CORPUS_PATH = Path(__file__).parent / "data" / "corpus.json"
|
| 27 |
+
|
| 28 |
+
# Maps a Stage 2 biomarker "band" finding to the literature domains most
|
| 29 |
+
# relevant to addressing it. Used to bias retrieval toward useful content
|
| 30 |
+
# rather than retrieving purely by surface similarity to assessment text.
|
| 31 |
+
MARKER_TO_DOMAINS = {
|
| 32 |
+
"semantic_fluency": ["cognitive_training", "social_engagement"],
|
| 33 |
+
"phonemic_fluency": ["exercise_cognitive_reserve", "diet_nutrition"],
|
| 34 |
+
"lexical_diversity": ["cognitive_reserve_early_life", "cognitive_training"],
|
| 35 |
+
"idea_density": ["cognitive_reserve_early_life", "social_engagement"],
|
| 36 |
+
"syntactic_complexity": ["exercise_cognitive_reserve", "diet_nutrition"],
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def load_corpus() -> list:
|
| 41 |
+
with open(CORPUS_PATH, "r") as f:
|
| 42 |
+
return json.load(f)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class LiteratureRetriever:
|
| 46 |
+
"""Embedding-backed retriever over the curated corpus, with automatic
|
| 47 |
+
fallback to TF-IDF if a neural embedding backend isn't available."""
|
| 48 |
+
|
| 49 |
+
def __init__(self):
|
| 50 |
+
self.corpus = load_corpus()
|
| 51 |
+
self.texts = [f"{d['title']}. {d['summary']}" for d in self.corpus]
|
| 52 |
+
self.backend = None
|
| 53 |
+
self._init_backend()
|
| 54 |
+
|
| 55 |
+
def _init_backend(self):
|
| 56 |
+
# Try neural embeddings first (production path)
|
| 57 |
+
try:
|
| 58 |
+
from sentence_transformers import SentenceTransformer
|
| 59 |
+
model = SentenceTransformer("intfloat/e5-large-v2")
|
| 60 |
+
# e5 models expect "passage: " / "query: " prefixes for best results
|
| 61 |
+
passage_texts = [f"passage: {t}" for t in self.texts]
|
| 62 |
+
embeddings = model.encode(passage_texts, normalize_embeddings=True)
|
| 63 |
+
self._st_model = model
|
| 64 |
+
self._embeddings = np.array(embeddings, dtype="float32")
|
| 65 |
+
self.backend = "sentence-transformers"
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
import faiss
|
| 69 |
+
index = faiss.IndexFlatIP(self._embeddings.shape[1])
|
| 70 |
+
index.add(self._embeddings)
|
| 71 |
+
self._faiss_index = index
|
| 72 |
+
self.backend = "sentence-transformers+faiss"
|
| 73 |
+
except ImportError:
|
| 74 |
+
self._faiss_index = None
|
| 75 |
+
return
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
# Fallback: TF-IDF (always available, no network/model download required)
|
| 80 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 81 |
+
self._vectorizer = TfidfVectorizer(stop_words="english")
|
| 82 |
+
self._tfidf_matrix = self._vectorizer.fit_transform(self.texts)
|
| 83 |
+
self.backend = "tfidf_fallback"
|
| 84 |
+
|
| 85 |
+
def _retrieve_indices(self, query: str, k: int, candidate_idx=None):
|
| 86 |
+
if self.backend.startswith("sentence-transformers"):
|
| 87 |
+
q_emb = self._st_model.encode([f"query: {query}"], normalize_embeddings=True)
|
| 88 |
+
q_emb = np.array(q_emb, dtype="float32")
|
| 89 |
+
if candidate_idx is not None and len(candidate_idx) > 0:
|
| 90 |
+
sub_embeddings = self._embeddings[candidate_idx]
|
| 91 |
+
sims = sub_embeddings @ q_emb[0]
|
| 92 |
+
order = np.argsort(-sims)[:k]
|
| 93 |
+
return [candidate_idx[i] for i in order]
|
| 94 |
+
else:
|
| 95 |
+
sims = self._embeddings @ q_emb[0]
|
| 96 |
+
return list(np.argsort(-sims)[:k])
|
| 97 |
+
else:
|
| 98 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 99 |
+
q_vec = self._vectorizer.transform([query])
|
| 100 |
+
if candidate_idx is not None and len(candidate_idx) > 0:
|
| 101 |
+
sims = cosine_similarity(q_vec, self._tfidf_matrix[candidate_idx]).flatten()
|
| 102 |
+
order = np.argsort(-sims)[:k]
|
| 103 |
+
return [candidate_idx[i] for i in order]
|
| 104 |
+
else:
|
| 105 |
+
sims = cosine_similarity(q_vec, self._tfidf_matrix).flatten()
|
| 106 |
+
return list(np.argsort(-sims)[:k])
|
| 107 |
+
|
| 108 |
+
def retrieve_for_domains(self, query: str, domains: list, k: int = 2) -> list:
|
| 109 |
+
candidate_idx = [i for i, d in enumerate(self.corpus) if d["domain"] in domains]
|
| 110 |
+
idx = self._retrieve_indices(query, k, candidate_idx=candidate_idx if candidate_idx else None)
|
| 111 |
+
return [self.corpus[i] for i in idx]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def select_retrieval_plan(bands: dict) -> dict:
|
| 115 |
+
"""
|
| 116 |
+
Given Stage 2's `bands` dict (marker -> 'below_typical_range' / 'within_typical_range'
|
| 117 |
+
/ 'above_typical_range'), decide which markers are worth addressing and which
|
| 118 |
+
literature domains to pull from for each.
|
| 119 |
+
"""
|
| 120 |
+
plan = {}
|
| 121 |
+
for marker, band in bands.items():
|
| 122 |
+
if band == "below_typical_range" and marker in MARKER_TO_DOMAINS:
|
| 123 |
+
plan[marker] = MARKER_TO_DOMAINS[marker]
|
| 124 |
+
return plan
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def build_retrieved_context(retriever: LiteratureRetriever, profile: dict, k_per_marker: int = 2) -> dict:
|
| 128 |
+
"""Returns {marker: [retrieved doc dicts]} for every marker that needs addressing."""
|
| 129 |
+
plan = select_retrieval_plan(profile["bands"])
|
| 130 |
+
retrieved = {}
|
| 131 |
+
queries = {
|
| 132 |
+
"semantic_fluency": "interventions to improve semantic memory and category fluency",
|
| 133 |
+
"phonemic_fluency": "exercise and vascular health for executive function and word retrieval",
|
| 134 |
+
"lexical_diversity": "building cognitive reserve through lifelong learning and engagement",
|
| 135 |
+
"idea_density": "early and ongoing intellectual engagement and cognitive reserve",
|
| 136 |
+
"syntactic_complexity": "physical activity and diet supporting language processing and cognitive load",
|
| 137 |
+
}
|
| 138 |
+
for marker, domains in plan.items():
|
| 139 |
+
docs = retriever.retrieve_for_domains(queries.get(marker, marker), domains, k=k_per_marker)
|
| 140 |
+
retrieved[marker] = docs
|
| 141 |
+
return retrieved
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------------------------------------------------------------------------
|
| 145 |
+
# Generation — uses Groq's inference API running an open-weight model
|
| 146 |
+
# (openai/gpt-oss-120b). See README.md for API key setup.
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
COACHING_SYSTEM_PROMPT = """You are a brain-health research communicator writing for NeuroLens, \
|
| 149 |
+
a non-clinical research demonstration prototype. You write supportive, non-alarmist, \
|
| 150 |
+
evidence-grounded coaching summaries for users based on their performance on simplified \
|
| 151 |
+
cognitive-linguistic tasks.
|
| 152 |
+
|
| 153 |
+
Strict rules:
|
| 154 |
+
- Only state claims that are directly supported by the provided source excerpts. Do not \
|
| 155 |
+
add outside knowledge or invent statistics.
|
| 156 |
+
- Every factual claim about research findings must be attributable to one of the provided \
|
| 157 |
+
sources; refer to sources naturally (e.g., "Research from the FINGER trial suggests...").
|
| 158 |
+
- Never use clinical or diagnostic language (no "you may have," "this indicates early signs of," \
|
| 159 |
+
"this is a symptom of"). Frame everything as "your results on this task" and "research on this topic," \
|
| 160 |
+
never as a statement about the user's health status.
|
| 161 |
+
- Tone: warm, encouraging, plain-language. No alarmism.
|
| 162 |
+
- End with one sentence reminding the user this is a research prototype, not a clinical assessment.
|
| 163 |
+
- Keep the full response under 250 words.
|
| 164 |
+
- Respond with plain prose only — no markdown headers, no preamble like "Here is your summary."
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
GROQ_MODEL = "openai/gpt-oss-120b" # see README.md for API key setup at console.groq.com
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def generate_coaching(profile: dict, retrieved_context: dict, client=None, model: str = GROQ_MODEL) -> dict:
|
| 171 |
+
"""
|
| 172 |
+
Calls Groq's API to generate a grounded coaching summary.
|
| 173 |
+
`client` should be an instantiated groq.Groq() client; if None, one is
|
| 174 |
+
created from the GROQ_API_KEY environment variable (see README.md for setup).
|
| 175 |
+
Returns {"text": str, "sources_used": [...]}
|
| 176 |
+
"""
|
| 177 |
+
from groq import Groq
|
| 178 |
+
|
| 179 |
+
if client is None:
|
| 180 |
+
client = Groq()
|
| 181 |
+
|
| 182 |
+
if not retrieved_context:
|
| 183 |
+
return {
|
| 184 |
+
"text": ("Your results across these short tasks fell within the typical ranges used for "
|
| 185 |
+
"this demo, so there's no specific area to highlight right now. Maintaining a mix "
|
| 186 |
+
"of physical activity, social engagement, varied learning, sleep, and a nutrient-dense "
|
| 187 |
+
"diet is broadly supported by the brain-health prevention literature regardless. "
|
| 188 |
+
"Reminder: NeuroLens is a research prototype, not a clinical assessment."),
|
| 189 |
+
"sources_used": [],
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
context_blocks = []
|
| 193 |
+
all_sources = []
|
| 194 |
+
for marker, docs in retrieved_context.items():
|
| 195 |
+
for d in docs:
|
| 196 |
+
context_blocks.append(
|
| 197 |
+
f"[Source: {d['title']} — {d['source']}]\n{d['summary']}"
|
| 198 |
+
)
|
| 199 |
+
all_sources.append(d)
|
| 200 |
+
|
| 201 |
+
context_text = "\n\n".join(context_blocks)
|
| 202 |
+
markers_text = ", ".join(retrieved_context.keys())
|
| 203 |
+
|
| 204 |
+
user_prompt = f"""The user's results on these tasks were below the typical comparison range: {markers_text}.
|
| 205 |
+
|
| 206 |
+
Here are the relevant source excerpts to ground your response (use ONLY these):
|
| 207 |
+
|
| 208 |
+
{context_text}
|
| 209 |
+
|
| 210 |
+
Write the personalized coaching summary now."""
|
| 211 |
+
|
| 212 |
+
response = client.chat.completions.create(
|
| 213 |
+
model=model,
|
| 214 |
+
max_tokens=1000,
|
| 215 |
+
reasoning_effort="low",
|
| 216 |
+
messages=[
|
| 217 |
+
{"role": "system", "content": COACHING_SYSTEM_PROMPT},
|
| 218 |
+
{"role": "user", "content": user_prompt},
|
| 219 |
+
],
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
text = (response.choices[0].message.content or "").strip()
|
| 223 |
+
if not text:
|
| 224 |
+
raise RuntimeError(
|
| 225 |
+
"Groq returned empty content for coaching generation (model spent its token "
|
| 226 |
+
"budget on internal reasoning). Try increasing max_tokens further."
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# de-duplicate sources by id
|
| 230 |
+
seen_ids = set()
|
| 231 |
+
unique_sources = []
|
| 232 |
+
for s in all_sources:
|
| 233 |
+
if s["id"] not in seen_ids:
|
| 234 |
+
seen_ids.add(s["id"])
|
| 235 |
+
unique_sources.append(s)
|
| 236 |
+
|
| 237 |
+
return {"text": text, "sources_used": unique_sources}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
# Faithfulness evaluation (RAGAS-style) — also via Groq.
|
| 242 |
+
# ---------------------------------------------------------------------------
|
| 243 |
+
FAITHFULNESS_SYSTEM_PROMPT = """You are a strict fact-checker. You will be given a generated \
|
| 244 |
+
coaching text and a set of source excerpts it was supposed to be grounded in.
|
| 245 |
+
|
| 246 |
+
Break the generated text into individual factual claims about research findings. For each \
|
| 247 |
+
claim, determine whether it is directly supported by the source excerpts (SUPPORTED), \
|
| 248 |
+
partially supported / overstated relative to the sources (PARTIAL), or not supported / \
|
| 249 |
+
hallucinated (UNSUPPORTED).
|
| 250 |
+
|
| 251 |
+
Respond ONLY with valid JSON in this exact format — no other text, no markdown code fences, \
|
| 252 |
+
no explanation before or after the JSON:
|
| 253 |
+
{
|
| 254 |
+
"claims": [
|
| 255 |
+
{"claim": "...", "verdict": "SUPPORTED|PARTIAL|UNSUPPORTED", "reason": "..."}
|
| 256 |
+
],
|
| 257 |
+
"faithfulness_score": <float between 0 and 1, fraction of claims that are SUPPORTED>
|
| 258 |
+
}
|
| 259 |
+
Keep each "reason" to one short sentence. Do not show your reasoning process — output the
|
| 260 |
+
JSON object directly.
|
| 261 |
+
"""
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def check_faithfulness(generated_text: str, sources_used: list, client=None, model: str = GROQ_MODEL) -> dict:
|
| 265 |
+
from groq import Groq
|
| 266 |
+
import json as _json
|
| 267 |
+
|
| 268 |
+
if client is None:
|
| 269 |
+
client = Groq()
|
| 270 |
+
|
| 271 |
+
if not sources_used:
|
| 272 |
+
return {"claims": [], "faithfulness_score": 1.0, "note": "No sources retrieved; generic fallback text used."}
|
| 273 |
+
|
| 274 |
+
source_text = "\n\n".join(f"[{s['title']}]\n{s['summary']}" for s in sources_used)
|
| 275 |
+
|
| 276 |
+
user_prompt = f"""GENERATED TEXT:
|
| 277 |
+
{generated_text}
|
| 278 |
+
|
| 279 |
+
SOURCE EXCERPTS:
|
| 280 |
+
{source_text}
|
| 281 |
+
|
| 282 |
+
Evaluate faithfulness now. Respond with JSON only."""
|
| 283 |
+
|
| 284 |
+
response = client.chat.completions.create(
|
| 285 |
+
model=model,
|
| 286 |
+
max_tokens=2000,
|
| 287 |
+
reasoning_effort="low",
|
| 288 |
+
messages=[
|
| 289 |
+
{"role": "system", "content": FAITHFULNESS_SYSTEM_PROMPT},
|
| 290 |
+
{"role": "user", "content": user_prompt},
|
| 291 |
+
],
|
| 292 |
+
# Note: response_format={"type": "json_object"} is intentionally omitted —
|
| 293 |
+
# it has been unreliable with this model (see Groq community reports of
|
| 294 |
+
# json_validate_failed / structured outputs being ignored on gpt-oss-120b).
|
| 295 |
+
# Plain prompt-based JSON instructions + the parsing below are more robust.
|
| 296 |
+
)
|
| 297 |
+
raw = (response.choices[0].message.content or "").strip()
|
| 298 |
+
if not raw:
|
| 299 |
+
return {"claims": [], "faithfulness_score": None,
|
| 300 |
+
"note": "Groq returned empty content (model spent its token budget on internal "
|
| 301 |
+
"reasoning). Try increasing max_tokens further."}
|
| 302 |
+
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 303 |
+
try:
|
| 304 |
+
return _json.loads(raw)
|
| 305 |
+
except _json.JSONDecodeError:
|
| 306 |
+
return {"claims": [], "faithfulness_score": None, "note": "Could not parse evaluator output", "raw": raw}
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
if __name__ == "__main__":
|
| 310 |
+
retriever = LiteratureRetriever()
|
| 311 |
+
print(f"Backend: {retriever.backend}, corpus size: {len(retriever.corpus)}")
|
| 312 |
+
|
| 313 |
+
fake_bands = {
|
| 314 |
+
"semantic_fluency": "below_typical_range",
|
| 315 |
+
"phonemic_fluency": "within_typical_range",
|
| 316 |
+
"lexical_diversity": "within_typical_range",
|
| 317 |
+
"idea_density": "below_typical_range",
|
| 318 |
+
"syntactic_complexity": "within_typical_range",
|
| 319 |
+
}
|
| 320 |
+
fake_profile = {"bands": fake_bands}
|
| 321 |
+
context = build_retrieved_context(retriever, fake_profile)
|
| 322 |
+
for marker, docs in context.items():
|
| 323 |
+
print(f"\n--- {marker} ---")
|
| 324 |
+
for d in docs:
|
| 325 |
+
print(" ", d["title"])
|
requirements.txt
CHANGED
|
@@ -1,3 +1,10 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.38
|
| 2 |
+
streamlit-autorefresh>=1.0
|
| 3 |
+
groq>=0.13
|
| 4 |
+
spacy>=3.7
|
| 5 |
+
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.7.1/en_core_web_sm-3.7.1-py3-none-any.whl
|
| 6 |
+
nltk>=3.9
|
| 7 |
+
scikit-learn>=1.5
|
| 8 |
+
numpy>=1.26
|
| 9 |
+
sentence-transformers>=3.0
|
| 10 |
+
faiss-cpu>=1.8
|