"""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 = ( '' '' ) 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'{escape(tier_label(tier))}' def html_list(items: list[str], css_class: str = "") -> str: attr = f' class="{css_class}"' if css_class else "" return f"" + "".join(f"
  • {escape(item)}
  • " for item in items) + "" def numbered_list(items: list[str]) -> str: return "
      " + "".join(f"
    1. {escape(item)}
    2. " for item in items) + "
    " def step_head(num: int, title: str, sub: str) -> str: return ( f'
    {num}' f'
    {escape(title)}
    ' f'
    {escape(sub)}
    ' ) _PRIVACY_PULSE_STYLE = ( "" ) def header_html() -> str: pulse_dot = ( '' ) return ( '
    ' '
    ' f'
    {TOOTH_SVG}
    ' "
    " '
    Dental SOAP
    ' '
    Turn your dental story into a one-page, dentist-ready handoff — emergencies flagged by fixed rules, not the AI.
    ' "
    " "
    " '
    ' f"{pulse_dot}" " In-Space processing · no database · no external AI API" "
    " "
    " + _PRIVACY_PULSE_STYLE ) def rail_html() -> str: steps = ["Chief complaint", "Dental history", "Jaw, bite & TMJ", "Medical background"] chips = "".join( f'{i}{escape(label)}' for i, label in enumerate(steps, start=1) ) return f'
    {chips}
    ' def footer_html() -> str: return ( '
    ' 'Safety boundary' '' f"{escape(DISCLAIMER)} " "Fixed safety rules may advise urgent in-person care and cannot be suppressed by the model." "" "
    " ) 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 """
    Safety Sentinel · Rule-Based · Deterministic
    Waiting for your answers.

    Once you build your handoff, every answer is checked against deterministic clinical red-flag rules — computed from what you report, never guessed or hallucinated by the AI model.

    """ 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'
    ' for w in widths) return f"

    {label}

    {lines}
    " return f"""
    DENTAL SOAP · VISIT PREP
    Dentist Visit Handoff
    Awaiting your story
    This page writes itself from your story — load an example case or start the guided interview.
    {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,))}
    """ 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('
    ' for _ in range(lines)) return f'

    {label}

    {bars}
    ' return f"""
    DENTAL SOAP · VISIT PREP
    Composing your dentist-ready handoff — safety rules already ran
    {sec("CHIEF CONCERN", 1)} {sec("CONCISE SUMMARY", 2)} {sec("CURRENT SYMPTOMS", 3)} {sec("QUESTIONS FOR THE DENTIST", 2)}
    """ 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 "

    Bring To The Visit

    " + 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'{escape(text)} {_ARABIC_AS_WRITTEN}' 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'

    {_ARABIC_EXTRA_QUESTIONS_NOTE}

    ' if untranslated > 0 else "" ) tracker = [_ARABIC_TRACKER[t] for t in output.after_visit_tracker if t in _ARABIC_TRACKER] tracker_html = ( "

    متتبع ما بعد الزيارة

    " + html_list(tracker, css_class="tracker") if tracker else "" ) # isolates the LTR name/digits inside the RTL line — without it the # surrounding parentheses render scrambled ("(38" with an orphaned paren). patient_meta = f"المريض: {escape(output.patient_name)}" if output.patient_name else "" if output.patient_age is not None: age_str = f"العمر: {output.patient_age}" patient_meta = f"{patient_meta} · {age_str}" if patient_meta else age_str patient_meta_html = f'
    {patient_meta}
    ' if patient_meta else "" arabic_badges = ( f'{escape(date.today().isoformat())}' 'ليست تشخيصا' 'من إعداد المريض' ) return f"""
    DENTAL SOAP · تحضير الزيارة
    ملخص زيارة طبيب الأسنان
    {patient_meta_html}
    {arabic_badges}

    هذا ملخص لمساعدتك في التحضير للزيارة. لا يقدم تشخيصًا ولا يفسر الأشعة.

    الشكوى الرئيسية

    {_arabic_narrative(output.chief_concern)}

    ملخص القصة كما وردت

    {_arabic_narrative(output.concise_summary)}

    الأعراض الحالية

    {html_list(symptoms)}

    تاريخ الأسنان المهم

    {html_list(history)}

    ملاحظات السلامة الطبية

    {html_list(medical)} {"

    ما يجب إحضاره معك للزيارة

    " + html_list([_arabic_bring_phrase(i) for i in output.bring_checklist]) if output.bring_checklist else ""}

    حالة السلامة

    {safety}

    أسئلة للطبيب

    {html_list(questions)} {questions_note} {tracker_html}
    """ 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 = ( '

    ' "يرجى طلب الرعاية الطبية فورًا — هذا تنبيه سلامة.

    " if language in {"Arabic", "Bilingual"} else "" ) return f"""
    Stop and seek urgent care
    {arabic_line}
    {tier_pill(first.tier)}

    {escape(first.title)}

    {escape(first.patient_message)}

    What to tell the clinician

    {escape(first.clinician_question)}

    Evidence from your story

    {evidence}

    Boundary

    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.

    """ 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'
    {patient_meta}
    ' 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'{escape(text)}' for text in (date.today().isoformat(), *provenance_badges) ) badges += f'{escape(flag_text)}' html = f"""
    DENTAL SOAP · VISIT PREP
    {escape(output.artifact_label)}
    {patient_meta_html}
    {badges}

    Chief Concern

    {escape(output.chief_concern)}

    Concise Summary

    {escape(output.concise_summary)}

    Timeline / Story Evidence

    {html_list(output.timeline)}

    Current Symptoms

    {html_list(output.current_symptoms)}

    Relevant Dental History

    {html_list(output.dental_history)}

    Medical Safety Notes

    {html_list(output.medical_safety_notes)}

    Patient Goals

    {html_list(output.patient_goals)} {_bring_section_html(output)}

    Questions For The Dentist

    {numbered_list(output.dentist_questions)}

    After-Visit Tracker

    {html_list(output.after_visit_tracker, css_class="tracker")}
    DENTAL SOAP · {date.today().isoformat()} · DOC {doc_id} · AI-generated intake summary — for dental professional review only
    {escape(" · ".join(output.limitations))}
    {escape(DISCLAIMER)}
    """ 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'
    {html}{arabic}
    ' return html def render_safety_html(output: HandoffOutput) -> str: if not output.red_flags: return """
    Safety panel · rule-based
    Clear from provided answers.

    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.

    """ body = [ '
    ', '
    Safety panel · rule-based
    ', "Deterministic rules fired.", "

    These warnings are computed from your answers, not guessed by the model.

    ", ] for flag in output.red_flags: evidence_items = "".join( f'
    {escape(span.source)}' f"“{escape(span.quote)}”
    " 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
    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"""
    {tier_pill(flag.tier)}
    {escape(flag.title)}
    {escape(flag.patient_message)}
    Why this firedrule · {escape(flag.rule_id)}
    {evidence_items}
    rule{escape(flag.rule_id)}
    {evidence_items}
    """ ) body.append("
    ") 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 """
    One 4B Model · Bounded Agent Workflow
    The model organizes language. Deterministic code owns safety and validation.
    Ready
    History agent LLMAdaptive, bounded intake questions Available
    Handoff agent LLMSix narrative fields only Waiting
    Safety sentinel RULERules, urgency tiers, evidence spans Always on
    Output guard RULEPydantic schema + claim filter Waiting
    """ 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'{escape(status)}' return f"""
    One 4B Model · Bounded Agent Workflow
    {escape(route_note)}
    Auditable run
    History agent LLMAdaptive, bounded intake questions {pill(status1)}
    Handoff agent LLMDrafts only validated narrative fields {pill(status2)}
    Safety sentinel RULERules, urgency tiers, evidence spans {pill(status3)}
    Output guard RULEPydantic schema + claim filter {pill(status4)}
    """