File size: 18,088 Bytes
07d65c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30263a9
07d65c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c30884
 
 
 
 
 
07d65c3
7c30884
 
 
 
 
07d65c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a91f32b
07d65c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c30884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07d65c3
5282e29
 
7c30884
5282e29
 
 
7c30884
5282e29
 
 
 
 
07d65c3
7c30884
 
 
 
a91f32b
 
 
 
7c30884
a91f32b
 
7c30884
07d65c3
 
7c30884
 
5282e29
 
 
 
07d65c3
 
5282e29
 
 
 
 
07d65c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c30884
07d65c3
7c30884
 
 
07d65c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5282e29
07d65c3
 
 
 
5282e29
07d65c3
 
5282e29
07d65c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
"""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(
            """
            <section class="hero">
              <div class="eyebrow">Cycle-aware health education</div>
              <h1>Aphaea</h1>
              <p>Explore menstrual wellness, movement, and contraception with
              answers grounded in a cited source library under active review.</p>
              <span class="status">Educational tool - not medical care</span>
            </section>
            """
        )

        with gr.Row(equal_height=False):
            with gr.Column(scale=1, elem_classes="panel"):
                gr.Markdown("### Optional session profile")
                gr.Markdown(
                    "A calendar estimate can personalize wording, but it cannot confirm "
                    "ovulation or hormonal phase.",
                    elem_classes="profile-copy",
                )
                start_input = gr.DateTime(
                    label="Most recent period start",
                    include_time=False,
                    type="string",
                    info="Choose the first day from the calendar.",
                )
                length_input = gr.Slider(
                    minimum=21,
                    maximum=40,
                    value=28,
                    step=1,
                    label="Typical cycle length",
                )
                training_input = gr.Dropdown(
                    ["Beginner", "Intermediate", "Advanced"],
                    value="Beginner",
                    label="Training experience",
                )
                save_profile = gr.Button("Create session profile", variant="primary")
                profile_output = gr.Markdown(
                    "No profile created. You can still use the assistant."
                )
                gr.HTML(f'<div class="notice">{MEDICAL_NOTICE}</div>')

            with gr.Column(scale=2, elem_classes="panel"):
                gr.Markdown("### Ask Aphaea")
                chatbot = gr.Chatbot(
                    elem_id="aphaea-chat",
                    type="messages",
                    height=210,
                    placeholder="Ask a question or choose a starting prompt below.",
                    show_copy_button=True,
                )
                user_input = gr.Textbox(
                    elem_id="question-input",
                    label="Your question",
                    placeholder="For example: What are common reasons a cycle changes?",
                    lines=1,
                )
                with gr.Row():
                    send_button = gr.Button("Send", variant="primary")
                    clear_button = gr.Button("Clear conversation")
                with gr.Row():
                    workout_button = gr.Button("Adapt a workout")
                    contraception_button = gr.Button("Compare contraception")
                    symptoms_button = gr.Button("Understand cycle changes")
                with gr.Accordion("Sources used for the latest answer", open=False):
                    source_output = gr.Markdown(
                        "Sources will appear after you ask a question.",
                        elem_classes="sources",
                    )

    save_profile.click(
        build_profile,
        [start_input, length_input, training_input],
        [profile_output, profile_state],
    )

    for trigger in (send_button.click, user_input.submit):
        trigger(
            answer,
            [user_input, history_state, profile_state],
            [chatbot, history_state, user_input, source_output],
        )

    workout_button.click(
        lambda history, profile: suggested_prompt(
            "How can I adjust exercise when my energy or symptoms change?",
            history,
            profile,
        ),
        [history_state, profile_state],
        [chatbot, history_state, user_input, source_output],
    )
    contraception_button.click(
        lambda history, profile: suggested_prompt(
            "What factors should someone discuss with a clinician when comparing contraception?",
            history,
            profile,
        ),
        [history_state, profile_state],
        [chatbot, history_state, user_input, source_output],
    )
    symptoms_button.click(
        lambda history, profile: suggested_prompt(
            "What are common reasons menstrual-cycle timing can change?",
            history,
            profile,
        ),
        [history_state, profile_state],
        [chatbot, history_state, user_input, source_output],
    )
    clear_button.click(
        lambda: ([], [], "Sources will appear after you ask a question."),
        outputs=[chatbot, history_state, source_output],
    )


if __name__ == "__main__":
    demo.queue().launch()