Kshamaa S commited on
Commit
8dccdbf
·
1 Parent(s): 5c81160

Initial deployment: NeuroLens cognitive health screening pipeline

Browse files
Files changed (6) hide show
  1. README.md +191 -15
  2. app.py +355 -0
  3. biomarkers.py +350 -0
  4. data/corpus.json +146 -0
  5. rag_engine.py +325 -0
  6. requirements.txt +10 -3
README.md CHANGED
@@ -1,20 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: NeuroLens
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Streamlit template space
12
- license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
- # Welcome to Streamlit!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
 
 
 
 
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
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
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