dental-soap / render.py
DrZayed's picture
Native-quality Arabic handoff card
7cf8405
Raw
History Blame Contribute Delete
33.2 kB
"""HTML renderers for Dental SOAP. Pure functions over validated HandoffOutput."""
from __future__ import annotations
import hashlib
import re
from datetime import date
from html import escape
from schema import HandoffOutput
DISCLAIMER = (
"This tool organizes patient-reported history. It does not diagnose, choose dental "
"treatment, interpret imaging, or replace a licensed dentist."
)
HARD_RULE_IDS = {
"airway_or_deep_space_infection",
"gca_jaw_claudication",
"hypochlorite_accident",
"neurologic_or_cardiac_mimic",
}
TIER_LABELS = {
"emergency_now": "Emergency now",
"urgent_medical": "Urgent medical",
"same_day_urgent": "Same-day urgent",
"emergency_or_same_day": "Emergency or same-day",
"urgent_dental": "Urgent dental",
"dentist_discussion": "Discuss with dentist",
"none": "No urgent flag",
}
_TIER_PILL = {
"emergency_now": "tier-emergency",
"urgent_medical": "tier-emergency",
"emergency_or_same_day": "tier-emergency",
"same_day_urgent": "tier-urgent",
"urgent_dental": "tier-urgent",
"dentist_discussion": "tier-discuss",
"none": "tier-discuss",
}
TOOTH_SVG = (
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" '
'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
'<path d="M7 3c-2.4 0-4 1.9-4 4.5 0 4.2 2 7.5 3 11.5.4 1.6 2.4 1.7 2.8 0 '
'.5-2.2.9-5 3.2-5s2.7 2.8 3.2 5c.4 1.7 2.4 1.6 2.8 0 1-4 3-7.3 3-11.5C21 4.9 19.4 3 17 3 '
'c-1.8 0-2.9 1-5 1S8.8 3 7 3Z"/></svg>'
)
def tier_label(tier: str) -> str:
return TIER_LABELS.get(tier, tier)
def tier_pill(tier: str) -> str:
cls = _TIER_PILL.get(tier, "tier-discuss")
return f'<span class="tier-pill {cls}">{escape(tier_label(tier))}</span>'
def html_list(items: list[str], css_class: str = "") -> str:
attr = f' class="{css_class}"' if css_class else ""
return f"<ul{attr}>" + "".join(f"<li>{escape(item)}</li>" for item in items) + "</ul>"
def numbered_list(items: list[str]) -> str:
return "<ol>" + "".join(f"<li>{escape(item)}</li>" for item in items) + "</ol>"
def step_head(num: int, title: str, sub: str) -> str:
return (
f'<div class="step-head"><span class="step-num">{num}</span>'
f'<div><div class="step-title">{escape(title)}</div>'
f'<div class="step-sub">{escape(sub)}</div></div></div>'
)
_PRIVACY_PULSE_STYLE = (
"<style>@keyframes privacyPulse {"
"0%{box-shadow:0 0 0 0 rgba(13,13,13,0.40);}"
"70%{box-shadow:0 0 0 8px rgba(13,13,13,0);}"
"100%{box-shadow:0 0 0 0 rgba(13,13,13,0);}}"
"</style>"
)
def header_html() -> str:
pulse_dot = (
'<span style="width:8px;height:8px;border-radius:50%;background:#0D0D0D;'
'display:inline-block;animation:privacyPulse 2.5s infinite;"></span>'
)
return (
'<div class="app-header">'
'<div class="brand-row">'
f'<div class="brand-mark">{TOOTH_SVG}</div>'
"<div>"
'<div class="brand-name">Dental SOAP</div>'
'<div class="brand-tag">Turn your dental story into a one-page, dentist-ready handoff &mdash; emergencies flagged by fixed rules, not the AI.</div>'
"</div>"
"</div>"
'<div class="privacy-pill" style="display:flex;align-items:center;gap:8px;">'
f"{pulse_dot}"
" In-Space processing &middot; no database &middot; no external AI API"
"</div>"
"</div>"
+ _PRIVACY_PULSE_STYLE
)
def rail_html() -> str:
steps = ["Chief complaint", "Dental history", "Jaw, bite & TMJ", "Medical background"]
chips = "".join(
f'<span class="rail-chip"><span class="n">{i}</span>{escape(label)}</span>'
for i, label in enumerate(steps, start=1)
)
return f'<div class="step-rail">{chips}</div>'
def footer_html() -> str:
return (
'<div class="disclaimer-bar" role="note">'
'<span class="disclaimer-kicker">Safety boundary</span>'
'<span class="disclaimer-copy">'
f"<strong>{escape(DISCLAIMER)}</strong> "
"Fixed safety rules may advise urgent in-person care and cannot be suppressed by the model."
"</span>"
"</div>"
)
def is_hard_interrupt(output: HandoffOutput) -> bool:
return any(flag.rule_id in HARD_RULE_IDS for flag in output.red_flags)
def initial_safety_html() -> str:
"""Neutral pre-build state: no safety claim is made before any answers exist."""
return """
<div class="safety-panel clear">
<div class="safety-kicker">Safety Sentinel &middot; Rule-Based &middot; Deterministic</div>
<strong>Waiting for your answers.</strong>
<p>Once you build your handoff, every answer is checked against deterministic
clinical red-flag rules &mdash; computed from what <em>you</em> report, never
guessed or hallucinated by the AI model.</p>
</div>
"""
def placeholder_handoff_html() -> str:
"""Pre-build 'ghost document': a faint, static preview of the real handoff
card. The empty state shows the artifact itself — real section headings over
unfilled lines — instead of a generic dashed box, so the deliverable is
legible before the first interaction. Static (no shimmer): the shimmer
skeleton means 'composing now'; the ghost means 'awaiting your story'."""
def sec(label: str, widths: tuple[int, ...]) -> str:
lines = "".join(f'<div class="ghost-line" style="width:{w}%"></div>' for w in widths)
return f"<div class='ghost-sec'><h3>{label}</h3>{lines}</div>"
return f"""
<div class="handoff-card ghost" aria-label="Handoff preview — awaiting your story">
<div class="doc-band">
<div>
<div class="product">DENTAL SOAP &middot; VISIT PREP</div>
<div class="artifact">Dentist Visit Handoff</div>
</div>
<div class="doc-meta"><div class="doc-badges">
<span class="doc-badge ghost-badge">Awaiting your story</span>
</div></div>
</div>
<div class="doc-body">
<div class="ghost-cta">This page writes itself from your story &mdash;
load an example case or start the guided interview.</div>
{sec("Chief Concern", (58,))}
{sec("Concise Summary", (92, 71))}
{sec("Timeline / Story Evidence", (84, 76, 64))}
{sec("Current Symptoms", (52, 66))}
{sec("Questions For The Dentist", (88, 74))}
{sec("After-Visit Tracker", (69,))}
</div>
</div>
"""
def skeleton_handoff_html() -> str:
"""Branded loading state shown the instant Build is pressed: a handoff-shaped
shimmer skeleton so the ZeroGPU cold start reads as 'composing' rather than a
broken, half-faded page (the single highest-impact first-impression fix)."""
def sec(label: str, lines: int) -> str:
bars = "".join('<div class="skel-line"></div>' for _ in range(lines))
return f'<div class="skel-sec"><h3>{label}</h3>{bars}</div>'
return f"""
<div class="handoff-card skeleton" aria-busy="true">
<div class="doc-band">
<div>
<div class="product">DENTAL SOAP &middot; VISIT PREP</div>
<div class="skel-title"></div>
</div>
<div class="doc-meta"><div class="skel-badges">
<span class="skel-badge"></span><span class="skel-badge"></span>
</div></div>
</div>
<div class="doc-body">
<div class="skel-note">Composing your dentist-ready handoff &mdash; safety rules already ran</div>
{sec("CHIEF CONCERN", 1)}
{sec("CONCISE SUMMARY", 2)}
{sec("CURRENT SYMPTOMS", 3)}
{sec("QUESTIONS FOR THE DENTIST", 2)}
</div>
</div>
"""
def _bring_section_html(output: HandoffOutput) -> str:
"""'Bring To The Visit' section — omitted entirely for legacy caches without the field."""
if not output.bring_checklist:
return ""
return "<h3>Bring To The Visit</h3>" + html_list(output.bring_checklist, css_class="tracker")
# Deterministic Arabic renderings for the fixed bring-checklist item pool.
# Prefix-matched because two items carry dynamic tails (meds, allergies).
# All Arabic strings in this module were reviewed in a native-quality pass
# (Gemini 2.5 Pro, 2026-06-09) for grammar, register, and clinical accuracy.
_ARABIC_BRING_PREFIXES = [
("your medication list", "قائمة الأدوية والجرعات (أو علب الأدوية نفسها)."),
("a written list of any medications", "قائمة مكتوبة بأي أدوية وجرعاتها، أو علب الأدوية نفسها."),
("exact allergy names", "أسماء المواد المسببة للحساسية، مع وصف دقيق لنوع التفاعل الذي حدث."),
("the actual imaging files", "ملفات الأشعة نفسها (أشعة سينية أو CBCT) على فلاشة أو الهاتف — الملفات وليس التقرير فقط."),
("dates of recent dental procedures", "تواريخ إجراءات الأسنان الأخيرة وبيانات التواصل مع العيادة المعالجة."),
("your current night guard", "واقي الأسنان الليلي أو الجبيرة أو المثبت — أحضر الجهاز نفسه."),
("the crown or fragment", "التاج أو الجزء المكسور في علبة نظيفة — لا تلصقه ولا تعده إلى مكانه."),
("a short pain log", "سجل قصير للألم: متى يحدث، ما الذي يثيره، ما الذي يخففه، ودرجة الألم من 10 يوميا."),
("this handoff", "هذا الملخص مطبوعا أو على هاتفك."),
]
def _arabic_bring_phrase(item: str) -> str:
lower = item.lower()
for prefix, arabic in _ARABIC_BRING_PREFIXES:
if lower.startswith(prefix):
return arabic
return item
def _arabic_phrase(item: str) -> str:
lower = item.lower()
if lower.startswith("pain score reported as"):
score = re.search(r"(\d+/10)", item)
return f"درجة الألم المبلغ عنها: {score.group(1) if score else escape(item)}"
if "biting" in lower or "chewing" in lower:
return "ألم أو إحساس بكدمة عند العض أو المضغ."
if "hot/cold" in lower:
return "حساسية للساخن أو البارد."
if "jaw/tmj" in lower:
return "أعراض في الفك أو مفصله يجب توضيحها للطبيب."
if "swelling" in lower:
return "تم الإبلاغ عن تورم."
if "fever" in lower or "unwell" in lower:
return "تم الإبلاغ عن حرارة أو شعور عام بالتعب."
if lower.startswith("area:"):
return "المنطقة: " + item.split(":", 1)[1].strip()
if lower.startswith("recent dental work:"):
value = item.split(":", 1)[1].strip()
# Translate the common deterministic "none" value; any other value is
# patient-entered free text and stays in the patient's own words.
if re.match(r"^(no recent|none\b|no\b)", value, re.IGNORECASE):
return "إجراءات الأسنان الأخيرة: لا يوجد"
return "إجراءات الأسنان الأخيرة: " + value
if "medications" in lower:
return "الأدوية أو المكملات التي يجب مراجعتها: " + item.split(":", 1)[-1].strip()
if "allergies" in lower:
return "الحساسية أو التفاعلات السابقة التي يجب مراجعتها: " + item.split(":", 1)[-1].strip()
return item
# Vetted Arabic twins for the DETERMINISTIC dentist-question bank and tracker
# items (app._base_questions / app._tracker_items). Exact-string keyed: a model-
# appended question has no twin and is never machine-translated — the Arabic
# card notes that extra case-specific questions appear in the English copy.
_ARABIC_QUESTIONS = {
"Which tooth or area should we prioritize examining first based on my history?":
"بناءً على تاريخي المرضي، أي سن أو منطقة لها الأولوية في الفحص؟",
"What findings on exam or imaging would help separate tooth, crown, bite, and jaw-muscle causes?":
"ما نتائج الفحص أو الأشعة التي تساعد في التمييز بين أسباب المشكلة: هل هي السن، التاج، الإطباق، أم عضلات الفك؟",
"What should I track after today's visit so we can tell whether symptoms are improving?":
"ما الذي يجب أن أتابعه بعد زيارة اليوم لنعرف ما إذا كانت الأعراض تتحسن؟",
"Can you check the crown margin, contacts, cement seal, and whether the bite is high?":
"هل يمكنك فحص حافة التاج، ونقاط التماس، وإحكام المادة اللاصقة، وما إذا كان الإطباق مرتفعًا؟",
"Should this tooth have an endodontic reassessment, and what records or X-rays would help?":
"هل هذا السن بحاجة لإعادة تقييم علاج العصب؟ وما هي السجلات أو الأشعة التي قد تساعد في ذلك؟",
"Can you check the bite with articulating paper and compare both sides of contact?":
"هل يمكنك فحص الإطباق بورق العضة الملون ومقارنة التلامس على الجانبين؟",
"Could jaw muscles or the TMJ be contributing, and do I need referral or conservative jaw care?":
"هل يمكن أن تكون عضلات الفك أو مفصل الفك سببًا في الأعراض؟ وهل أحتاج لتحويل طبي أو رعاية تحفظية للفك؟",
"Could my sinus symptoms and tooth symptoms be related, and how would we tell which is driving which?":
"هل يمكن وجود صلة بين أعراض الجيوب الأنفية وأعراض أسناني؟ وكيف نفرق أيهما المسبب للآخر؟",
"Does my temperature-sensitivity pattern help localize which tooth or surface to test first?":
"هل نمط حساسية أسناني للحرارة والبرودة يساعد في تحديد أي سن أو سطح نبدأ باختباره؟",
"What warning signs of spreading infection should send me to urgent care before our next appointment?":
"ما علامات انتشار العدوى التي تستدعي الذهاب إلى الطوارئ قبل موعدنا القادم؟",
"Do any of my current medications change what is safe or recommended at this visit?":
"هل أي من أدويتي الحالية يغير من سلامة أو توصيات العلاج لهذه الزيارة؟",
}
_ARABIC_TRACKER = {
"What was examined: tooth/area, bite, crown margin, gums, TMJ, imaging reviewed.":
"ما تم فحصه: السن/المنطقة، الإطباق، حافة التاج، اللثة، مفصل الفك، الأشعة التي روجعت.",
"What changed today: adjustment, medication advice, referral, imaging request, or watchful waiting.":
"ما الذي تغير اليوم: تعديل، نصيحة دوائية، تحويل، طلب أشعة، أو مراقبة وانتظار.",
"Pain score before/after visit and whether biting, temperature, or jaw symptoms changed.":
"درجة الألم قبل وبعد الزيارة، وهل تغيرت الأعراض عند العض، أو الحساسية للحرارة والبرودة، أو أعراض الفك.",
"Next step, owner, and follow-up date.":
"الخطوة التالية، المسؤول عنها، وتاريخ المتابعة.",
}
_ARABIC_EXTRA_QUESTIONS_NOTE = "توجد أسئلة إضافية خاصة بالحالة باللغة الإنجليزية."
_ARABIC_AS_WRITTEN = "(كما كتبه المريض)"
_ARABIC_CHARS = re.compile(r"[؀-ۿ]")
def _arabic_narrative(text: str) -> str:
"""Patient/model narrative is never machine-translated. When the text has no
Arabic script at all, isolate it as an LTR run and annotate it as the
patient's own wording so English under an Arabic heading reads as a choice,
not a rendering bug."""
if _ARABIC_CHARS.search(text):
return escape(text)
return f'<bdi>{escape(text)}</bdi> <span class="as-written">{_ARABIC_AS_WRITTEN}</span>'
def _arabic_card(output: HandoffOutput) -> str:
symptoms = [_arabic_phrase(item) for item in output.current_symptoms]
history = [_arabic_phrase(item) for item in output.dental_history]
medical = [_arabic_phrase(item) for item in output.medical_safety_notes]
safety = (
"توجد علامات سلامة تتطلب مناقشة أو تعاملًا سريعًا حسب درجة الخطورة."
if output.red_flags
else "لم تظهر علامة طوارئ من المعلومات التي أدخلتها، لكن هذا لا ينفي وجود مشكلة تحتاج لفحص طبيب الأسنان."
)
# Deterministic question bank twins; model-appended questions have no twin
# and are flagged as English-only rather than machine-translated.
questions = [_ARABIC_QUESTIONS[q] for q in output.dentist_questions if q in _ARABIC_QUESTIONS]
untranslated = len(output.dentist_questions) - len(questions)
if not questions:
questions = [
"ما السن أو المنطقة التي يجب فحصها أولا؟",
"هل المشكلة مرتبطة بالعضة أو التاج أو علاج العصب أو مفصل الفك؟",
"ما العلامات التي تستدعي مراجعة عاجلة؟",
]
questions_note = (
f'<p class="as-written">{_ARABIC_EXTRA_QUESTIONS_NOTE}</p>' if untranslated > 0 else ""
)
tracker = [_ARABIC_TRACKER[t] for t in output.after_visit_tracker if t in _ARABIC_TRACKER]
tracker_html = (
"<h3>متتبع ما بعد الزيارة</h3>" + html_list(tracker, css_class="tracker") if tracker else ""
)
# <bdi> isolates the LTR name/digits inside the RTL line — without it the
# surrounding parentheses render scrambled ("(38" with an orphaned paren).
patient_meta = f"المريض: <bdi>{escape(output.patient_name)}</bdi>" if output.patient_name else ""
if output.patient_age is not None:
age_str = f"العمر: <bdi>{output.patient_age}</bdi>"
patient_meta = f"{patient_meta} · {age_str}" if patient_meta else age_str
patient_meta_html = f'<div class="patient-line">{patient_meta}</div>' if patient_meta else ""
arabic_badges = (
f'<span class="doc-badge"><bdi>{escape(date.today().isoformat())}</bdi></span>'
'<span class="doc-badge">ليست تشخيصا</span>'
'<span class="doc-badge">من إعداد المريض</span>'
)
return f"""
<div class="handoff-card arabic" dir="rtl" lang="ar">
<div class="doc-band">
<div>
<div class="product">DENTAL SOAP · تحضير الزيارة</div>
<div class="artifact">ملخص زيارة طبيب الأسنان</div>
</div>
<div class="doc-meta">{patient_meta_html}<div class="doc-badges">{arabic_badges}</div></div>
</div>
<div class="doc-body">
<p>هذا ملخص لمساعدتك في التحضير للزيارة. لا يقدم تشخيصًا ولا يفسر الأشعة.</p>
<h3>الشكوى الرئيسية</h3>
<p>{_arabic_narrative(output.chief_concern)}</p>
<h3>ملخص القصة كما وردت</h3>
<p>{_arabic_narrative(output.concise_summary)}</p>
<h3>الأعراض الحالية</h3>
{html_list(symptoms)}
<h3>تاريخ الأسنان المهم</h3>
{html_list(history)}
<h3>ملاحظات السلامة الطبية</h3>
{html_list(medical)}
{"<h3>ما يجب إحضاره معك للزيارة</h3>" + html_list([_arabic_bring_phrase(i) for i in output.bring_checklist]) if output.bring_checklist else ""}
<h3>حالة السلامة</h3>
<p>{safety}</p>
<h3>أسئلة للطبيب</h3>
{html_list(questions)}
{questions_note}
{tracker_html}
</div>
</div>
"""
def _interrupt_card(output: HandoffOutput, language: str = "English") -> str:
first = output.red_flags[0]
evidence = html_list([span.quote for flag in output.red_flags for span in flag.evidence][:4])
# Arabic urgency line appears only when the patient chose Arabic or Bilingual —
# an unexplained RTL sentence inside an English card reads as a bug.
arabic_line = (
'<p dir="rtl" lang="ar" style="margin:6px 0 2px;text-align:right;font-size:14px">'
"يرجى طلب الرعاية الطبية فورًا — هذا تنبيه سلامة.</p>"
if language in {"Arabic", "Bilingual"}
else ""
)
return f"""
<div class="interrupt-card">
<div class="interrupt-band">Stop and seek urgent care</div>
{arabic_line}
<div class="interrupt-body">
{tier_pill(first.tier)}
<p><strong>{escape(first.title)}</strong></p>
<p>{escape(first.patient_message)}</p>
<h3>What to tell the clinician</h3>
<p>{escape(first.clinician_question)}</p>
<h3>Evidence from your story</h3>
{evidence}
<h3>Boundary</h3>
<p>Dental SOAP is pausing the normal handoff because a high-risk rule fired.
This is not a diagnosis; it is a safety escalation to in-person care.</p>
</div>
</div>
"""
def render_handoff_html(output: HandoffOutput, language: str, model_authored: bool = False) -> str:
if is_hard_interrupt(output):
return _interrupt_card(output, language)
count = len(output.red_flags)
flag_text = f"{count} safety flag{'s' if count != 1 else ''}" if count else "No urgent safety flags"
patient_meta = f"Patient: {escape(output.patient_name)}" if output.patient_name else ""
if output.patient_age is not None:
age_str = f"Age: {output.patient_age}"
patient_meta = f"{patient_meta} ({age_str})" if patient_meta else age_str
patient_meta_html = f'<div class="patient-line">{patient_meta}</div>' if patient_meta else ""
doc_id = hashlib.sha1(
f"{output.patient_name}|{output.chief_concern}|{date.today().isoformat()}".encode()
).hexdigest()[:8].upper()
# Provenance labeling: the narrative body may be AI-summarized; safety flags
# and evidence are always rule-computed and never model-authored.
provenance_badges = (
("AI-summarized narrative", "Safety flags rule-computed")
if model_authored
else ("Patient-authored", "Not a diagnosis")
)
badges = "".join(
f'<span class="doc-badge">{escape(text)}</span>'
for text in (date.today().isoformat(), *provenance_badges)
)
badges += f'<span class="doc-badge {"flag-alert" if count else "flag-clear"}">{escape(flag_text)}</span>'
html = f"""
<div class="handoff-card">
<div class="doc-band">
<div>
<div class="product">DENTAL SOAP · VISIT PREP</div>
<div class="artifact">{escape(output.artifact_label)}</div>
</div>
<div class="doc-meta">{patient_meta_html}<div class="doc-badges">{badges}</div></div>
</div>
<div class="doc-body">
<h3>Chief Concern</h3>
<p>{escape(output.chief_concern)}</p>
<h3>Concise Summary</h3>
<p>{escape(output.concise_summary)}</p>
<h3>Timeline / Story Evidence</h3>
{html_list(output.timeline)}
<h3>Current Symptoms</h3>
{html_list(output.current_symptoms)}
<h3>Relevant Dental History</h3>
{html_list(output.dental_history)}
<h3>Medical Safety Notes</h3>
{html_list(output.medical_safety_notes)}
<h3>Patient Goals</h3>
{html_list(output.patient_goals)}
{_bring_section_html(output)}
<h3>Questions For The Dentist</h3>
{numbered_list(output.dentist_questions)}
<h3>After-Visit Tracker</h3>
{html_list(output.after_visit_tracker, css_class="tracker")}
<div class="doc-foot">
<div class="doc-foot-meta">DENTAL SOAP &middot; {date.today().isoformat()} &middot; DOC {doc_id} &middot; AI-generated intake summary — for dental professional review only</div>
{escape(" · ".join(output.limitations))}<br>{escape(DISCLAIMER)}
</div>
</div>
</div>
"""
if language in {"Arabic", "Bilingual"}:
arabic = _arabic_card(output)
if language == "Arabic":
return arabic
# Side-by-side on wide screens (the bilingual money shot), stacked on
# narrow ones — .bilingual-wrap is a responsive grid in style.css.
return f'<div class="bilingual-wrap">{html}{arabic}</div>'
return html
def render_safety_html(output: HandoffOutput) -> str:
if not output.red_flags:
return """
<div class="safety-panel clear">
<div class="safety-kicker">Safety panel · rule-based</div>
<strong>Clear from provided answers.</strong>
<p>No urgent red flag was detected from the current story and structured fields.
This does not rule out a dental problem; bring the handoff to a licensed dentist.</p>
</div>
"""
body = [
'<div class="safety-panel">',
'<div class="safety-kicker">Safety panel · rule-based</div>',
"<strong>Deterministic rules fired.</strong>",
"<p>These warnings are computed from your answers, not guessed by the model.</p>",
]
for flag in output.red_flags:
evidence_items = "".join(
f'<div class="ev-span"><span class="ev-src">{escape(span.source)}</span>'
f"&ldquo;{escape(span.quote)}&rdquo;</div>"
for span in flag.evidence
)
# "Show your work": the evidence is tucked behind a disclosure that names
# the exact deterministic rule that fired — clinician-grade provenance.
# A closed <details> never renders its content on paper, so the printed
# card carries a static print-only copy: a printed urgent flag must show
# the patient's triggering words (FIELD_NOTES promise).
body.append(
f"""
<div class="flag">
{tier_pill(flag.tier)}
<div class="flag-title">{escape(flag.title)}</div>
<div class="flag-body">{escape(flag.patient_message)}</div>
<details class="flag-rule">
<summary><span class="flag-rule-k">Why this fired</span><span class="flag-rule-id">rule &middot; {escape(flag.rule_id)}</span></summary>
<div class="flag-evidence">{evidence_items}</div>
</details>
<div class="flag-evidence flag-evidence-print">
<div class="ev-span"><span class="ev-src">rule</span>{escape(flag.rule_id)}</div>
{evidence_items}
</div>
</div>
"""
)
body.append("</div>")
return "".join(body)
def plain_text_handoff(output: HandoffOutput) -> str:
"""Plain-text version of the handoff for email body and PDF source."""
lines: list[str] = [
f"{output.product_name}{output.artifact_label}",
f"Date: {date.today().isoformat()} · Patient-authored · Not a diagnosis",
"",
]
if output.red_flags:
lines.append("SAFETY FLAGS (rule-based):")
for flag in output.red_flags:
lines.append(f"- [{tier_label(flag.tier)}] {flag.title}: {flag.patient_message}")
lines.append("")
def section(title: str, items: list[str], numbered: bool = False, boxes: bool = False) -> None:
lines.append(title.upper())
for i, item in enumerate(items, start=1):
prefix = f"{i}. " if numbered else ("[ ] " if boxes else "- ")
lines.append(f"{prefix}{item}")
lines.append("")
section("Chief concern", [output.chief_concern])
section("Concise summary", [output.concise_summary])
section("Timeline / story evidence", output.timeline)
section("Current symptoms", output.current_symptoms)
section("Relevant dental history", output.dental_history)
section("Medical safety notes", output.medical_safety_notes)
section("Patient goals", output.patient_goals)
if output.bring_checklist:
section("Bring to the visit", output.bring_checklist, boxes=True)
section("Questions for the dentist", output.dentist_questions, numbered=True)
section("After-visit tracker", output.after_visit_tracker, boxes=True)
lines.append(" · ".join(output.limitations))
lines.append(DISCLAIMER)
return "\n".join(lines)
def initial_agent_dashboard_html() -> str:
return """
<div class="agent-dashboard no-print">
<div class="agent-dashboard-head">
<div>
<div class="agent-dashboard-title">One 4B Model &middot; Bounded Agent Workflow</div>
<div class="agent-dashboard-note">The model organizes language. Deterministic code owns safety and validation.</div>
</div>
<span class="workflow-route">Ready</span>
</div>
<div class="agent-list">
<div class="agent-row">
<span><span class="agent-name">History agent <span class="agent-type type-llm">LLM</span></span><span class="agent-role">Adaptive, bounded intake questions</span></span>
<span class="agent-status status-ready">Available</span>
</div>
<div class="agent-row">
<span><span class="agent-name">Handoff agent <span class="agent-type type-llm">LLM</span></span><span class="agent-role">Six narrative fields only</span></span>
<span class="agent-status status-idle">Waiting</span>
</div>
<div class="agent-row">
<span><span class="agent-name">Safety sentinel <span class="agent-type type-rule">RULE</span></span><span class="agent-role">Rules, urgency tiers, evidence spans</span></span>
<span class="agent-status status-active">Always on</span>
</div>
<div class="agent-row">
<span><span class="agent-name">Output guard <span class="agent-type type-rule">RULE</span></span><span class="agent-role">Pydantic schema + claim filter</span></span>
<span class="agent-status status-idle">Waiting</span>
</div>
</div>
</div>
"""
def render_agent_dashboard(
status1: str,
status2: str,
status3: str,
status4: str,
*,
route_note: str = "",
) -> str:
def pill(status: str) -> str:
classes = {
"Available": "status-ready",
"Complete": "status-complete",
"Cached": "status-complete",
"Checked": "status-complete",
"Passed": "status-complete",
"Clear": "status-complete",
"Always on": "status-active",
"Partial": "status-template",
"Template": "status-template",
"Fallback": "status-template",
"Skipped": "status-bypass",
"No model text": "status-bypass",
"Waiting": "status-idle",
}
# Live sentinel outcome, e.g. "2 flags found" — render as active so the
# real flag count stands out as the run's most important telemetry.
if status.endswith("found"):
css_class = "status-active"
else:
css_class = classes.get(status, "status-idle")
return f'<span class="agent-status {css_class}">{escape(status)}</span>'
return f"""
<div class="agent-dashboard no-print">
<div class="agent-dashboard-head">
<div>
<div class="agent-dashboard-title">One 4B Model &middot; Bounded Agent Workflow</div>
<div class="agent-dashboard-note">{escape(route_note)}</div>
</div>
<span class="workflow-route">Auditable run</span>
</div>
<div class="agent-list">
<div class="agent-row">
<span><span class="agent-name">History agent <span class="agent-type type-llm">LLM</span></span><span class="agent-role">Adaptive, bounded intake questions</span></span>
{pill(status1)}
</div>
<div class="agent-row">
<span><span class="agent-name">Handoff agent <span class="agent-type type-llm">LLM</span></span><span class="agent-role">Drafts only validated narrative fields</span></span>
{pill(status2)}
</div>
<div class="agent-row">
<span><span class="agent-name">Safety sentinel <span class="agent-type type-rule">RULE</span></span><span class="agent-role">Rules, urgency tiers, evidence spans</span></span>
{pill(status3)}
</div>
<div class="agent-row">
<span><span class="agent-name">Output guard <span class="agent-type type-rule">RULE</span></span><span class="agent-role">Pydantic schema + claim filter</span></span>
{pill(status4)}
</div>
</div>
</div>
"""