MukulRay commited on
Commit
fadd796
Β·
1 Parent(s): 81deeef

Add curated corpus integration scaffold

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.pdf binary
.gitignore CHANGED
@@ -61,7 +61,13 @@ service-account*.json
61
 
62
  # Data artifacts
63
  data/processed/
 
 
 
 
 
64
  eval/ragas_results.json
 
65
 
66
  # Session artifacts
67
  CHANGES_APPLIED.md
 
61
 
62
  # Data artifacts
63
  data/processed/
64
+ data/curated/resources_seed.jsonl
65
+ data/curated/source_inventory.csv
66
+ data/curated/excluded_sources.csv
67
+ data/curated/raw_pages/
68
+ data/curated/indexes/
69
  eval/ragas_results.json
70
+ eval/curated_retrieval_audit.json
71
 
72
  # Session artifacts
73
  CHANGES_APPLIED.md
data/curated/resources_seed.example.jsonl ADDED
@@ -0,0 +1 @@
 
 
1
+ {"id":"umd_counseling_services_example_001","source_id":"src_example_001","source_name":"UMD Counseling Center","source_type":"university_resource","title":"Counseling Services","url":"https://counseling.umd.edu/","topic":"counseling_services","audience":"umd_student","risk_level":"safe","usage_mode":"retrieval","text":"The University of Maryland Counseling Center provides support services for students dealing with emotional, academic, social, or personal concerns. Students can use counseling resources to talk through stress, relationship concerns, adjustment difficulties, anxiety, depression, and other challenges that may affect wellbeing or academic life. The Counseling Center can also help students understand what type of support may fit their situation and connect them with appropriate campus or community resources.","summary":"Overview of UMD Counseling Center support services for student wellbeing.","last_checked":"2026-04-27","notes":"Example row only. Replace with Karthik's reviewed corpus before building the curated index."}
demo/app.py CHANGED
@@ -12,6 +12,7 @@ import uuid
12
  import datetime
13
  import os
14
  import threading
 
15
  from pipeline.pipeline import EmpathRAGPipeline
16
 
17
  # Constants
@@ -26,10 +27,15 @@ LABEL_COLORS = {
26
  LOG_PATH = "eval/human_eval_log.jsonl"
27
  LOG_TURNS = os.getenv("EMPATHRAG_LOG_TURNS") == "1"
28
  SHARE_DEMO = os.getenv("EMPATHRAG_SHARE") == "1"
 
29
 
30
  # Initialize pipeline (runs once at module load)
31
  print("[Demo] Initialising EmpathRAG pipeline...")
32
- pipeline = EmpathRAGPipeline(use_real_guardrail=True, guardrail_threshold=0.5)
 
 
 
 
33
  pipeline_lock = threading.Lock()
34
  print("[Demo] Pipeline ready.")
35
 
@@ -127,10 +133,51 @@ def format_ig_panel(is_crisis, confidence, ig_tokens, loading) -> str:
127
  return html
128
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  def respond(message, chat_history, session_state):
131
  """
132
  Generator function - yields UI state after each update.
133
- Yields tuple of 5 values: (chatbot, timeline_html, trajectory, crisis_html, session_id)
 
134
  """
135
  if not session_state:
136
  session_state = new_session_state()
@@ -144,6 +191,7 @@ def respond(message, chat_history, session_state):
144
  format_emotion_timeline(emotion_history, pipeline.tracker.trajectory()),
145
  pipeline.tracker.trajectory(),
146
  format_ig_panel(False, 0.0, [], False),
 
147
  session_id,
148
  session_state)
149
  return
@@ -189,6 +237,7 @@ def respond(message, chat_history, session_state):
189
  timeline_html,
190
  result["trajectory"],
191
  format_ig_panel(True, result["crisis_confidence"], [], loading=True),
 
192
  session_id,
193
  session_state)
194
 
@@ -201,6 +250,7 @@ def respond(message, chat_history, session_state):
201
  timeline_html,
202
  result["trajectory"],
203
  format_ig_panel(True, confidence, ig_tokens, loading=False),
 
204
  session_id,
205
  session_state)
206
  else:
@@ -209,6 +259,7 @@ def respond(message, chat_history, session_state):
209
  timeline_html,
210
  result["trajectory"],
211
  format_ig_panel(False, 0.0, [], False),
 
212
  session_id,
213
  session_state)
214
 
@@ -219,8 +270,9 @@ def reset_session_handler():
219
 
220
  placeholder_timeline = "<div style='color:#888;font-size:13px;padding:8px;'>No emotions detected yet.</div>"
221
  placeholder_crisis = "<div style='color:#888;font-size:13px;padding:8px;'>No crisis detected this session.</div>"
 
222
 
223
- return ([], placeholder_timeline, "stable", placeholder_crisis, session_state["session_id"], session_state)
224
 
225
 
226
  # Gradio UI
@@ -259,12 +311,14 @@ with gr.Blocks(theme=gr.themes.Soft(), title="EmpathRAG Demo") as demo:
259
 
260
  gr.Markdown("### Safety Guardrail")
261
  crisis_out = gr.HTML(value="<div style='color:#888;font-size:13px;padding:8px;'>No crisis detected this session.</div>")
 
 
262
 
263
  # Wire up interactions
264
  msg_box.submit(
265
  respond,
266
  inputs=[msg_box, chatbot, session_state],
267
- outputs=[chatbot, timeline_out, trajectory_out, crisis_out, session_id_box, session_state]
268
  ).then(
269
  lambda: "",
270
  outputs=msg_box
@@ -273,7 +327,7 @@ with gr.Blocks(theme=gr.themes.Soft(), title="EmpathRAG Demo") as demo:
273
  send_btn.click(
274
  respond,
275
  inputs=[msg_box, chatbot, session_state],
276
- outputs=[chatbot, timeline_out, trajectory_out, crisis_out, session_id_box, session_state]
277
  ).then(
278
  lambda: "",
279
  outputs=msg_box
@@ -281,7 +335,7 @@ with gr.Blocks(theme=gr.themes.Soft(), title="EmpathRAG Demo") as demo:
281
 
282
  reset_btn.click(
283
  reset_session_handler,
284
- outputs=[chatbot, timeline_out, trajectory_out, crisis_out, session_id_box, session_state]
285
  )
286
 
287
 
 
12
  import datetime
13
  import os
14
  import threading
15
+ from html import escape
16
  from pipeline.pipeline import EmpathRAGPipeline
17
 
18
  # Constants
 
27
  LOG_PATH = "eval/human_eval_log.jsonl"
28
  LOG_TURNS = os.getenv("EMPATHRAG_LOG_TURNS") == "1"
29
  SHARE_DEMO = os.getenv("EMPATHRAG_SHARE") == "1"
30
+ RETRIEVAL_CORPUS = os.getenv("EMPATHRAG_RETRIEVAL_CORPUS", "auto")
31
 
32
  # Initialize pipeline (runs once at module load)
33
  print("[Demo] Initialising EmpathRAG pipeline...")
34
+ pipeline = EmpathRAGPipeline(
35
+ use_real_guardrail=True,
36
+ guardrail_threshold=0.5,
37
+ retrieval_corpus=RETRIEVAL_CORPUS,
38
+ )
39
  pipeline_lock = threading.Lock()
40
  print("[Demo] Pipeline ready.")
41
 
 
133
  return html
134
 
135
 
136
+ def format_retrieval_panel(result=None) -> str:
137
+ """Format retrieval corpus and source metadata for the demo side panel."""
138
+ if not result:
139
+ return "<div style='color:#888;font-size:13px;padding:8px;'>No retrieval yet.</div>"
140
+
141
+ safety_level = escape(str(result.get("safety_level", "unknown")))
142
+ safety_reason = escape(str(result.get("safety_reason", "")))
143
+ corpus = escape(str(result.get("retrieval_corpus", "unknown")))
144
+ html = (
145
+ "<div style='font-size:12px;line-height:1.35;'>"
146
+ f"<div><strong>Corpus:</strong> {corpus}</div>"
147
+ f"<div><strong>Safety:</strong> {safety_level}</div>"
148
+ f"<div><strong>Reason:</strong> {safety_reason}</div>"
149
+ )
150
+
151
+ sources = result.get("retrieved_sources", [])
152
+ if not sources:
153
+ html += "<div style='color:#888;margin-top:8px;'>No sources retrieved.</div></div>"
154
+ return html
155
+
156
+ html += "<div style='margin-top:10px;font-weight:600;'>Top Sources</div>"
157
+ for source in sources[:3]:
158
+ title = escape(str(source.get("title", "") or "Untitled source"))
159
+ source_name = escape(str(source.get("source_name", "") or "Unknown source"))
160
+ topic = escape(str(source.get("topic", "") or ""))
161
+ risk = escape(str(source.get("risk_level", "") or ""))
162
+ url = escape(str(source.get("url", "") or ""))
163
+ html += (
164
+ "<div style='border-top:1px solid #ddd;padding-top:6px;margin-top:6px;'>"
165
+ f"<div><strong>{title}</strong></div>"
166
+ f"<div>{source_name}</div>"
167
+ f"<div style='color:#666;'>topic={topic} Β· risk={risk}</div>"
168
+ )
169
+ if url:
170
+ html += f"<div><a href='{url}' target='_blank'>source link</a></div>"
171
+ html += "</div>"
172
+ html += "</div>"
173
+ return html
174
+
175
+
176
  def respond(message, chat_history, session_state):
177
  """
178
  Generator function - yields UI state after each update.
179
+ Yields chatbot, emotion timeline, trajectory, safety panel, retrieval panel,
180
+ session ID, and per-user session state.
181
  """
182
  if not session_state:
183
  session_state = new_session_state()
 
191
  format_emotion_timeline(emotion_history, pipeline.tracker.trajectory()),
192
  pipeline.tracker.trajectory(),
193
  format_ig_panel(False, 0.0, [], False),
194
+ format_retrieval_panel(),
195
  session_id,
196
  session_state)
197
  return
 
237
  timeline_html,
238
  result["trajectory"],
239
  format_ig_panel(True, result["crisis_confidence"], [], loading=True),
240
+ format_retrieval_panel(result),
241
  session_id,
242
  session_state)
243
 
 
250
  timeline_html,
251
  result["trajectory"],
252
  format_ig_panel(True, confidence, ig_tokens, loading=False),
253
+ format_retrieval_panel(result),
254
  session_id,
255
  session_state)
256
  else:
 
259
  timeline_html,
260
  result["trajectory"],
261
  format_ig_panel(False, 0.0, [], False),
262
+ format_retrieval_panel(result),
263
  session_id,
264
  session_state)
265
 
 
270
 
271
  placeholder_timeline = "<div style='color:#888;font-size:13px;padding:8px;'>No emotions detected yet.</div>"
272
  placeholder_crisis = "<div style='color:#888;font-size:13px;padding:8px;'>No crisis detected this session.</div>"
273
+ placeholder_retrieval = "<div style='color:#888;font-size:13px;padding:8px;'>No retrieval yet.</div>"
274
 
275
+ return ([], placeholder_timeline, "stable", placeholder_crisis, placeholder_retrieval, session_state["session_id"], session_state)
276
 
277
 
278
  # Gradio UI
 
311
 
312
  gr.Markdown("### Safety Guardrail")
313
  crisis_out = gr.HTML(value="<div style='color:#888;font-size:13px;padding:8px;'>No crisis detected this session.</div>")
314
+ gr.Markdown("### Retrieval Sources")
315
+ retrieval_out = gr.HTML(value="<div style='color:#888;font-size:13px;padding:8px;'>No retrieval yet.</div>")
316
 
317
  # Wire up interactions
318
  msg_box.submit(
319
  respond,
320
  inputs=[msg_box, chatbot, session_state],
321
+ outputs=[chatbot, timeline_out, trajectory_out, crisis_out, retrieval_out, session_id_box, session_state]
322
  ).then(
323
  lambda: "",
324
  outputs=msg_box
 
327
  send_btn.click(
328
  respond,
329
  inputs=[msg_box, chatbot, session_state],
330
+ outputs=[chatbot, timeline_out, trajectory_out, crisis_out, retrieval_out, session_id_box, session_state]
331
  ).then(
332
  lambda: "",
333
  outputs=msg_box
 
335
 
336
  reset_btn.click(
337
  reset_session_handler,
338
+ outputs=[chatbot, timeline_out, trajectory_out, crisis_out, retrieval_out, session_id_box, session_state]
339
  )
340
 
341
 
docs/KARTHIK_CORPUS_INTEGRATION_STEPS.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Karthik Corpus Integration Steps
2
+
3
+ Use this after Karthik sends the curated corpus delivery.
4
+
5
+ ## Expected Files
6
+
7
+ Place the files here:
8
+
9
+ ```text
10
+ data/curated/resources_seed.jsonl
11
+ data/curated/source_inventory.csv
12
+ data/curated/excluded_sources.csv
13
+ data/curated/raw_pages/
14
+ ```
15
+
16
+ The only required file for indexing is `resources_seed.jsonl`. The others are
17
+ for review, reproducibility, and the class/research writeup.
18
+
19
+ ## Validate
20
+
21
+ ```bash
22
+ python -m src.data.curated_resources data/curated/resources_seed.jsonl
23
+ ```
24
+
25
+ If validation fails, fix the reported line numbers before indexing.
26
+
27
+ ## Build Curated Index
28
+
29
+ ```bash
30
+ python -m src.data.build_curated_index
31
+ ```
32
+
33
+ This creates:
34
+
35
+ ```text
36
+ data/curated/indexes/faiss_curated.index
37
+ data/curated/indexes/metadata_curated.db
38
+ ```
39
+
40
+ These artifacts are intentionally separate from the existing Reddit research
41
+ index. Do not delete or overwrite `data/indexes/faiss_flat.index` or
42
+ `data/indexes/metadata.db`.
43
+
44
+ ## Run Retrieval Audit
45
+
46
+ ```bash
47
+ python eval/run_curated_retrieval_audit.py
48
+ ```
49
+
50
+ Review `eval/curated_retrieval_audit.json` and confirm top sources are safe,
51
+ official, and relevant.
52
+
53
+ ## Run Demo
54
+
55
+ Default demo behavior uses `auto` retrieval mode:
56
+
57
+ - If curated index exists, use `curated_support`.
58
+ - If curated index is missing, fall back to `reddit_research`.
59
+
60
+ ```bash
61
+ python demo/app.py
62
+ ```
63
+
64
+ To force a mode:
65
+
66
+ ```bash
67
+ set EMPATHRAG_RETRIEVAL_CORPUS=curated_support
68
+ python demo/app.py
69
+ ```
70
+
71
+ or:
72
+
73
+ ```bash
74
+ set EMPATHRAG_RETRIEVAL_CORPUS=reddit_research
75
+ python demo/app.py
76
+ ```
77
+
78
+ ## Presentation Safety Note
79
+
80
+ For the MSML demo, describe Reddit as the original research corpus and curated
81
+ resources as the safer V2 student-support corpus. The curated corpus is the
82
+ preferred path for any UMD counseling conversation or future publication work.
docs/TEAMMATE_CURATED_CORPUS_HANDOFF.md ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EmpathRAG V2 Curated Corpus Handoff
2
+
3
+ This document is the teammate handoff for building the first curated support
4
+ corpus for EmpathRAG V2. The goal is to produce clean, source-cited,
5
+ student-support content that can be directly ingested into a separate curated
6
+ FAISS + SQLite retrieval index.
7
+
8
+ ## 1. Objective
9
+
10
+ EmpathRAG currently has a large Reddit-based retrieval corpus. That corpus is
11
+ useful for research comparison and ablation, but it should not be the primary
12
+ student-facing support source for a mental-health-adjacent demo.
13
+
14
+ Your task is to create a safer curated corpus from official and reputable public
15
+ resources. This corpus should help EmpathRAG retrieve grounding context for:
16
+
17
+ - anxiety before exams, thesis defense, or presentations
18
+ - advisor conflict and academic frustration
19
+ - burnout, isolation, loneliness, and imposter feelings
20
+ - depression/help-seeking language
21
+ - campus counseling navigation
22
+ - after-hours and crisis support
23
+ - disability/accessibility support
24
+ - graduate student stress, funding stress, and academic pressure
25
+ - grounding exercises and help-seeking scripts
26
+
27
+ Prioritize correctness, source quality, and safety over volume.
28
+
29
+ ## 2. Deliverables
30
+
31
+ Please deliver a folder like this:
32
+
33
+ ```text
34
+ curated_corpus_delivery/
35
+ README_corpus_notes.md
36
+ source_inventory.csv
37
+ excluded_sources.csv
38
+ resources_seed.jsonl
39
+ raw_pages/
40
+ src_001.txt
41
+ src_002.txt
42
+ ```
43
+
44
+ Required files:
45
+
46
+ - `source_inventory.csv`: every source/page reviewed.
47
+ - `resources_seed.jsonl`: final clean chunks for ingestion.
48
+ - `README_corpus_notes.md`: summary of what was collected and any concerns.
49
+
50
+ Optional but helpful:
51
+
52
+ - `raw_pages/`: saved raw text snapshots from pages.
53
+ - `excluded_sources.csv`: pages reviewed but rejected.
54
+
55
+ ## 3. Source Priority
56
+
57
+ Use official/public sources first.
58
+
59
+ Priority order:
60
+
61
+ 1. UMD Counseling Center
62
+ 2. UMD after-hours or crisis support pages
63
+ 3. UMD Graduate School resources
64
+ 4. UMD Accessibility and Disability Service
65
+ 5. UMD Ombuds, conflict-resolution, or student support resources
66
+ 6. 988 Lifeline
67
+ 7. NIMH
68
+ 8. SAMHSA
69
+ 9. CDC mental health or suicide prevention resources
70
+ 10. Other reputable nonprofit/clinical education resources only if they fill a
71
+ real coverage gap
72
+
73
+ Do not use:
74
+
75
+ - Reddit, Quora, forums, or social media
76
+ - random blogs
77
+ - commercial therapy marketing pages
78
+ - personal stories with graphic crisis details
79
+ - pages that describe self-harm methods
80
+ - content that gives diagnosis, treatment, or medication instructions
81
+ - login-gated or private pages
82
+
83
+ ## 4. Source Inventory Format
84
+
85
+ Create `source_inventory.csv`.
86
+
87
+ Required columns:
88
+
89
+ ```csv
90
+ source_id,source_name,source_type,title,url,domain,date_accessed,include_status,reason,license_or_terms_notes
91
+ ```
92
+
93
+ Allowed `include_status` values:
94
+
95
+ ```text
96
+ include
97
+ partial
98
+ exclude
99
+ needs_review
100
+ ```
101
+
102
+ Example:
103
+
104
+ ```csv
105
+ src_001,UMD Counseling Center,university_resource,Counseling Services,https://counseling.umd.edu/,counseling.umd.edu,2026-04-27,include,Official student counseling resource,Public webpage
106
+ ```
107
+
108
+ Use `needs_review` when the page seems useful but contains sensitive, ambiguous,
109
+ or policy-heavy content that should be checked before inclusion.
110
+
111
+ ## 5. Main Corpus Format
112
+
113
+ Create `resources_seed.jsonl`.
114
+
115
+ Use JSONL format: one valid JSON object per line. Do not wrap the file in a JSON
116
+ array.
117
+
118
+ Required schema:
119
+
120
+ ```json
121
+ {
122
+ "id": "umd_counseling_001",
123
+ "source_id": "src_001",
124
+ "source_name": "UMD Counseling Center",
125
+ "source_type": "university_resource",
126
+ "title": "Counseling Services",
127
+ "url": "https://example.edu/page",
128
+ "topic": "counseling_services",
129
+ "audience": "umd_student",
130
+ "risk_level": "safe",
131
+ "usage_mode": "retrieval",
132
+ "text": "Clean paragraph-sized text chunk suitable for retrieval.",
133
+ "summary": "One sentence summary of the chunk.",
134
+ "last_checked": "2026-04-27",
135
+ "notes": "Why this chunk is useful."
136
+ }
137
+ ```
138
+
139
+ Every field is required. Every `id` must be unique.
140
+
141
+ ## 6. Allowed Field Values
142
+
143
+ ### `source_type`
144
+
145
+ Use one of:
146
+
147
+ ```text
148
+ university_resource
149
+ crisis_resource
150
+ government_public_health
151
+ student_support
152
+ clinician_review_candidate
153
+ ```
154
+
155
+ ### `topic`
156
+
157
+ Use one primary topic per chunk:
158
+
159
+ ```text
160
+ crisis_immediate_help
161
+ counseling_services
162
+ after_hours_support
163
+ academic_burnout
164
+ advisor_conflict
165
+ isolation_loneliness
166
+ anxiety_stress
167
+ depression_support
168
+ accessibility_disability
169
+ graduate_student_support
170
+ help_seeking_script
171
+ grounding_exercise
172
+ campus_navigation
173
+ therapy_expectations
174
+ peer_support
175
+ emergency_services
176
+ ```
177
+
178
+ If a chunk fits multiple topics, choose the most specific one.
179
+
180
+ ### `audience`
181
+
182
+ Use one of:
183
+
184
+ ```text
185
+ umd_student
186
+ graduate_student
187
+ student_general
188
+ crisis_support
189
+ supporter_or_friend
190
+ ```
191
+
192
+ ### `risk_level`
193
+
194
+ Use one of:
195
+
196
+ ```text
197
+ safe
198
+ wellbeing
199
+ crisis_resource
200
+ exclude
201
+ ```
202
+
203
+ Meanings:
204
+
205
+ - `safe`: normal support/resource text.
206
+ - `wellbeing`: distress/help-seeking content, but not crisis.
207
+ - `crisis_resource`: crisis resource or urgent-support content.
208
+ - `exclude`: do not put this in the final retrieval corpus.
209
+
210
+ Prefer putting excluded content in `excluded_sources.csv` instead of
211
+ `resources_seed.jsonl`.
212
+
213
+ ### `usage_mode`
214
+
215
+ Use one of:
216
+
217
+ ```text
218
+ retrieval
219
+ wellbeing_only
220
+ crisis_only
221
+ metadata_only
222
+ ```
223
+
224
+ Meanings:
225
+
226
+ - `retrieval`: safe for normal RAG retrieval.
227
+ - `wellbeing_only`: use only when the user is distressed/help-seeking.
228
+ - `crisis_only`: use only when triage detects crisis or emergency.
229
+ - `metadata_only`: useful as source/contact metadata, not generation context.
230
+
231
+ Examples:
232
+
233
+ - UMD counseling overview: `retrieval`
234
+ - 988 immediate crisis guidance: `crisis_only`
235
+ - grounding exercise: `wellbeing_only`
236
+ - phone-number-only contact page: `metadata_only`
237
+
238
+ ## 7. Chunking Rules
239
+
240
+ Do not dump full webpages.
241
+
242
+ Each chunk should be:
243
+
244
+ - 80-250 words
245
+ - focused on one useful idea
246
+ - understandable without the full page
247
+ - source-cited with URL
248
+ - safe, factual, and student-appropriate
249
+
250
+ Remove:
251
+
252
+ - nav menus
253
+ - footers
254
+ - cookie banners
255
+ - sidebars
256
+ - repeated boilerplate
257
+ - irrelevant event calendars
258
+ - legal disclaimers with no support value
259
+ - raw HTML artifacts
260
+
261
+ If a useful page has 1,000 words, split it into 4-8 focused chunks.
262
+
263
+ ## 8. Safety Filtering
264
+
265
+ Do not include content that:
266
+
267
+ - describes self-harm methods in detail
268
+ - gives instructions for suicide or self-harm
269
+ - includes graphic personal crisis stories
270
+ - makes diagnosis claims
271
+ - claims to replace therapy, counseling, or emergency care
272
+ - gives medication instructions
273
+ - sounds judgmental, stigmatizing, or moralizing
274
+ - is outdated, unofficial, or unsupported
275
+
276
+ For crisis resources, include only safe action-oriented guidance:
277
+
278
+ - call or text 988
279
+ - contact emergency services
280
+ - contact campus crisis or after-hours support
281
+ - seek immediate support
282
+ - stay with another person
283
+ - ask someone nearby for help
284
+
285
+ ## 9. Recommended Corpus Size
286
+
287
+ First usable version:
288
+
289
+ ```text
290
+ Minimum: 80 chunks
291
+ Good target: 150-250 chunks
292
+ Excellent first pass: 300-500 chunks
293
+ ```
294
+
295
+ Suggested distribution:
296
+
297
+ ```text
298
+ UMD-specific resources: 40-80 chunks
299
+ Crisis resources: 20-40 chunks
300
+ Government/public health: 40-80 chunks
301
+ Graduate/student academic support: 40-80 chunks
302
+ Grounding/help-seeking snippets: 20-60 chunks
303
+ ```
304
+
305
+ A clean 150-chunk corpus is better than a noisy 1,000-chunk scrape.
306
+
307
+ ## 10. Collection and Scraping Method
308
+
309
+ Preferred workflow:
310
+
311
+ 1. Manually collect official source URLs.
312
+ 2. Record every reviewed URL in `source_inventory.csv`.
313
+ 3. Use simple scraping only for public pages.
314
+ 4. Save raw extracted text in `raw_pages/` when possible.
315
+ 5. Manually clean and chunk the useful text.
316
+ 6. Annotate every chunk with topic, audience, risk level, and usage mode.
317
+ 7. Validate JSONL before handoff.
318
+
319
+ Recommended Python tools:
320
+
321
+ ```text
322
+ requests
323
+ beautifulsoup4
324
+ trafilatura
325
+ pandas
326
+ ```
327
+
328
+ Basic scraping example:
329
+
330
+ ```python
331
+ import requests
332
+ from bs4 import BeautifulSoup
333
+
334
+ url = "https://example.edu/page"
335
+ html = requests.get(url, timeout=20).text
336
+ soup = BeautifulSoup(html, "html.parser")
337
+
338
+ for tag in soup(["script", "style", "nav", "footer", "header"]):
339
+ tag.decompose()
340
+
341
+ text = soup.get_text("\n")
342
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
343
+ clean_text = "\n".join(lines)
344
+ ```
345
+
346
+ Do not trust scraper output directly. Every final chunk should be manually
347
+ reviewed.
348
+
349
+ Respect:
350
+
351
+ - public pages only
352
+ - no login-gated content
353
+ - no private student information
354
+ - no heavy request volume
355
+ - robots/terms restrictions when applicable
356
+
357
+ ## 11. Manual Annotation Process
358
+
359
+ For each chunk, answer:
360
+
361
+ 1. What student problem does this help with?
362
+ 2. Is this UMD-specific, general student support, or crisis support?
363
+ 3. Is it safe for normal retrieval?
364
+ 4. Should it appear only in wellbeing/crisis contexts?
365
+ 5. Does it contain actionable resource/navigation information?
366
+ 6. Is the URL official and useful?
367
+
368
+ Then fill:
369
+
370
+ ```json
371
+ "topic": "...",
372
+ "audience": "...",
373
+ "risk_level": "...",
374
+ "usage_mode": "..."
375
+ ```
376
+
377
+ LLMs may help draft labels, but a human should approve every final row.
378
+
379
+ ## 12. Compatibility With EmpathRAG
380
+
381
+ EmpathRAG currently uses a FAISS index plus SQLite metadata.
382
+
383
+ The current v1 SQLite metadata roughly contains:
384
+
385
+ ```text
386
+ id
387
+ text
388
+ emotion_label
389
+ safety_score
390
+ source
391
+ ```
392
+
393
+ For v2, we will build a separate curated index from `resources_seed.jsonl`.
394
+
395
+ Field mapping:
396
+
397
+ ```text
398
+ id -> metadata ID
399
+ text -> embedded retrieval chunk
400
+ source_name -> source display name
401
+ url -> citation URL
402
+ title -> citation title
403
+ topic -> retrieval/evaluation filter
404
+ risk_level -> safety filter
405
+ usage_mode -> routing filter
406
+ summary -> compact display/eval text
407
+ ```
408
+
409
+ Do not build FAISS unless asked. Deliver clean structured data; integration will
410
+ happen in EmpathRAG.
411
+
412
+ ## 13. Current Reddit Corpus Policy
413
+
414
+ Do not mix Reddit into the curated corpus.
415
+
416
+ Current Reddit corpus role:
417
+
418
+ ```text
419
+ research baseline
420
+ ablation comparison
421
+ emotion/retrieval experiment
422
+ not primary student-facing support source
423
+ ```
424
+
425
+ New curated corpus role:
426
+
427
+ ```text
428
+ safer student-support retrieval
429
+ MSML class demo
430
+ future UMD counseling stakeholder discussion
431
+ publication-oriented system improvement
432
+ ```
433
+
434
+ If you find useful Reddit-like examples, save them separately only if needed for
435
+ future research evaluation. Do not include them in `resources_seed.jsonl`.
436
+
437
+ ## 14. Quality Checklist
438
+
439
+ Before handoff, verify:
440
+
441
+ - [ ] Every JSONL line is valid JSON.
442
+ - [ ] Every row has a unique `id`.
443
+ - [ ] Every row has a matching `source_id` in `source_inventory.csv`.
444
+ - [ ] Every row has a working `url`.
445
+ - [ ] Every row has `source_name`.
446
+ - [ ] Every row has one allowed `source_type`.
447
+ - [ ] Every row has one allowed `topic`.
448
+ - [ ] Every row has one allowed `audience`.
449
+ - [ ] Every row has one allowed `risk_level`.
450
+ - [ ] Every row has one allowed `usage_mode`.
451
+ - [ ] Text chunks are usually 80-250 words.
452
+ - [ ] No nav/footer/cookie/sidebar junk remains.
453
+ - [ ] No Reddit/social media/random blog content appears.
454
+ - [ ] No graphic self-harm details appear.
455
+ - [ ] No diagnosis/treatment/medication instructions appear.
456
+ - [ ] Crisis resources are marked `crisis_resource` and `crisis_only`.
457
+ - [ ] UMD-specific resources are prioritized.
458
+ - [ ] `README_corpus_notes.md` is included.
459
+
460
+ ## 15. README Notes Template
461
+
462
+ In `README_corpus_notes.md`, include:
463
+
464
+ ```text
465
+ Corpus creator:
466
+ Date:
467
+ Total sources reviewed:
468
+ Total chunks included:
469
+ Total chunks excluded:
470
+ Main source domains:
471
+ Known limitations:
472
+ Sources needing review:
473
+ Pages that were hard to scrape:
474
+ Content you were unsure about:
475
+ Suggested next sources:
476
+ ```
477
+
478
+ ## 16. Good Example Row
479
+
480
+ ```json
481
+ {
482
+ "id": "umd_counseling_services_001",
483
+ "source_id": "src_001",
484
+ "source_name": "UMD Counseling Center",
485
+ "source_type": "university_resource",
486
+ "title": "Counseling Services",
487
+ "url": "https://counseling.umd.edu/",
488
+ "topic": "counseling_services",
489
+ "audience": "umd_student",
490
+ "risk_level": "safe",
491
+ "usage_mode": "retrieval",
492
+ "text": "The University of Maryland Counseling Center provides support services for students dealing with emotional, academic, social, or personal concerns. Students can use counseling resources to talk through stress, relationship concerns, adjustment difficulties, anxiety, depression, and other challenges that may affect wellbeing or academic life. The Counseling Center can also help students understand what type of support may fit their situation and connect them with appropriate campus or community resources.",
493
+ "summary": "Overview of UMD Counseling Center support services for student wellbeing.",
494
+ "last_checked": "2026-04-27",
495
+ "notes": "Useful for routing students toward official campus counseling support."
496
+ }
497
+ ```
498
+
499
+ ## 17. Bad Example Row
500
+
501
+ Do not include:
502
+
503
+ ```json
504
+ {
505
+ "text": "I saw someone on Reddit say they fixed depression by ignoring everyone and taking random supplements..."
506
+ }
507
+ ```
508
+
509
+ Why this is bad:
510
+
511
+ - unofficial
512
+ - anecdotal
513
+ - potentially unsafe
514
+ - not student-resource grounded
515
+ - no source metadata
516
+ - not appropriate for mental-health support retrieval
517
+
518
+ ## 18. Validation Commands
519
+
520
+ If using Python, validate JSONL with:
521
+
522
+ ```python
523
+ import json
524
+ from pathlib import Path
525
+
526
+ path = Path("resources_seed.jsonl")
527
+ seen = set()
528
+
529
+ for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
530
+ row = json.loads(line)
531
+ assert row["id"] not in seen, f"duplicate id on line {line_no}"
532
+ seen.add(row["id"])
533
+ for field in [
534
+ "id", "source_id", "source_name", "source_type", "title", "url",
535
+ "topic", "audience", "risk_level", "usage_mode", "text",
536
+ "summary", "last_checked", "notes"
537
+ ]:
538
+ assert row.get(field), f"missing {field} on line {line_no}"
539
+
540
+ print(f"Valid rows: {len(seen)}")
541
+ ```
542
+
543
+ ## 19. Final Handoff Standard
544
+
545
+ The handoff is ready when:
546
+
547
+ - `resources_seed.jsonl` is valid JSONL.
548
+ - `source_inventory.csv` explains every source.
549
+ - UMD resources are clearly prioritized.
550
+ - Crisis resources are separated by `risk_level` and `usage_mode`.
551
+ - No unsafe or noisy web content is included.
552
+ - The data can be ingested without further scraping.
553
+
554
+ Once delivered, EmpathRAG can build:
555
+
556
+ - curated FAISS index
557
+ - curated SQLite metadata
558
+ - source-cited retrieval
559
+ - safer MSML class demo
560
+ - stronger research direction for publication
docs/TEAMMATE_CURATED_CORPUS_HANDOFF.pdf ADDED
Binary file (22.1 kB). View file
 
eval/run_curated_retrieval_audit.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audit curated-support retrieval without running the generator.
3
+
4
+ Run after Karthik's corpus has been validated and indexed:
5
+ python eval/run_curated_retrieval_audit.py
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, "src")
15
+
16
+ from pipeline.pipeline import EmpathRAGPipeline
17
+ from pipeline.query_router import route_query
18
+
19
+
20
+ PROMPTS = [
21
+ "I'm so anxious about my thesis defense next week, I can't sleep.",
22
+ "My advisor rejected my work again and I don't know what to do.",
23
+ "I feel isolated in my program and I don't have anyone to talk to.",
24
+ "I think I might need counseling but I'm not sure where to start.",
25
+ "I'm burned out and falling behind on everything.",
26
+ "Can you help me find support for disability accommodations?",
27
+ "I don't know who to contact after hours if things get worse.",
28
+ ]
29
+
30
+ RESULTS_PATH = Path("eval/curated_retrieval_audit.json")
31
+
32
+
33
+ def main() -> int:
34
+ pipeline = EmpathRAGPipeline(
35
+ retrieval_corpus="curated_support",
36
+ use_real_guardrail=True,
37
+ guardrail_threshold=0.5,
38
+ )
39
+
40
+ rows = []
41
+ for prompt in PROMPTS:
42
+ emotion = pipeline._classify_emotion(prompt)
43
+ pipeline.tracker.update(emotion, len(prompt.split()))
44
+ trajectory = pipeline.tracker.trajectory()
45
+ routed = route_query(prompt, emotion, trajectory)
46
+ retrieved = pipeline._retrieve(routed, emotion)
47
+ sources = pipeline._source_summaries(retrieved)
48
+ rows.append(
49
+ {
50
+ "prompt": prompt,
51
+ "emotion": emotion,
52
+ "trajectory": trajectory,
53
+ "routed_query": routed,
54
+ "sources": sources,
55
+ }
56
+ )
57
+
58
+ print("\nPROMPT:", prompt)
59
+ if not sources:
60
+ print(" NO SOURCES")
61
+ continue
62
+ for i, source in enumerate(sources[:3], 1):
63
+ print(
64
+ f" {i}. {source['source_name']} | {source['title']} | "
65
+ f"{source['topic']} | {source['risk_level']}"
66
+ )
67
+ if source["url"]:
68
+ print(f" {source['url']}")
69
+
70
+ RESULTS_PATH.write_text(json.dumps(rows, indent=2), encoding="utf-8")
71
+ print(f"\nSaved audit: {RESULTS_PATH}")
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ raise SystemExit(main())
src/data/build_curated_index.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build a separate FAISS + SQLite index for the curated EmpathRAG corpus.
3
+
4
+ Run from repo root:
5
+ python -m src.data.build_curated_index
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sqlite3
13
+ from pathlib import Path
14
+
15
+ import faiss
16
+ import numpy as np
17
+ from sentence_transformers import SentenceTransformer
18
+
19
+ from .curated_resources import ingestion_rows, validate_file
20
+
21
+
22
+ MODEL_NAME = "sentence-transformers/all-mpnet-base-v2"
23
+ DEFAULT_INPUT = "data/curated/resources_seed.jsonl"
24
+ DEFAULT_INDEX = "data/curated/indexes/faiss_curated.index"
25
+ DEFAULT_DB = "data/curated/indexes/metadata_curated.db"
26
+
27
+
28
+ def build_curated_index(
29
+ input_path: str = DEFAULT_INPUT,
30
+ index_path: str = DEFAULT_INDEX,
31
+ db_path: str = DEFAULT_DB,
32
+ model_name: str = MODEL_NAME,
33
+ ) -> None:
34
+ rows, _ = validate_file(input_path, strict=True)
35
+ usable = ingestion_rows(rows)
36
+ if not usable:
37
+ raise ValueError("No usable curated rows found after validation/filtering.")
38
+
39
+ texts = [row["text"] for row in usable]
40
+ print(f"Curated rows loaded: {len(rows)}")
41
+ print(f"Rows entering retrieval index: {len(usable)}")
42
+
43
+ encoder = SentenceTransformer(model_name)
44
+ embeddings = encoder.encode(
45
+ texts,
46
+ batch_size=64,
47
+ show_progress_bar=True,
48
+ normalize_embeddings=True,
49
+ )
50
+ embeddings = np.array(embeddings, dtype=np.float32)
51
+
52
+ dim = embeddings.shape[1]
53
+ index = faiss.IndexFlatL2(dim)
54
+ index.add(embeddings)
55
+
56
+ Path(index_path).parent.mkdir(parents=True, exist_ok=True)
57
+ faiss.write_index(index, index_path)
58
+
59
+ _write_metadata(db_path, usable)
60
+
61
+ print(f"Curated FAISS index saved: {index_path}")
62
+ print(f"Curated metadata DB saved: {db_path}")
63
+ print(f"Vectors indexed: {index.ntotal}")
64
+
65
+
66
+ def _write_metadata(db_path: str, rows: list[dict]) -> None:
67
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
68
+ if os.path.exists(db_path):
69
+ os.remove(db_path)
70
+
71
+ conn = sqlite3.connect(db_path)
72
+ c = conn.cursor()
73
+ c.execute(
74
+ """
75
+ CREATE TABLE chunks (
76
+ id INTEGER PRIMARY KEY,
77
+ resource_id TEXT UNIQUE NOT NULL,
78
+ text TEXT NOT NULL,
79
+ source_id TEXT NOT NULL,
80
+ source_name TEXT NOT NULL,
81
+ source_type TEXT NOT NULL,
82
+ title TEXT NOT NULL,
83
+ url TEXT NOT NULL,
84
+ topic TEXT NOT NULL,
85
+ audience TEXT NOT NULL,
86
+ risk_level TEXT NOT NULL,
87
+ usage_mode TEXT NOT NULL,
88
+ summary TEXT NOT NULL,
89
+ last_checked TEXT NOT NULL,
90
+ notes TEXT NOT NULL
91
+ )
92
+ """
93
+ )
94
+ c.execute("CREATE INDEX idx_chunks_topic ON chunks(topic)")
95
+ c.execute("CREATE INDEX idx_chunks_risk ON chunks(risk_level)")
96
+ c.execute("CREATE INDEX idx_chunks_usage ON chunks(usage_mode)")
97
+
98
+ for idx, row in enumerate(rows):
99
+ c.execute(
100
+ """
101
+ INSERT INTO chunks (
102
+ id, resource_id, text, source_id, source_name, source_type,
103
+ title, url, topic, audience, risk_level, usage_mode, summary,
104
+ last_checked, notes
105
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
106
+ """,
107
+ (
108
+ idx,
109
+ row["id"],
110
+ row["text"],
111
+ row["source_id"],
112
+ row["source_name"],
113
+ row["source_type"],
114
+ row["title"],
115
+ row["url"],
116
+ row["topic"],
117
+ row["audience"],
118
+ row["risk_level"],
119
+ row["usage_mode"],
120
+ row["summary"],
121
+ row["last_checked"],
122
+ row["notes"],
123
+ ),
124
+ )
125
+
126
+ conn.commit()
127
+ conn.close()
128
+
129
+
130
+ def main() -> int:
131
+ parser = argparse.ArgumentParser(description="Build curated EmpathRAG FAISS index.")
132
+ parser.add_argument("--input", default=DEFAULT_INPUT)
133
+ parser.add_argument("--index", default=DEFAULT_INDEX)
134
+ parser.add_argument("--db", default=DEFAULT_DB)
135
+ parser.add_argument("--model", default=MODEL_NAME)
136
+ args = parser.parse_args()
137
+
138
+ build_curated_index(
139
+ input_path=args.input,
140
+ index_path=args.index,
141
+ db_path=args.db,
142
+ model_name=args.model,
143
+ )
144
+ return 0
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
src/data/curated_resources.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utilities for EmpathRAG curated resource corpora.
3
+
4
+ The curated corpus is a JSONL file prepared from official/student-support
5
+ resources. It intentionally stays separate from the Reddit research corpus.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Iterable
15
+
16
+
17
+ REQUIRED_FIELDS = (
18
+ "id",
19
+ "source_id",
20
+ "source_name",
21
+ "source_type",
22
+ "title",
23
+ "url",
24
+ "topic",
25
+ "audience",
26
+ "risk_level",
27
+ "usage_mode",
28
+ "text",
29
+ "summary",
30
+ "last_checked",
31
+ "notes",
32
+ )
33
+
34
+ SOURCE_TYPES = {
35
+ "university_resource",
36
+ "crisis_resource",
37
+ "government_public_health",
38
+ "student_support",
39
+ "clinician_review_candidate",
40
+ }
41
+
42
+ TOPICS = {
43
+ "crisis_immediate_help",
44
+ "counseling_services",
45
+ "after_hours_support",
46
+ "academic_burnout",
47
+ "advisor_conflict",
48
+ "isolation_loneliness",
49
+ "anxiety_stress",
50
+ "depression_support",
51
+ "accessibility_disability",
52
+ "graduate_student_support",
53
+ "help_seeking_script",
54
+ "grounding_exercise",
55
+ "campus_navigation",
56
+ "therapy_expectations",
57
+ "peer_support",
58
+ "emergency_services",
59
+ }
60
+
61
+ AUDIENCES = {
62
+ "umd_student",
63
+ "graduate_student",
64
+ "student_general",
65
+ "crisis_support",
66
+ "supporter_or_friend",
67
+ }
68
+
69
+ RISK_LEVELS = {"safe", "wellbeing", "crisis_resource", "exclude"}
70
+ USAGE_MODES = {"retrieval", "wellbeing_only", "crisis_only", "metadata_only"}
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ValidationIssue:
75
+ line_no: int
76
+ row_id: str
77
+ message: str
78
+
79
+
80
+ def load_jsonl(path: str | Path) -> list[dict]:
81
+ rows = []
82
+ path = Path(path)
83
+ for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
84
+ if not line.strip():
85
+ continue
86
+ try:
87
+ row = json.loads(line)
88
+ except json.JSONDecodeError as exc:
89
+ raise ValueError(f"Invalid JSON on line {line_no}: {exc}") from exc
90
+ if not isinstance(row, dict):
91
+ raise ValueError(f"Line {line_no} must be a JSON object.")
92
+ row["_line_no"] = line_no
93
+ rows.append(row)
94
+ return rows
95
+
96
+
97
+ def validate_rows(rows: Iterable[dict]) -> list[ValidationIssue]:
98
+ issues: list[ValidationIssue] = []
99
+ seen_ids: set[str] = set()
100
+
101
+ for row in rows:
102
+ line_no = int(row.get("_line_no", 0))
103
+ row_id = str(row.get("id", "")).strip()
104
+
105
+ for field in REQUIRED_FIELDS:
106
+ if not str(row.get(field, "")).strip():
107
+ issues.append(ValidationIssue(line_no, row_id, f"missing field: {field}"))
108
+
109
+ if row_id in seen_ids:
110
+ issues.append(ValidationIssue(line_no, row_id, "duplicate id"))
111
+ if row_id:
112
+ seen_ids.add(row_id)
113
+
114
+ _check_allowed(issues, row, line_no, row_id, "source_type", SOURCE_TYPES)
115
+ _check_allowed(issues, row, line_no, row_id, "topic", TOPICS)
116
+ _check_allowed(issues, row, line_no, row_id, "audience", AUDIENCES)
117
+ _check_allowed(issues, row, line_no, row_id, "risk_level", RISK_LEVELS)
118
+ _check_allowed(issues, row, line_no, row_id, "usage_mode", USAGE_MODES)
119
+
120
+ text = str(row.get("text", "")).strip()
121
+ word_count = len(text.split())
122
+ if text and not (40 <= word_count <= 300):
123
+ issues.append(
124
+ ValidationIssue(
125
+ line_no,
126
+ row_id,
127
+ f"text length {word_count} words outside review band 40-300",
128
+ )
129
+ )
130
+ if row.get("risk_level") == "exclude" and row.get("usage_mode") != "metadata_only":
131
+ issues.append(
132
+ ValidationIssue(
133
+ line_no,
134
+ row_id,
135
+ "exclude rows must use usage_mode=metadata_only or be removed",
136
+ )
137
+ )
138
+
139
+ return issues
140
+
141
+
142
+ def ingestion_rows(rows: Iterable[dict]) -> list[dict]:
143
+ """Rows safe to embed into the curated retrieval index."""
144
+ usable = []
145
+ for row in rows:
146
+ if row.get("risk_level") == "exclude":
147
+ continue
148
+ if row.get("usage_mode") == "metadata_only":
149
+ continue
150
+ usable.append({k: v for k, v in row.items() if not k.startswith("_")})
151
+ return usable
152
+
153
+
154
+ def validate_file(path: str | Path, strict: bool = True) -> tuple[list[dict], list[ValidationIssue]]:
155
+ rows = load_jsonl(path)
156
+ issues = validate_rows(rows)
157
+ if strict and issues:
158
+ messages = "\n".join(
159
+ f"line {i.line_no} ({i.row_id or 'no id'}): {i.message}" for i in issues
160
+ )
161
+ raise ValueError(f"Curated corpus validation failed:\n{messages}")
162
+ return rows, issues
163
+
164
+
165
+ def _check_allowed(
166
+ issues: list[ValidationIssue],
167
+ row: dict,
168
+ line_no: int,
169
+ row_id: str,
170
+ field: str,
171
+ allowed: set[str],
172
+ ) -> None:
173
+ value = row.get(field)
174
+ if value and value not in allowed:
175
+ issues.append(
176
+ ValidationIssue(
177
+ line_no,
178
+ row_id,
179
+ f"{field}={value!r} is not one of {sorted(allowed)}",
180
+ )
181
+ )
182
+
183
+
184
+ def main() -> int:
185
+ parser = argparse.ArgumentParser(description="Validate EmpathRAG curated JSONL corpus.")
186
+ parser.add_argument("path", help="Path to resources_seed.jsonl")
187
+ parser.add_argument("--non-strict", action="store_true", help="Print issues but exit 0.")
188
+ args = parser.parse_args()
189
+
190
+ rows, issues = validate_file(args.path, strict=False)
191
+ usable = ingestion_rows(rows)
192
+ print(f"Rows: {len(rows)}")
193
+ print(f"Usable retrieval rows: {len(usable)}")
194
+
195
+ if issues:
196
+ print(f"Issues: {len(issues)}")
197
+ for issue in issues:
198
+ print(f"- line {issue.line_no} ({issue.row_id or 'no id'}): {issue.message}")
199
+ return 0 if args.non_strict else 1
200
+
201
+ print("Validation passed.")
202
+ return 0
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())
src/pipeline/pipeline.py CHANGED
@@ -20,6 +20,7 @@ VRAM sequencing on RTX 3060 6GB:
20
  import asyncio
21
  import sqlite3
22
  import time
 
23
  import torch
24
  import numpy as np
25
  import faiss
@@ -106,6 +107,9 @@ class EmpathRAGPipeline:
106
  guardrail_ckpt: str = "models/safety_guardrail",
107
  faiss_index_path:str = "data/indexes/faiss_flat.index",
108
  db_path: str = "data/indexes/metadata.db",
 
 
 
109
  mistral_path: str = "models/generator/mistral-7b-instruct-v0.2.Q4_K_M.gguf",
110
  st_model: str = "sentence-transformers/all-mpnet-base-v2",
111
  n_gpu_layers: int = 28,
@@ -118,7 +122,11 @@ class EmpathRAGPipeline:
118
  ):
119
  self.top_k = top_k
120
  self.guardrail_threshold = guardrail_threshold
121
- self.db_path = db_path
 
 
 
 
122
  self.safety_policy = SafetyTriagePolicy(
123
  support_threshold=guardrail_threshold
124
  )
@@ -170,7 +178,8 @@ class EmpathRAGPipeline:
170
  # Start on CPU β€” we move to GPU only during encode(), then back
171
 
172
  print("[EmpathRAG] Loading FAISS index...")
173
- self.faiss_index = faiss.read_index(faiss_index_path)
 
174
  print(f"[EmpathRAG] FAISS: {self.faiss_index.ntotal:,} vectors")
175
 
176
  print("[EmpathRAG] Loading Mistral 7B (GPU)...")
@@ -187,6 +196,20 @@ class EmpathRAGPipeline:
187
  self.conv_history = [] # list of {"role": "user"|"assistant", "content": str}
188
  print("[EmpathRAG] Pipeline initialised. Ready for inference.")
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  # ── Stage 1: Emotion classification ───────────────────────────────────────
191
 
192
  def _classify_emotion(self, text: str) -> int:
@@ -203,10 +226,10 @@ class EmpathRAGPipeline:
203
 
204
  # ── Stage 4: FAISS retrieval ───────────────────────────────────────────────
205
 
206
- def _retrieve(self, query: str, emotion_label: int) -> list[str]:
207
  """
208
  Encodes query on GPU, searches FAISS, filters via SQLite.
209
- Returns top_k chunk texts ranked by emotion match + safety score.
210
  GPU usage: ~440 MB during encode, freed before returning.
211
  """
212
  # Move encoder to GPU for this call only
@@ -229,6 +252,9 @@ class EmpathRAGPipeline:
229
  if not candidate_ids:
230
  return []
231
 
 
 
 
232
  # Fetch metadata from SQLite
233
  placeholders = ",".join("?" * len(candidate_ids))
234
  conn = sqlite3.connect(self.db_path)
@@ -246,7 +272,64 @@ class EmpathRAGPipeline:
246
  return match_bonus + safety
247
 
248
  rows_sorted = sorted(rows, key=_score, reverse=True)[: self.top_k]
249
- return [r[1] for r in rows_sorted]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
  # ── Stage 5: Generation ────────────────────────────────────────────────────
252
 
@@ -362,6 +445,8 @@ class EmpathRAGPipeline:
362
  "safety_reason": safety_decision.reason,
363
  "ig_highlights": ig_highlights,
364
  "retrieved_chunks": [],
 
 
365
  "latency_ms": latency,
366
  }
367
 
@@ -372,7 +457,8 @@ class EmpathRAGPipeline:
372
 
373
  # ── Stage 4: Retrieval ─────────────────────────────────────────────────
374
  t0 = time.perf_counter()
375
- chunks = self._retrieve(routed_query, emotion_label)
 
376
  latency["retrieval_ms"] = round((time.perf_counter() - t0) * 1000)
377
 
378
  # ── Stage 5: Generation ────────────────────────────────────────────────
@@ -398,9 +484,24 @@ class EmpathRAGPipeline:
398
  "safety_reason": safety_decision.reason,
399
  "ig_highlights": [],
400
  "retrieved_chunks": chunks,
 
 
401
  "latency_ms": latency,
402
  }
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  def reset_session(self):
405
  """Clear session emotion history and conversation history."""
406
  self.tracker.reset()
 
20
  import asyncio
21
  import sqlite3
22
  import time
23
+ from pathlib import Path
24
  import torch
25
  import numpy as np
26
  import faiss
 
107
  guardrail_ckpt: str = "models/safety_guardrail",
108
  faiss_index_path:str = "data/indexes/faiss_flat.index",
109
  db_path: str = "data/indexes/metadata.db",
110
+ retrieval_corpus: str = "reddit_research",
111
+ curated_index_path: str = "data/curated/indexes/faiss_curated.index",
112
+ curated_db_path: str = "data/curated/indexes/metadata_curated.db",
113
  mistral_path: str = "models/generator/mistral-7b-instruct-v0.2.Q4_K_M.gguf",
114
  st_model: str = "sentence-transformers/all-mpnet-base-v2",
115
  n_gpu_layers: int = 28,
 
122
  ):
123
  self.top_k = top_k
124
  self.guardrail_threshold = guardrail_threshold
125
+ self.retrieval_corpus = self._resolve_retrieval_corpus(
126
+ retrieval_corpus, curated_index_path, curated_db_path
127
+ )
128
+ self.faiss_index_path = curated_index_path if self.retrieval_corpus == "curated_support" else faiss_index_path
129
+ self.db_path = curated_db_path if self.retrieval_corpus == "curated_support" else db_path
130
  self.safety_policy = SafetyTriagePolicy(
131
  support_threshold=guardrail_threshold
132
  )
 
178
  # Start on CPU β€” we move to GPU only during encode(), then back
179
 
180
  print("[EmpathRAG] Loading FAISS index...")
181
+ self.faiss_index = faiss.read_index(self.faiss_index_path)
182
+ print(f"[EmpathRAG] Retrieval corpus: {self.retrieval_corpus}")
183
  print(f"[EmpathRAG] FAISS: {self.faiss_index.ntotal:,} vectors")
184
 
185
  print("[EmpathRAG] Loading Mistral 7B (GPU)...")
 
196
  self.conv_history = [] # list of {"role": "user"|"assistant", "content": str}
197
  print("[EmpathRAG] Pipeline initialised. Ready for inference.")
198
 
199
+ def _resolve_retrieval_corpus(
200
+ self,
201
+ retrieval_corpus: str,
202
+ curated_index_path: str,
203
+ curated_db_path: str,
204
+ ) -> str:
205
+ allowed = {"reddit_research", "curated_support", "auto"}
206
+ if retrieval_corpus not in allowed:
207
+ raise ValueError(f"retrieval_corpus must be one of {sorted(allowed)}")
208
+ if retrieval_corpus == "auto":
209
+ curated_ready = Path(curated_index_path).exists() and Path(curated_db_path).exists()
210
+ return "curated_support" if curated_ready else "reddit_research"
211
+ return retrieval_corpus
212
+
213
  # ── Stage 1: Emotion classification ───────────────────────────────────────
214
 
215
  def _classify_emotion(self, text: str) -> int:
 
226
 
227
  # ── Stage 4: FAISS retrieval ───────────────────────────────────────────────
228
 
229
+ def _retrieve(self, query: str, emotion_label: int) -> list[dict]:
230
  """
231
  Encodes query on GPU, searches FAISS, filters via SQLite.
232
+ Returns top_k chunk metadata dicts.
233
  GPU usage: ~440 MB during encode, freed before returning.
234
  """
235
  # Move encoder to GPU for this call only
 
252
  if not candidate_ids:
253
  return []
254
 
255
+ if self.retrieval_corpus == "curated_support":
256
+ return self._fetch_curated_rows(candidate_ids)
257
+
258
  # Fetch metadata from SQLite
259
  placeholders = ",".join("?" * len(candidate_ids))
260
  conn = sqlite3.connect(self.db_path)
 
272
  return match_bonus + safety
273
 
274
  rows_sorted = sorted(rows, key=_score, reverse=True)[: self.top_k]
275
+ return [
276
+ {
277
+ "id": r[0],
278
+ "text": r[1],
279
+ "emotion_label": r[2],
280
+ "safety_score": r[3],
281
+ "source_name": "Reddit Mental Health",
282
+ "source_type": "research_corpus",
283
+ "title": "Reddit Mental Health chunk",
284
+ "url": "",
285
+ "topic": "",
286
+ "risk_level": "research_only",
287
+ "usage_mode": "retrieval",
288
+ }
289
+ for r in rows_sorted
290
+ ]
291
+
292
+ def _fetch_curated_rows(self, candidate_ids: list[int]) -> list[dict]:
293
+ placeholders = ",".join("?" * len(candidate_ids))
294
+ conn = sqlite3.connect(self.db_path)
295
+ rows = conn.execute(
296
+ f"""
297
+ SELECT id, resource_id, text, source_id, source_name, source_type,
298
+ title, url, topic, audience, risk_level, usage_mode, summary,
299
+ last_checked, notes
300
+ FROM chunks
301
+ WHERE id IN ({placeholders})
302
+ """,
303
+ candidate_ids,
304
+ ).fetchall()
305
+ conn.close()
306
+
307
+ by_id = {row[0]: row for row in rows}
308
+ ordered = [by_id[i] for i in candidate_ids if i in by_id]
309
+ filtered = [
310
+ row for row in ordered
311
+ if row[10] != "exclude" and row[11] != "metadata_only"
312
+ ][: self.top_k]
313
+ return [
314
+ {
315
+ "id": row[0],
316
+ "resource_id": row[1],
317
+ "text": row[2],
318
+ "source_id": row[3],
319
+ "source_name": row[4],
320
+ "source_type": row[5],
321
+ "title": row[6],
322
+ "url": row[7],
323
+ "topic": row[8],
324
+ "audience": row[9],
325
+ "risk_level": row[10],
326
+ "usage_mode": row[11],
327
+ "summary": row[12],
328
+ "last_checked": row[13],
329
+ "notes": row[14],
330
+ }
331
+ for row in filtered
332
+ ]
333
 
334
  # ── Stage 5: Generation ────────────────────────────────────────────────────
335
 
 
445
  "safety_reason": safety_decision.reason,
446
  "ig_highlights": ig_highlights,
447
  "retrieved_chunks": [],
448
+ "retrieved_sources": [],
449
+ "retrieval_corpus": self.retrieval_corpus,
450
  "latency_ms": latency,
451
  }
452
 
 
457
 
458
  # ── Stage 4: Retrieval ─────────────────────────────────────────────────
459
  t0 = time.perf_counter()
460
+ retrieved = self._retrieve(routed_query, emotion_label)
461
+ chunks = [row["text"] for row in retrieved]
462
  latency["retrieval_ms"] = round((time.perf_counter() - t0) * 1000)
463
 
464
  # ── Stage 5: Generation ────────────────────────────────────────────────
 
484
  "safety_reason": safety_decision.reason,
485
  "ig_highlights": [],
486
  "retrieved_chunks": chunks,
487
+ "retrieved_sources": self._source_summaries(retrieved),
488
+ "retrieval_corpus": self.retrieval_corpus,
489
  "latency_ms": latency,
490
  }
491
 
492
+ def _source_summaries(self, retrieved: list[dict]) -> list[dict]:
493
+ return [
494
+ {
495
+ "title": row.get("title", ""),
496
+ "source_name": row.get("source_name", ""),
497
+ "url": row.get("url", ""),
498
+ "topic": row.get("topic", ""),
499
+ "risk_level": row.get("risk_level", ""),
500
+ "usage_mode": row.get("usage_mode", ""),
501
+ }
502
+ for row in retrieved
503
+ ]
504
+
505
  def reset_session(self):
506
  """Clear session emotion history and conversation history."""
507
  self.tracker.reset()