Deploy rebuilt Aphaea with cited retrieval

#3
.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: Chatbot
3
- emoji: 💻
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 5.38.2
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def determine_cycle_phase(start_date_str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  try:
57
- start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
58
- days_since = (datetime.now() - start_date).days % 28
59
- if days_since < 5:
60
- return "Menstrual Phase", "💗 Time to rest and recover. Gentle movement like stretching or walking is great."
61
- elif days_since < 13:
62
- return "Follicular Phase", "💪 Your energy’s building—go for strength training or cardio!"
63
- elif days_since < 16:
64
- return "Ovulation Phase", "🔥 Peak power! Try high-intensity workouts or social activities."
65
- else:
66
- return "Luteal Phase", "🌙 Wind down. Opt for lighter training, yoga, or bodyweight exercises."
67
- except:
68
- return "Unknown Phase", "Couldn't parse the date. Please use YYYY-MM-DD."
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
- # Now adapt based on actual response
 
 
 
 
 
89
  try:
90
- assistant_reply = response['choices'][0]['message']['content'].strip()
91
- except Exception as e:
92
- assistant_reply = f"⚠️ There was an error processing the response: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- new_history = history + [
95
  {"role": "user", "content": message},
96
- {"role": "assistant", "content": assistant_reply}
97
  ]
98
- return new_history, new_history, ""
99
 
100
- def update_chatbot(user_message, history):
101
- return respond(user_message, history)
102
 
103
- def set_user_info(name, age, level, period_start_date, period_end_date):
104
- phase, tip = determine_cycle_phase(period_start_date)
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
- with gr.Blocks(
113
- css="""
114
- .gradio-container {
115
- background: linear-gradient(135deg, #F6D365 0%, #FDA085 50%, #FF8FA0 100%);
116
- font-family: 'Quicksand', sans-serif;
117
- }
118
- .message.user {
119
- background-color: lightpink;
120
- border-radius: 20px;
121
- padding: 10px;
122
- margin: 5px;
123
- max-width: 75%;
124
- align-self: flex-end;
125
- }
126
- .message.bot {
127
- background-color: #FFE4D1;
128
- border-radius: 20px;
129
- padding: 10px;
130
- margin: 5px;
131
- max-width: 75%;
132
- align-self: flex-start;
133
- }
134
- .chat-interface {
135
- background-color: peachpuff;
136
- border-radius: 12px;
137
- padding: 15px;
138
- box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
139
- }
140
- #about-you-title {
141
- font-size: 24px;
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
- user_text.submit(
203
- fn=update_chatbot,
204
- inputs=[user_text, chat_history],
205
- outputs=[chatbot, chat_history, user_text]
206
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
- workout_btn.click(
209
- fn=lambda history: button_click("What workout routine would you recommend based on my cycle phase?", history),
210
- inputs=[chat_history],
211
- outputs=[chatbot, chat_history]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  )
213
 
214
- contraceptive_btn.click(
215
- fn=lambda history: button_click("Can you explain the different contraceptive options and their benefits?", history),
216
- inputs=[chat_history],
217
- outputs=[chatbot, chat_history]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
- if __name__ == '__main__':
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
- sentence_transformers
4
- torch
5
- numpy
 
 
 
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