"""Aphaea: a cited, cycle-aware wellness education assistant."""
import json
import os
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
import gradio as gr
import numpy as np
from dotenv import load_dotenv
from huggingface_hub import InferenceClient
from sentence_transformers import SentenceTransformer
APP_DIR = Path(__file__).parent
load_dotenv(APP_DIR / ".env")
MODEL_ID = os.getenv("HF_MODEL", "Qwen/Qwen2.5-7B-Instruct")
HF_TOKEN = os.getenv("HF_TOKEN")
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
MEDICAL_NOTICE = (
"Aphaea provides general education, not diagnosis or medical care. "
"Seek urgent help for severe pain, fainting, very heavy bleeding, trouble breathing, "
"or another emergency."
)
SYSTEM_PROMPT = f"""
You are Aphaea, a careful educational assistant for menstrual-cycle wellness,
exercise, contraception, and reproductive health.
Rules:
- Use only the supplied source passages for factual health claims.
- Cite supporting passages inline as [S1], [S2], and so on.
- If the passages do not support an answer, say that clearly and recommend a
qualified clinician or pharmacist instead of guessing.
- Never diagnose, prescribe, or tell someone to start or stop medication.
- Do not claim that a calendar estimate can confirm ovulation or cycle phase.
- For urgent warning signs, encourage immediate local medical care.
- Keep responses concise, inclusive, respectful, and easy to understand.
- Decline unrelated requests and redirect to Aphaea's health-education scope.
Medical notice: {MEDICAL_NOTICE}
""".strip()
@dataclass(frozen=True)
class SourceChunk:
text: str
title: str
source_id: str
url: str = ""
def split_legacy_knowledge(text: str) -> list[SourceChunk]:
"""Split the original corpus while retaining stable source identifiers."""
paragraphs = [p.strip() for p in text.split("\n\n") if len(p.strip()) >= 40]
return [
SourceChunk(
text=paragraph,
title="Aphaea legacy knowledge base (source review pending)",
source_id=f"legacy-{index:03d}",
)
for index, paragraph in enumerate(paragraphs, start=1)
]
class KnowledgeBase:
def __init__(self, chunks: Iterable[SourceChunk]):
self.chunks = list(chunks)
if not self.chunks:
raise ValueError("The knowledge base contains no usable passages.")
self.encoder = SentenceTransformer(EMBEDDING_MODEL)
embeddings = self.encoder.encode(
[chunk.text for chunk in self.chunks],
convert_to_numpy=True,
normalize_embeddings=True,
)
self.embeddings = np.asarray(embeddings, dtype=np.float32)
def search(self, query: str, top_k: int = 4) -> list[tuple[SourceChunk, float]]:
query_embedding = self.encoder.encode(
[query], convert_to_numpy=True, normalize_embeddings=True
)[0]
scores = self.embeddings @ query_embedding
count = min(top_k, len(self.chunks))
indices = np.argsort(scores)[-count:][::-1]
return [(self.chunks[index], float(scores[index])) for index in indices]
@lru_cache(maxsize=1)
def get_knowledge_base() -> KnowledgeBase:
records = json.loads(
(APP_DIR / "knowledge" / "sources.json").read_text(encoding="utf-8")
)
chunks = [
SourceChunk(
text=record["text"],
title=record["title"],
source_id=record["source_id"],
url=record["url"],
)
for record in records
]
return KnowledgeBase(chunks)
def parse_iso_date(value: str | date | datetime) -> date:
if isinstance(value, datetime):
parsed = value.date()
elif isinstance(value, date):
parsed = value
elif not value or not str(value).strip():
raise ValueError("Enter the first day of your most recent period.")
else:
try:
parsed = date.fromisoformat(str(value).strip())
except ValueError as exc:
raise ValueError("Choose a valid date from the calendar.") from exc
if parsed > datetime.now().astimezone().date():
raise ValueError("The start date cannot be in the future.")
return parsed
def cycle_day_estimate(start_date: str, typical_length: int) -> tuple[int, str]:
"""Return a transparent calendar estimate, not an ovulation prediction."""
start = parse_iso_date(start_date)
length = int(typical_length)
if not 21 <= length <= 40:
raise ValueError("Typical cycle length must be between 21 and 40 days.")
elapsed = (datetime.now().astimezone().date() - start).days
day = elapsed % length + 1
return day, (
f"Calendar estimate: cycle day {day} of a typical {length}-day cycle. "
"This cannot confirm hormonal phase or ovulation, especially when cycles vary."
)
def build_profile(start_date: str, typical_length: int, training_level: str):
try:
day, estimate = cycle_day_estimate(start_date, typical_length)
except ValueError as exc:
return gr.update(value=f"**Check your information:** {exc}"), {}
profile = {
"cycle_day": day,
"cycle_length": int(typical_length),
"training_level": training_level or "Not specified",
}
message = (
f"### Your private session profile\n\n{estimate}\n\n"
f"**Training level:** {profile['training_level']}\n\n"
"Your entries stay in this browser session and are not written to a database."
)
return gr.update(value=message), profile
def format_sources(results: list[tuple[SourceChunk, float]]) -> tuple[str, str]:
context_parts = []
source_lines = []
for position, (chunk, score) in enumerate(results, start=1):
context_parts.append(f"[S{position}] {chunk.text}")
title = f"[{chunk.title}]({chunk.url})" if chunk.url else chunk.title
source_lines.append(f"**S{position}.** {title}")
return "\n\n".join(context_parts), "\n\n".join(source_lines)
def normalize_history(history) -> list[dict[str, str]]:
normalized = []
for entry in history or []:
if isinstance(entry, dict) and entry.get("role") in {"user", "assistant"}:
content = entry.get("content")
if isinstance(content, str):
normalized.append({"role": entry["role"], "content": content})
return normalized[-8:]
def answer(message: str, history, profile: dict):
message = (message or "").strip()
clean_history = normalize_history(history)
if not message:
return clean_history, clean_history, "", ""
try:
results = get_knowledge_base().search(message)
context, sources = format_sources(results)
except Exception as exc: # noqa: BLE001 - user-facing retrieval boundary
reply = (
"I couldn't search the knowledge library right now. "
f"Please try again after the app is restarted. ({type(exc).__name__})"
)
updated = clean_history + [
{"role": "user", "content": message},
{"role": "assistant", "content": reply},
]
return updated, updated, "", ""
if not HF_TOKEN:
reply = (
"The interface and knowledge retrieval are working, but model generation "
"is not configured locally yet. Add `HF_TOKEN` to a local `.env` workflow "
"or as a Hugging Face Space secret. Never paste the token into source code."
)
else:
profile_note = (
f"Session profile: {profile}. Treat the cycle day as a calendar estimate only."
if profile
else "No optional session profile was provided."
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*clean_history,
{
"role": "user",
"content": (
f"{profile_note}\n\nQuestion: {message}\n\n"
f"Retrieved passages:\n{context}"
),
},
]
try:
client = InferenceClient(model=MODEL_ID, token=HF_TOKEN)
completion = client.chat_completion(
messages=messages,
max_tokens=550,
temperature=0.2,
)
reply = completion.choices[0].message.content or "No response was returned."
except Exception as exc: # noqa: BLE001 - remote model boundary
reply = (
"The model service is unavailable right now, but your question was not lost. "
f"Please try again shortly. ({type(exc).__name__})"
)
updated = clean_history + [
{"role": "user", "content": message},
{"role": "assistant", "content": reply},
]
return updated, updated, "", sources
def suggested_prompt(prompt: str, history, profile):
return answer(prompt, history, profile)
CSS = """
:root {
--page: #fbf8f6; --surface: #ffffff; --surface-soft: #f9ecef;
--ink: #24212b; --muted: #6f6878; --plum: #69405f; --rose: #b75b73;
--border: #eadfe2; --notice: #fff3f5; --notice-ink: #5d3546;
--secondary: #f4eff1; --shadow: rgba(69,43,62,.06);
}
.dark {
--page: #141118; --surface: #211c25; --surface-soft: #30232d;
--ink: #f7f1f5; --muted: #c8bdc5; --plum: #f0b8cf; --rose: #ef8fab;
--border: #493a46; --notice: #3a202b; --notice-ink: #ffd9e4;
--secondary: #332b36; --shadow: rgba(0,0,0,.28);
}
.gradio-container { background: var(--page) !important; color: var(--ink) !important;
--body-background-fill: var(--page); --block-background-fill: var(--surface);
--input-background-fill: var(--surface); --body-text-color: var(--ink);
--block-label-text-color: var(--plum); }
.dark .gradio-container { color-scheme: dark; }
.app-shell { max-width: 1180px; margin: 0 auto; }
.hero { padding: 14px 20px; border: 1px solid var(--border); border-radius: 18px;
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-soft) 100%); margin-bottom: 10px; }
.eyebrow { color: var(--rose); font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-size: 12px; }
.hero h1 { font-size: 28px; line-height: 1.05; margin: 3px 0; color: var(--ink); }
.hero p { color: var(--muted); max-width: 850px; font-size: 14px; line-height: 1.3; margin: 0; }
.status { display: inline-block; margin-top: 7px; padding: 4px 9px; border-radius: 999px;
background: var(--secondary); color: var(--plum); font-size: 12px; font-weight: 700; }
.panel { border: 1px solid var(--border) !important; border-radius: 16px !important;
background: var(--surface) !important; padding: 12px !important; gap: 8px !important;
box-shadow: 0 8px 24px var(--shadow); }
.panel h3 { margin: 0 !important; line-height: 1.2 !important; }
.panel button { min-height: 34px !important; }
h1, h2, h3, p { color: var(--ink) !important; }
.panel .block, .panel .wrap, .panel .form { background: var(--surface) !important; color: var(--ink) !important; }
.panel input, .panel textarea { background: var(--surface) !important; color: var(--ink) !important;
border-color: var(--border) !important; }
.panel input::placeholder, .panel textarea::placeholder { color: var(--muted) !important; opacity: 1; }
#aphaea-chat,
#aphaea-chat .wrap,
#aphaea-chat .message,
#aphaea-chat .message * {
background: var(--surface) !important;
color: var(--ink) !important;
}
.dark #aphaea-chat .message.bot, .dark #aphaea-chat .message.bot * { background: #2b2330 !important; }
.panel label, .panel label span, .panel .label-wrap { color: var(--ink) !important; }
button.primary { color: #fff !important; }
.panel button:not(.primary) { background: var(--secondary) !important; color: var(--ink) !important;
border-color: var(--border) !important; }
.profile-copy { color: var(--muted); font-size: 12px; line-height: 1.35; }
.notice { border-left: 4px solid var(--rose); padding: 7px 9px; background: var(--notice);
color: var(--notice-ink); border-radius: 8px; font-size: 12px; line-height: 1.3; }
#question-input textarea { min-height: 38px !important; padding-top: 7px !important; padding-bottom: 7px !important; }
.sources { font-size: 12px; color: var(--muted); }
footer { display: none !important; }
@media (max-width: 760px) {
.hero { padding: 13px 15px; }
.hero p { font-size: 13px; }
.panel { padding: 11px !important; }
}
"""
THEME = gr.themes.Base(primary_hue="rose", neutral_hue="slate").set(
body_background_fill="#fbf8f6",
body_text_color="#24212b",
block_background_fill="#ffffff",
block_border_color="#eadfe2",
block_label_background_fill="#ffffff",
block_label_text_color="#69405f",
input_background_fill="#ffffff",
input_border_color="#ded6dc",
input_placeholder_color="#8a838d",
button_primary_background_fill="#7d4667",
button_primary_background_fill_hover="#673653",
button_primary_text_color="#ffffff",
button_secondary_background_fill="#f4eff1",
button_secondary_background_fill_hover="#eadfe3",
button_secondary_text_color="#3f283b",
)
with gr.Blocks(css=CSS, theme=THEME) as demo:
profile_state = gr.State({})
history_state = gr.State([])
with gr.Column(elem_classes="app-shell"):
gr.HTML(
"""
Explore menstrual wellness, movement, and contraception with
answers grounded in a cited source library under active review.Aphaea