Add curated corpus integration scaffold
Browse files- .gitattributes +1 -0
- .gitignore +6 -0
- data/curated/resources_seed.example.jsonl +1 -0
- demo/app.py +60 -6
- docs/KARTHIK_CORPUS_INTEGRATION_STEPS.md +82 -0
- docs/TEAMMATE_CURATED_CORPUS_HANDOFF.md +560 -0
- docs/TEAMMATE_CURATED_CORPUS_HANDOFF.pdf +0 -0
- eval/run_curated_retrieval_audit.py +76 -0
- src/data/build_curated_index.py +148 -0
- src/data/curated_resources.py +206 -0
- src/pipeline/pipeline.py +107 -6
.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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[
|
| 207 |
"""
|
| 208 |
Encodes query on GPU, searches FAISS, filters via SQLite.
|
| 209 |
-
Returns top_k chunk
|
| 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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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()
|