Spaces:
Sleeping
Sleeping
Deploy rebuilt Aphaea with cited retrieval
#3
by TeahReichenbaum - opened
- .env.example +6 -0
- LICENSE +21 -0
- README.md +74 -5
- app.py +391 -199
- knowledge/sources.json +44 -0
- requirements.txt +7 -5
- tests/__pycache__/test_core.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/test_core.py +46 -0
.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Create a Hugging Face read token at https://huggingface.co/settings/tokens.
|
| 2 |
+
# Add it as a Hugging Face Space secret named HF_TOKEN in production.
|
| 3 |
+
HF_TOKEN=
|
| 4 |
+
|
| 5 |
+
# Optional model override.
|
| 6 |
+
HF_MODEL=google/gemma-2-2b-it
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Teah Reichenbaum
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,81 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.38.2
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Aphaea
|
| 3 |
+
emoji: 🌙
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.38.2
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Aphaea
|
| 14 |
+
|
| 15 |
+
Aphaea is a cycle-aware reproductive-health education assistant. It combines
|
| 16 |
+
semantic retrieval with an instruction-tuned language model to answer questions
|
| 17 |
+
using a small, traceable source library.
|
| 18 |
+
|
| 19 |
+
> Aphaea provides general education, not diagnosis or medical care. It does not
|
| 20 |
+
> replace a clinician, pharmacist, or emergency service.
|
| 21 |
+
|
| 22 |
+
## What changed in the rebuild
|
| 23 |
+
|
| 24 |
+
- Connected semantic retrieval to the generation pipeline.
|
| 25 |
+
- Added inline source labels and clickable source details.
|
| 26 |
+
- Replaced fixed phase claims with a transparent calendar-day estimate.
|
| 27 |
+
- Added input validation, configuration errors, and model-service fallbacks.
|
| 28 |
+
- Removed collection of names and exact ages.
|
| 29 |
+
- Rebuilt the interface for a clearer desktop and mobile experience.
|
| 30 |
+
- Added authoritative summaries from CDC, ACOG, the Office on Women's Health,
|
| 31 |
+
and a peer-reviewed exercise meta-analysis.
|
| 32 |
+
|
| 33 |
+
## Architecture
|
| 34 |
+
|
| 35 |
+
1. Source summaries are loaded from `knowledge/sources.json`.
|
| 36 |
+
2. `all-MiniLM-L6-v2` embeds each passage and the user's question.
|
| 37 |
+
3. Cosine similarity selects the four most relevant passages.
|
| 38 |
+
4. Gemma receives the question, optional session profile, and retrieved context.
|
| 39 |
+
5. The response must cite the supplied passages as `[S1]`, `[S2]`, and so on.
|
| 40 |
+
|
| 41 |
+
## Run locally
|
| 42 |
+
|
| 43 |
+
Python 3.10 or newer is recommended.
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
python3 -m venv .venv
|
| 47 |
+
source .venv/bin/activate
|
| 48 |
+
pip install -r requirements.txt
|
| 49 |
+
cp .env.example .env
|
| 50 |
+
python app.py
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
Add a Hugging Face read token to `.env` as `HF_TOKEN`. Never commit `.env`.
|
| 54 |
+
The interface and retrieval can be inspected without a token; generation will
|
| 55 |
+
show a configuration message until the secret is present.
|
| 56 |
+
|
| 57 |
+
## Hugging Face deployment
|
| 58 |
+
|
| 59 |
+
Create a Space secret named `HF_TOKEN`. The application reads it at runtime.
|
| 60 |
+
Do not place the token in `README.md`, `app.py`, or the Git history.
|
| 61 |
+
|
| 62 |
+
## Knowledge-source policy
|
| 63 |
+
|
| 64 |
+
Every active passage must include a stable identifier, title, public URL, review
|
| 65 |
+
date, and concise paraphrase. Prefer government health agencies, professional
|
| 66 |
+
medical bodies, and peer-reviewed systematic reviews. Re-review health passages
|
| 67 |
+
before major releases.
|
| 68 |
+
|
| 69 |
+
## Limitations
|
| 70 |
+
|
| 71 |
+
- Retrieval quality is not yet clinically evaluated.
|
| 72 |
+
- The current corpus is intentionally small.
|
| 73 |
+
- Calendar estimates cannot confirm ovulation or hormonal phase.
|
| 74 |
+
- Generated answers can still be incomplete or wrong and require evaluation.
|
| 75 |
+
|
| 76 |
+
## Roadmap
|
| 77 |
+
|
| 78 |
+
- Build a versioned evaluation set for retrieval and citation accuracy.
|
| 79 |
+
- Add automated tests for source coverage and safety behavior.
|
| 80 |
+
- Conduct accessibility and mobile-layout audits.
|
| 81 |
+
- Publish measured results before making accuracy claims.
|
app.py
CHANGED
|
@@ -1,221 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
|
|
|
|
|
|
| 2 |
from huggingface_hub import InferenceClient
|
| 3 |
-
import os
|
| 4 |
from sentence_transformers import SentenceTransformer
|
| 5 |
-
import torch
|
| 6 |
-
import numpy as np
|
| 7 |
-
from datetime import datetime, timedelta
|
| 8 |
-
|
| 9 |
-
# Load knowledge base
|
| 10 |
-
with open("knowledge.txt", "r", encoding="utf-8") as f:
|
| 11 |
-
knowledge_text = f.read()
|
| 12 |
-
chunks = [chunk.strip() for chunk in knowledge_text.split("\n\n") if chunk.strip()]
|
| 13 |
-
embedder = SentenceTransformer('all-MiniLM-L6-v2')
|
| 14 |
-
chunk_embeddings = embedder.encode(chunks, convert_to_tensor=True)
|
| 15 |
-
|
| 16 |
-
def get_relevant_context(query, top_k=3):
|
| 17 |
-
query_embedding = embedder.encode(query, convert_to_tensor=True)
|
| 18 |
-
query_embedding = query_embedding / query_embedding.norm()
|
| 19 |
-
norm_chunk_embeddings = chunk_embeddings / chunk_embeddings.norm(dim=1, keepdim=True)
|
| 20 |
-
similarities = torch.matmul(norm_chunk_embeddings, query_embedding)
|
| 21 |
-
top_k_indices = torch.topk(similarities, k=top_k).indices.cpu().numpy()
|
| 22 |
-
context = "\n\n".join([chunks[i]] for i in top_k_indices)
|
| 23 |
-
return context
|
| 24 |
-
|
| 25 |
-
client = InferenceClient("google/gemma-2-2b-it")
|
| 26 |
-
|
| 27 |
-
cycle_ai_prompt = """
|
| 28 |
-
Cycle-Aware Wellness AI Coach (Strict Enforcement Version)
|
| 29 |
-
==========================================================
|
| 30 |
-
Mission:
|
| 31 |
-
--------
|
| 32 |
-
You are a compassionate and knowledgeable wellness coach who specializes in fitness aligned with the menstrual cycle and evidence-based contraceptive education. Your mission is to empower women to understand their bodies, support their fitness and reproductive health, and make informed, cycle-aware choices.
|
| 33 |
-
You may *only* respond to questions related to:
|
| 34 |
-
- Menstrual cycles and hormonal phases
|
| 35 |
-
- Cycle-based fitness and wellness programming
|
| 36 |
-
- Contraceptive methods and reproductive health education
|
| 37 |
-
- Hormonal syndromes or life stages (PCOS, PMDD, menopause, irregular cycles, etc.)
|
| 38 |
-
If the user asks something unrelated (e.g., fixing a car, meal prep, unrelated illnesses, tech support), immediately respond with:
|
| 39 |
-
"I'm here to help with cycle-based fitness and contraceptive wellness. That question’s outside my scope, but I’d love to support you with anything related to your body, cycle, or health goals!"
|
| 40 |
-
You must *never* attempt to answer off-topic requests, even if the user insists or rephrases. Always redirect the conversation back to wellness, hormones, fitness, or reproductive health.
|
| 41 |
-
Style and Voice Guidelines:
|
| 42 |
-
---------------------------
|
| 43 |
-
- Speak like a friendly, knowledgeable older sister who’s also a certified personal trainer and women’s health educator.
|
| 44 |
-
- Be warm, inclusive, and body-positive. Avoid judgment, shame, or clinical coldness.
|
| 45 |
-
- Validate the user’s experience before offering guidance (e.g., “That makes total sense—your energy might be shifting in this phase”).
|
| 46 |
-
- Encourage autonomy by offering options, not orders.
|
| 47 |
-
- Connect advice to real-life impact: how the cycle affects energy, mood, and performance.
|
| 48 |
-
When the user asks about workouts, always ask a follow-up:
|
| 49 |
-
"What kind of equipment or space do you have access to—like a gym, home weights, or just bodyweight? I’ll tailor a workout for you based on that!"
|
| 50 |
-
Final Boundary Rule:
|
| 51 |
-
--------------------
|
| 52 |
-
Strictly decline all unrelated questions. Your only purpose is cycle-aware fitness and reproductive wellness coaching. Do not give general medical, tech, cooking, legal, or life advice. IF THE USER ASKS ABOUT A WORKOUT YOU MUST ASK WHAT EQUIPTMENT OR THINGS THEY HAVE ACSESS TO
|
| 53 |
-
"""
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
try:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def respond(message, history):
|
| 71 |
-
if not isinstance(history, list):
|
| 72 |
-
history = []
|
| 73 |
-
|
| 74 |
-
messages = [{"role": "system", "content": cycle_ai_prompt}]
|
| 75 |
-
for entry in history:
|
| 76 |
-
if isinstance(entry, dict):
|
| 77 |
-
messages.append(entry)
|
| 78 |
-
messages.append({"role": "user", "content": message})
|
| 79 |
-
|
| 80 |
-
response = client.chat_completion(
|
| 81 |
-
messages,
|
| 82 |
-
max_tokens=500,
|
| 83 |
-
temperature=0.1
|
| 84 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
print("DEBUG RESPONSE:", response) # <-- Add this to inspect
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
try:
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
{"role": "user", "content": message},
|
| 96 |
-
{"role": "assistant", "content":
|
| 97 |
]
|
| 98 |
-
return
|
| 99 |
|
| 100 |
-
def update_chatbot(user_message, history):
|
| 101 |
-
return respond(user_message, history)
|
| 102 |
|
| 103 |
-
def
|
| 104 |
-
|
| 105 |
-
greeting = f"Hi {name}! I'm here to help you with cycle-aware fitness and wellness.\n\nYou’re {age} years old, training at a {level.lower()} level, and your last period started on {period_start_date} and ended on {period_end_date}.\n\nRight now, you’re likely in your **{phase}**. {tip} 💞\n\nAsk me anything about your body, cycle, or contraceptive health!"
|
| 106 |
-
return name, [{"role": "assistant", "content": greeting}]
|
| 107 |
|
| 108 |
-
def button_click(question, history):
|
| 109 |
-
new_history, updated_history, _ = respond(question, history)
|
| 110 |
-
return new_history, updated_history
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
font-weight: 700;
|
| 143 |
-
color: #A80055;
|
| 144 |
-
text-align: left;
|
| 145 |
-
padding-left: 10px;
|
| 146 |
-
margin-top: 10px;
|
| 147 |
-
margin-bottom: -10px;
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
#banner-image {
|
| 151 |
-
background-color: transparent;
|
| 152 |
-
margin-bottom: -150px;
|
| 153 |
-
margin-top: -150px;
|
| 154 |
-
padding-top: 20px;
|
| 155 |
-
}
|
| 156 |
-
""",
|
| 157 |
-
theme=gr.themes.Soft(
|
| 158 |
-
primary_hue="pink",
|
| 159 |
-
secondary_hue="orange",
|
| 160 |
-
neutral_hue="yellow",
|
| 161 |
-
spacing_size="lg",
|
| 162 |
-
radius_size="lg",
|
| 163 |
-
font=[gr.themes.GoogleFont("Quicksand"), "sans-serif"],
|
| 164 |
-
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"]
|
| 165 |
-
)
|
| 166 |
-
) as demo:
|
| 167 |
-
gr.Image(
|
| 168 |
-
value="Untitled design.png",
|
| 169 |
-
show_label=False,
|
| 170 |
-
show_share_button = False,
|
| 171 |
-
show_download_button = False,
|
| 172 |
-
elem_id="banner-image")
|
| 173 |
-
|
| 174 |
-
name_state = gr.State("")
|
| 175 |
-
chat_history = gr.State([])
|
| 176 |
-
gr.Markdown("## Tell me about yourself", elem_id="about-you-title")
|
| 177 |
-
|
| 178 |
-
with gr.Row():
|
| 179 |
-
with gr.Column(scale=1):
|
| 180 |
-
name_input = gr.Textbox(label="Name", placeholder="Your name…")
|
| 181 |
-
age_input = gr.Textbox(label="Age", placeholder="Your age…")
|
| 182 |
-
level_input = gr.Dropdown(choices=["Beginner", "Intermediate", "Expert"], label="Training Level")
|
| 183 |
-
period_start_input = gr.Textbox(label="Last Period Start Date", placeholder="YYYY-MM-DD")
|
| 184 |
-
period_end_input = gr.Textbox(label="Last Period End Date", placeholder="YYYY-MM-DD")
|
| 185 |
-
set_btn = gr.Button("Set Info")
|
| 186 |
-
gr.Markdown("_After the greeting appears, start chatting →_")
|
| 187 |
-
|
| 188 |
-
with gr.Column(scale=2):
|
| 189 |
-
chatbot = gr.Chatbot(label="Chat", type="messages")
|
| 190 |
-
user_text = gr.Textbox(placeholder="Ask me something…", label="")
|
| 191 |
-
with gr.Row():
|
| 192 |
-
workout_btn = gr.Button("Prompt: Workouts")
|
| 193 |
-
contraceptive_btn = gr.Button("Prompt: Contraceptives")
|
| 194 |
-
|
| 195 |
-
set_btn.click(
|
| 196 |
-
fn=set_user_info,
|
| 197 |
-
inputs=[name_input, age_input, level_input, period_start_input, period_end_input],
|
| 198 |
-
outputs=[name_state, chatbot],
|
| 199 |
-
show_progress=False,
|
| 200 |
-
)
|
| 201 |
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
)
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
-
if __name__ ==
|
| 221 |
-
demo.launch()
|
|
|
|
| 1 |
+
"""Aphaea: a cited, cycle-aware wellness education assistant."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from collections.abc import Iterable
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from datetime import date, datetime
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
import gradio as gr
|
| 12 |
+
import numpy as np
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
from huggingface_hub import InferenceClient
|
|
|
|
| 15 |
from sentence_transformers import SentenceTransformer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
APP_DIR = Path(__file__).parent
|
| 18 |
+
load_dotenv(APP_DIR / ".env")
|
| 19 |
+
MODEL_ID = os.getenv("HF_MODEL", "google/gemma-2-2b-it")
|
| 20 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 21 |
+
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 22 |
+
|
| 23 |
+
MEDICAL_NOTICE = (
|
| 24 |
+
"Aphaea provides general education, not diagnosis or medical care. "
|
| 25 |
+
"Seek urgent help for severe pain, fainting, very heavy bleeding, trouble breathing, "
|
| 26 |
+
"or another emergency."
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
SYSTEM_PROMPT = f"""
|
| 30 |
+
You are Aphaea, a careful educational assistant for menstrual-cycle wellness,
|
| 31 |
+
exercise, contraception, and reproductive health.
|
| 32 |
+
|
| 33 |
+
Rules:
|
| 34 |
+
- Use only the supplied source passages for factual health claims.
|
| 35 |
+
- Cite supporting passages inline as [S1], [S2], and so on.
|
| 36 |
+
- If the passages do not support an answer, say that clearly and recommend a
|
| 37 |
+
qualified clinician or pharmacist instead of guessing.
|
| 38 |
+
- Never diagnose, prescribe, or tell someone to start or stop medication.
|
| 39 |
+
- Do not claim that a calendar estimate can confirm ovulation or cycle phase.
|
| 40 |
+
- For urgent warning signs, encourage immediate local medical care.
|
| 41 |
+
- Keep responses concise, inclusive, respectful, and easy to understand.
|
| 42 |
+
- Decline unrelated requests and redirect to Aphaea's health-education scope.
|
| 43 |
+
|
| 44 |
+
Medical notice: {MEDICAL_NOTICE}
|
| 45 |
+
""".strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class SourceChunk:
|
| 50 |
+
text: str
|
| 51 |
+
title: str
|
| 52 |
+
source_id: str
|
| 53 |
+
url: str = ""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def split_legacy_knowledge(text: str) -> list[SourceChunk]:
|
| 57 |
+
"""Split the original corpus while retaining stable source identifiers."""
|
| 58 |
+
paragraphs = [p.strip() for p in text.split("\n\n") if len(p.strip()) >= 40]
|
| 59 |
+
return [
|
| 60 |
+
SourceChunk(
|
| 61 |
+
text=paragraph,
|
| 62 |
+
title="Aphaea legacy knowledge base (source review pending)",
|
| 63 |
+
source_id=f"legacy-{index:03d}",
|
| 64 |
+
)
|
| 65 |
+
for index, paragraph in enumerate(paragraphs, start=1)
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class KnowledgeBase:
|
| 70 |
+
def __init__(self, chunks: Iterable[SourceChunk]):
|
| 71 |
+
self.chunks = list(chunks)
|
| 72 |
+
if not self.chunks:
|
| 73 |
+
raise ValueError("The knowledge base contains no usable passages.")
|
| 74 |
+
self.encoder = SentenceTransformer(EMBEDDING_MODEL)
|
| 75 |
+
embeddings = self.encoder.encode(
|
| 76 |
+
[chunk.text for chunk in self.chunks],
|
| 77 |
+
convert_to_numpy=True,
|
| 78 |
+
normalize_embeddings=True,
|
| 79 |
+
)
|
| 80 |
+
self.embeddings = np.asarray(embeddings, dtype=np.float32)
|
| 81 |
+
|
| 82 |
+
def search(self, query: str, top_k: int = 4) -> list[tuple[SourceChunk, float]]:
|
| 83 |
+
query_embedding = self.encoder.encode(
|
| 84 |
+
[query], convert_to_numpy=True, normalize_embeddings=True
|
| 85 |
+
)[0]
|
| 86 |
+
scores = self.embeddings @ query_embedding
|
| 87 |
+
count = min(top_k, len(self.chunks))
|
| 88 |
+
indices = np.argsort(scores)[-count:][::-1]
|
| 89 |
+
return [(self.chunks[index], float(scores[index])) for index in indices]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@lru_cache(maxsize=1)
|
| 93 |
+
def get_knowledge_base() -> KnowledgeBase:
|
| 94 |
+
records = json.loads(
|
| 95 |
+
(APP_DIR / "knowledge" / "sources.json").read_text(encoding="utf-8")
|
| 96 |
+
)
|
| 97 |
+
chunks = [
|
| 98 |
+
SourceChunk(
|
| 99 |
+
text=record["text"],
|
| 100 |
+
title=record["title"],
|
| 101 |
+
source_id=record["source_id"],
|
| 102 |
+
url=record["url"],
|
| 103 |
+
)
|
| 104 |
+
for record in records
|
| 105 |
+
]
|
| 106 |
+
return KnowledgeBase(chunks)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def parse_iso_date(value: str) -> date:
|
| 110 |
+
if not value or not value.strip():
|
| 111 |
+
raise ValueError("Enter the first day of your most recent period.")
|
| 112 |
+
try:
|
| 113 |
+
parsed = date.fromisoformat(value.strip())
|
| 114 |
+
except ValueError as exc:
|
| 115 |
+
raise ValueError("Use the date format YYYY-MM-DD.") from exc
|
| 116 |
+
if parsed > datetime.now().astimezone().date():
|
| 117 |
+
raise ValueError("The start date cannot be in the future.")
|
| 118 |
+
return parsed
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def cycle_day_estimate(start_date: str, typical_length: int) -> tuple[int, str]:
|
| 122 |
+
"""Return a transparent calendar estimate, not an ovulation prediction."""
|
| 123 |
+
start = parse_iso_date(start_date)
|
| 124 |
+
length = int(typical_length)
|
| 125 |
+
if not 21 <= length <= 40:
|
| 126 |
+
raise ValueError("Typical cycle length must be between 21 and 40 days.")
|
| 127 |
+
elapsed = (datetime.now().astimezone().date() - start).days
|
| 128 |
+
day = elapsed % length + 1
|
| 129 |
+
return day, (
|
| 130 |
+
f"Calendar estimate: cycle day {day} of a typical {length}-day cycle. "
|
| 131 |
+
"This cannot confirm hormonal phase or ovulation, especially when cycles vary."
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def build_profile(start_date: str, typical_length: int, training_level: str):
|
| 136 |
try:
|
| 137 |
+
day, estimate = cycle_day_estimate(start_date, typical_length)
|
| 138 |
+
except ValueError as exc:
|
| 139 |
+
return gr.update(value=f"**Check your information:** {exc}"), {}
|
| 140 |
+
|
| 141 |
+
profile = {
|
| 142 |
+
"cycle_day": day,
|
| 143 |
+
"cycle_length": int(typical_length),
|
| 144 |
+
"training_level": training_level or "Not specified",
|
| 145 |
+
}
|
| 146 |
+
message = (
|
| 147 |
+
f"### Your private session profile\n\n{estimate}\n\n"
|
| 148 |
+
f"**Training level:** {profile['training_level']}\n\n"
|
| 149 |
+
"Your entries stay in this browser session and are not written to a database."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
)
|
| 151 |
+
return gr.update(value=message), profile
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def format_sources(results: list[tuple[SourceChunk, float]]) -> tuple[str, str]:
|
| 155 |
+
context_parts = []
|
| 156 |
+
source_lines = []
|
| 157 |
+
for position, (chunk, score) in enumerate(results, start=1):
|
| 158 |
+
context_parts.append(f"[S{position}] {chunk.text}")
|
| 159 |
+
title = f"[{chunk.title}]({chunk.url})" if chunk.url else chunk.title
|
| 160 |
+
source_lines.append(f"**S{position}.** {title} - `{chunk.source_id}`")
|
| 161 |
+
return "\n\n".join(context_parts), "\n\n".join(source_lines)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def normalize_history(history) -> list[dict[str, str]]:
|
| 165 |
+
normalized = []
|
| 166 |
+
for entry in history or []:
|
| 167 |
+
if isinstance(entry, dict) and entry.get("role") in {"user", "assistant"}:
|
| 168 |
+
content = entry.get("content")
|
| 169 |
+
if isinstance(content, str):
|
| 170 |
+
normalized.append({"role": entry["role"], "content": content})
|
| 171 |
+
return normalized[-8:]
|
| 172 |
|
|
|
|
| 173 |
|
| 174 |
+
def answer(message: str, history, profile: dict):
|
| 175 |
+
message = (message or "").strip()
|
| 176 |
+
clean_history = normalize_history(history)
|
| 177 |
+
if not message:
|
| 178 |
+
return clean_history, clean_history, "", ""
|
| 179 |
+
|
| 180 |
try:
|
| 181 |
+
results = get_knowledge_base().search(message)
|
| 182 |
+
context, sources = format_sources(results)
|
| 183 |
+
except Exception as exc: # noqa: BLE001 - user-facing retrieval boundary
|
| 184 |
+
reply = (
|
| 185 |
+
"I couldn't search the knowledge library right now. "
|
| 186 |
+
f"Please try again after the app is restarted. ({type(exc).__name__})"
|
| 187 |
+
)
|
| 188 |
+
updated = clean_history + [
|
| 189 |
+
{"role": "user", "content": message},
|
| 190 |
+
{"role": "assistant", "content": reply},
|
| 191 |
+
]
|
| 192 |
+
return updated, updated, "", ""
|
| 193 |
+
|
| 194 |
+
if not HF_TOKEN:
|
| 195 |
+
reply = (
|
| 196 |
+
"The interface and knowledge retrieval are working, but model generation "
|
| 197 |
+
"is not configured locally yet. Add `HF_TOKEN` to a local `.env` workflow "
|
| 198 |
+
"or as a Hugging Face Space secret. Never paste the token into source code."
|
| 199 |
+
)
|
| 200 |
+
else:
|
| 201 |
+
profile_note = (
|
| 202 |
+
f"Session profile: {profile}. Treat the cycle day as a calendar estimate only."
|
| 203 |
+
if profile
|
| 204 |
+
else "No optional session profile was provided."
|
| 205 |
+
)
|
| 206 |
+
messages = [
|
| 207 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 208 |
+
*clean_history,
|
| 209 |
+
{
|
| 210 |
+
"role": "user",
|
| 211 |
+
"content": (
|
| 212 |
+
f"{profile_note}\n\nQuestion: {message}\n\n"
|
| 213 |
+
f"Retrieved passages:\n{context}"
|
| 214 |
+
),
|
| 215 |
+
},
|
| 216 |
+
]
|
| 217 |
+
try:
|
| 218 |
+
client = InferenceClient(model=MODEL_ID, token=HF_TOKEN)
|
| 219 |
+
completion = client.chat_completion(
|
| 220 |
+
messages=messages,
|
| 221 |
+
max_tokens=550,
|
| 222 |
+
temperature=0.2,
|
| 223 |
+
)
|
| 224 |
+
reply = completion.choices[0].message.content or "No response was returned."
|
| 225 |
+
except Exception as exc: # noqa: BLE001 - remote model boundary
|
| 226 |
+
reply = (
|
| 227 |
+
"The model service is unavailable right now, but your question was not lost. "
|
| 228 |
+
f"Please try again shortly. ({type(exc).__name__})"
|
| 229 |
+
)
|
| 230 |
|
| 231 |
+
updated = clean_history + [
|
| 232 |
{"role": "user", "content": message},
|
| 233 |
+
{"role": "assistant", "content": reply},
|
| 234 |
]
|
| 235 |
+
return updated, updated, "", sources
|
| 236 |
|
|
|
|
|
|
|
| 237 |
|
| 238 |
+
def suggested_prompt(prompt: str, history, profile):
|
| 239 |
+
return answer(prompt, history, profile)
|
|
|
|
|
|
|
| 240 |
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
+
CSS = """
|
| 243 |
+
:root { --ink: #24212b; --muted: #6f6878; --plum: #69405f; --rose: #b75b73; }
|
| 244 |
+
.gradio-container { background: #fbf8f6 !important; color: var(--ink); color-scheme: light !important;
|
| 245 |
+
--body-background-fill: #fbf8f6; --block-background-fill: #ffffff;
|
| 246 |
+
--input-background-fill: #ffffff; --body-text-color: #24212b;
|
| 247 |
+
--block-label-text-color: #69405f; }
|
| 248 |
+
.app-shell { max-width: 1180px; margin: 0 auto; }
|
| 249 |
+
.hero { padding: 26px 30px; border: 1px solid #eadfe2; border-radius: 24px;
|
| 250 |
+
background: linear-gradient(135deg, #fff 0%, #f9ecef 100%); margin-bottom: 18px; }
|
| 251 |
+
.eyebrow { color: #8e5067; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-size: 12px; }
|
| 252 |
+
.hero h1 { font-size: 36px; line-height: 1.08; margin: 8px 0; color: #3f283b; }
|
| 253 |
+
.hero p { color: var(--muted); max-width: 720px; font-size: 16px; margin: 0; }
|
| 254 |
+
.status { display: inline-block; margin-top: 15px; padding: 6px 10px; border-radius: 999px;
|
| 255 |
+
background: #f0e3e7; color: #69405f; font-size: 12px; font-weight: 700; }
|
| 256 |
+
.panel { border: 1px solid #eadfe2 !important; border-radius: 20px !important;
|
| 257 |
+
background: #fff !important; padding: 18px !important; box-shadow: 0 10px 30px rgba(69,43,62,.06); }
|
| 258 |
+
h1, h2, h3, p { color: var(--ink) !important; }
|
| 259 |
+
.panel .block, .panel .wrap, .panel .form { background: #fff !important; color: var(--ink) !important; }
|
| 260 |
+
.panel input, .panel textarea { background: #fff !important; color: var(--ink) !important; }
|
| 261 |
+
#aphaea-chat { background: #fff !important; color: var(--ink) !important; }
|
| 262 |
+
#aphaea-chat .wrap { background: #1f2937 !important; }
|
| 263 |
+
#aphaea-chat .wrap, #aphaea-chat .wrap * { color: #f8fafc !important; }
|
| 264 |
+
.panel label, .panel label span, .panel .label-wrap { color: var(--ink) !important; }
|
| 265 |
+
button.primary { color: #fff !important; }
|
| 266 |
+
.profile-copy { color: var(--muted); font-size: 13px; }
|
| 267 |
+
.notice { border-left: 4px solid #b75b73; padding: 10px 12px; background: #fff3f5;
|
| 268 |
+
color: #5d3546; border-radius: 8px; font-size: 13px; }
|
| 269 |
+
.sources { font-size: 12px; color: var(--muted); }
|
| 270 |
+
footer { display: none !important; }
|
| 271 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
+
THEME = gr.themes.Base(primary_hue="rose", neutral_hue="slate").set(
|
| 274 |
+
body_background_fill="#fbf8f6",
|
| 275 |
+
body_text_color="#24212b",
|
| 276 |
+
block_background_fill="#ffffff",
|
| 277 |
+
block_border_color="#eadfe2",
|
| 278 |
+
block_label_background_fill="#ffffff",
|
| 279 |
+
block_label_text_color="#69405f",
|
| 280 |
+
input_background_fill="#ffffff",
|
| 281 |
+
input_border_color="#ded6dc",
|
| 282 |
+
input_placeholder_color="#8a838d",
|
| 283 |
+
button_primary_background_fill="#7d4667",
|
| 284 |
+
button_primary_background_fill_hover="#673653",
|
| 285 |
+
button_primary_text_color="#ffffff",
|
| 286 |
+
button_secondary_background_fill="#f4eff1",
|
| 287 |
+
button_secondary_background_fill_hover="#eadfe3",
|
| 288 |
+
button_secondary_text_color="#3f283b",
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
with gr.Blocks(css=CSS, theme=THEME) as demo:
|
| 293 |
+
profile_state = gr.State({})
|
| 294 |
+
history_state = gr.State([])
|
| 295 |
+
|
| 296 |
+
with gr.Column(elem_classes="app-shell"):
|
| 297 |
+
gr.HTML(
|
| 298 |
+
"""
|
| 299 |
+
<section class="hero">
|
| 300 |
+
<div class="eyebrow">Cycle-aware health education</div>
|
| 301 |
+
<h1>Aphaea</h1>
|
| 302 |
+
<p>Explore menstrual wellness, movement, and contraception with
|
| 303 |
+
answers grounded in a cited source library under active review.</p>
|
| 304 |
+
<span class="status">Educational tool - not medical care</span>
|
| 305 |
+
</section>
|
| 306 |
+
"""
|
| 307 |
+
)
|
| 308 |
|
| 309 |
+
with gr.Row(equal_height=False):
|
| 310 |
+
with gr.Column(scale=1, elem_classes="panel"):
|
| 311 |
+
gr.Markdown("### Optional session profile")
|
| 312 |
+
gr.Markdown(
|
| 313 |
+
"A calendar estimate can personalize wording, but it cannot confirm "
|
| 314 |
+
"ovulation or hormonal phase.",
|
| 315 |
+
elem_classes="profile-copy",
|
| 316 |
+
)
|
| 317 |
+
start_input = gr.Textbox(
|
| 318 |
+
label="Most recent period start",
|
| 319 |
+
placeholder="YYYY-MM-DD",
|
| 320 |
+
)
|
| 321 |
+
length_input = gr.Slider(
|
| 322 |
+
minimum=21,
|
| 323 |
+
maximum=40,
|
| 324 |
+
value=28,
|
| 325 |
+
step=1,
|
| 326 |
+
label="Typical cycle length",
|
| 327 |
+
)
|
| 328 |
+
training_input = gr.Dropdown(
|
| 329 |
+
["Beginner", "Intermediate", "Advanced"],
|
| 330 |
+
value="Beginner",
|
| 331 |
+
label="Training experience",
|
| 332 |
+
)
|
| 333 |
+
save_profile = gr.Button("Create session profile", variant="primary")
|
| 334 |
+
profile_output = gr.Markdown(
|
| 335 |
+
"No profile created. You can still use the assistant."
|
| 336 |
+
)
|
| 337 |
+
gr.HTML(f'<div class="notice">{MEDICAL_NOTICE}</div>')
|
| 338 |
+
|
| 339 |
+
with gr.Column(scale=2, elem_classes="panel"):
|
| 340 |
+
gr.Markdown("### Ask Aphaea")
|
| 341 |
+
chatbot = gr.Chatbot(
|
| 342 |
+
elem_id="aphaea-chat",
|
| 343 |
+
type="messages",
|
| 344 |
+
height=430,
|
| 345 |
+
placeholder="Ask a question or choose a starting prompt below.",
|
| 346 |
+
show_copy_button=True,
|
| 347 |
+
)
|
| 348 |
+
user_input = gr.Textbox(
|
| 349 |
+
label="Your question",
|
| 350 |
+
placeholder="For example: What are common reasons a cycle changes?",
|
| 351 |
+
lines=2,
|
| 352 |
+
)
|
| 353 |
+
with gr.Row():
|
| 354 |
+
send_button = gr.Button("Send", variant="primary")
|
| 355 |
+
clear_button = gr.Button("Clear conversation")
|
| 356 |
+
with gr.Row():
|
| 357 |
+
workout_button = gr.Button("Adapt a workout")
|
| 358 |
+
contraception_button = gr.Button("Compare contraception")
|
| 359 |
+
symptoms_button = gr.Button("Understand cycle changes")
|
| 360 |
+
with gr.Accordion("Sources used for the latest answer", open=False):
|
| 361 |
+
source_output = gr.Markdown(
|
| 362 |
+
"Sources will appear after you ask a question.",
|
| 363 |
+
elem_classes="sources",
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
save_profile.click(
|
| 367 |
+
build_profile,
|
| 368 |
+
[start_input, length_input, training_input],
|
| 369 |
+
[profile_output, profile_state],
|
| 370 |
)
|
| 371 |
|
| 372 |
+
for trigger in (send_button.click, user_input.submit):
|
| 373 |
+
trigger(
|
| 374 |
+
answer,
|
| 375 |
+
[user_input, history_state, profile_state],
|
| 376 |
+
[chatbot, history_state, user_input, source_output],
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
workout_button.click(
|
| 380 |
+
lambda history, profile: suggested_prompt(
|
| 381 |
+
"How can I adjust exercise when my energy or symptoms change?",
|
| 382 |
+
history,
|
| 383 |
+
profile,
|
| 384 |
+
),
|
| 385 |
+
[history_state, profile_state],
|
| 386 |
+
[chatbot, history_state, user_input, source_output],
|
| 387 |
+
)
|
| 388 |
+
contraception_button.click(
|
| 389 |
+
lambda history, profile: suggested_prompt(
|
| 390 |
+
"What factors should someone discuss with a clinician when comparing contraception?",
|
| 391 |
+
history,
|
| 392 |
+
profile,
|
| 393 |
+
),
|
| 394 |
+
[history_state, profile_state],
|
| 395 |
+
[chatbot, history_state, user_input, source_output],
|
| 396 |
)
|
| 397 |
+
symptoms_button.click(
|
| 398 |
+
lambda history, profile: suggested_prompt(
|
| 399 |
+
"What are common reasons menstrual-cycle timing can change?",
|
| 400 |
+
history,
|
| 401 |
+
profile,
|
| 402 |
+
),
|
| 403 |
+
[history_state, profile_state],
|
| 404 |
+
[chatbot, history_state, user_input, source_output],
|
| 405 |
+
)
|
| 406 |
+
clear_button.click(
|
| 407 |
+
lambda: ([], [], "Sources will appear after you ask a question."),
|
| 408 |
+
outputs=[chatbot, history_state, source_output],
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
|
| 412 |
+
if __name__ == "__main__":
|
| 413 |
+
demo.queue().launch()
|
knowledge/sources.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"source_id": "owh-cycle-basics-2024",
|
| 4 |
+
"title": "Office on Women's Health - Your menstrual cycle",
|
| 5 |
+
"url": "https://womenshealth.gov/menstrual-cycle/your-menstrual-cycle",
|
| 6 |
+
"reviewed_at": "2026-08-05",
|
| 7 |
+
"text": "A menstrual cycle starts on the first day of menstrual bleeding and ends when the next period begins. Estrogen and progesterone levels usually change during the cycle and may contribute to symptoms. Calendar dates alone cannot prove when ovulation occurred, and cycles may become irregular for many reasons. A clinician can evaluate pregnancy or health conditions when periods stop or become irregular."
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"source_id": "acog-aub-patterns",
|
| 11 |
+
"title": "ACOG - Abnormal Uterine Bleeding",
|
| 12 |
+
"url": "https://www.acog.org/womens-health/faqs/abnormal-uterine-bleeding",
|
| 13 |
+
"reviewed_at": "2026-08-05",
|
| 14 |
+
"text": "ACOG describes cycles shorter than 21 days or longer than 35 days, bleeding longer than 7 days, bleeding between periods or after sex, and substantial cycle-length variation as patterns that can warrant medical discussion. Potential causes include ovulation changes, fibroids, polyps, bleeding disorders, medications, pregnancy-related conditions, and other health conditions; symptoms alone cannot identify the cause."
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"source_id": "acog-aub-emergency",
|
| 18 |
+
"title": "ACOG - Abnormal Uterine Bleeding",
|
| 19 |
+
"url": "https://www.acog.org/womens-health/faqs/abnormal-uterine-bleeding",
|
| 20 |
+
"reviewed_at": "2026-08-05",
|
| 21 |
+
"text": "ACOG advises emergency medical care when someone is changing pads or tampons every hour for more than 2 hours in a row and also has chest pain, shortness of breath, lightheadedness, or dizziness. Acute or unusually heavy bleeding should not be diagnosed by a chatbot."
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"source_id": "cdc-contraception-choice-2024",
|
| 25 |
+
"title": "CDC - Contraception and Birth Control Methods",
|
| 26 |
+
"url": "https://www.cdc.gov/contraception/about/index.html",
|
| 27 |
+
"reviewed_at": "2026-08-05",
|
| 28 |
+
"text": "The CDC recommends voluntary, informed contraceptive choice. Relevant comparison factors include safety for the individual's medical history, effectiveness with typical use, availability and cost, possible side effects, user control, reversibility, and how easily a method can be stopped or removed. A clinician or pharmacist can help assess contraindications and medication interactions."
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"source_id": "cdc-contraception-sti-2024",
|
| 32 |
+
"title": "CDC - Contraception and Birth Control Methods",
|
| 33 |
+
"url": "https://www.cdc.gov/contraception/about/index.html",
|
| 34 |
+
"reviewed_at": "2026-08-05",
|
| 35 |
+
"text": "Most contraceptive methods do not protect against sexually transmitted infections. Condoms can help reduce the risk of HIV and other sexually transmitted infections and may be used alone or together with another contraceptive method. Emergency contraception is intended for use after unprotected sex or method failure, not as a regular contraceptive method."
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"source_id": "mcnulty-exercise-review-2020",
|
| 39 |
+
"title": "McNulty et al. - Menstrual cycle phase and exercise performance",
|
| 40 |
+
"url": "https://pubmed.ncbi.nlm.nih.gov/32661839/",
|
| 41 |
+
"reviewed_at": "2026-08-05",
|
| 42 |
+
"text": "A systematic review and meta-analysis found only trivial average differences in exercise performance across menstrual-cycle phases, with large differences between studies and low-quality evidence. The authors concluded that general phase-based exercise rules cannot be formed from the evidence and recommended personalizing training according to each individual's own responses and symptoms."
|
| 43 |
+
}
|
| 44 |
+
]
|
requirements.txt
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
-
gradio
|
| 2 |
-
huggingface_hub
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==5.38.2
|
| 2 |
+
huggingface_hub>=0.33,<1.0
|
| 3 |
+
sentence-transformers>=4.1,<6.0
|
| 4 |
+
numpy>=1.26,<3.0
|
| 5 |
+
requests>=2.32,<3.0
|
| 6 |
+
python-dotenv>=1.0,<2.0
|
| 7 |
+
pytest>=8.0,<9.0
|
tests/__pycache__/test_core.cpython-312-pytest-8.4.2.pyc
ADDED
|
Binary file (9.24 kB). View file
|
|
|
tests/test_core.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app import cycle_day_estimate, parse_iso_date, split_legacy_knowledge
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_parse_iso_date_accepts_today():
|
| 11 |
+
today = datetime.now().astimezone().date()
|
| 12 |
+
assert parse_iso_date(today.isoformat()) == today
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_parse_iso_date_rejects_future_date():
|
| 16 |
+
today = datetime.now().astimezone().date()
|
| 17 |
+
future = (today + timedelta(days=1)).isoformat()
|
| 18 |
+
with pytest.raises(ValueError, match="future"):
|
| 19 |
+
parse_iso_date(future)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_cycle_estimate_is_transparent():
|
| 23 |
+
today = datetime.now().astimezone().date()
|
| 24 |
+
day, explanation = cycle_day_estimate(today.isoformat(), 28)
|
| 25 |
+
assert day == 1
|
| 26 |
+
assert "cannot confirm hormonal phase or ovulation" in explanation
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_legacy_splitter_retains_stable_ids():
|
| 30 |
+
text = (
|
| 31 |
+
"A sufficiently long first paragraph that should become a source chunk.\n\n"
|
| 32 |
+
"A sufficiently long second paragraph that should become another chunk."
|
| 33 |
+
)
|
| 34 |
+
chunks = split_legacy_knowledge(text)
|
| 35 |
+
assert [chunk.source_id for chunk in chunks] == ["legacy-001", "legacy-002"]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_active_sources_are_traceable():
|
| 39 |
+
source_path = Path(__file__).parents[1] / "knowledge" / "sources.json"
|
| 40 |
+
records = json.loads(source_path.read_text(encoding="utf-8"))
|
| 41 |
+
assert len(records) >= 5
|
| 42 |
+
assert len({record["source_id"] for record in records}) == len(records)
|
| 43 |
+
for record in records:
|
| 44 |
+
assert record["url"].startswith("https://")
|
| 45 |
+
assert record["reviewed_at"]
|
| 46 |
+
assert len(record["text"]) >= 100
|