thuna / app.py
Abinjithtk's picture
fix: green user bubbles, light bot bubbles, no code blocks, readable chat
45dc2fe
Raw
History Blame Contribute Delete
22.1 kB
"""
Thuna (തുണ) — Offline AI Health Companion for Elderly
Gradio App for Build Small Hackathon (Hugging Face × Gradio)
Voice-first Malayalam health companion powered by Gemma 4 E2B (2B params).
"""
import gradio as gr
import os
import re
from typing import List, Tuple
from datetime import datetime
from intent_parser import parse_intent
from health_store import HealthStore
from agent_engine import run_agent, AgentResult
# ═══════════════════════════════════════════════════════════════════════════
# LLM
# ═══════════════════════════════════════════════════════════════════════════
from huggingface_hub import InferenceClient
HF_TOKEN = os.environ.get("HF_TOKEN", "")
MODEL_ID = "google/gemma-3-4b-it"
client = None
try:
if HF_TOKEN:
client = InferenceClient(token=HF_TOKEN)
except Exception:
pass
SYSTEM_PROMPT = """You are "തുണ" (Thuna), a health companion for elderly people in Kerala, India.
CRITICAL RULE: You MUST reply ONLY in Malayalam (മലയാളം) script.
DO NOT use Tamil (தமிழ்). DO NOT use Hindi. DO NOT use English sentences.
Use English only for medicine names and numbers.
Keep answers to 2-3 short sentences. Be warm and caring.
Example correct response: "BP record ചെയ്തു. 140/90 — ചെറിയ ഉയർച്ച ഉണ്ട്. വിശ്രമിക്കുക."
Example WRONG (Tamil): "மாத்திரை எடுத்துக்கட்டா" ← NEVER do this
Example WRONG (Hindi): "Aapka BP theek hai" ← NEVER do this"""
def generate_llm_response(context: str, chat_history: List[dict]) -> str:
if not client:
return generate_fallback_response(context)
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for msg in chat_history[-6:]:
messages.append(msg)
messages.append({"role": "user", "content": context})
try:
response = client.chat_completion(
model=MODEL_ID, messages=messages, max_tokens=150, temperature=0.7,
)
text = re.sub(r'[\*\#]+', '', response.choices[0].message.content.strip())
# Reject if Tamil (U+0B80-0BFF) or Hindi (U+0900-097F) detected
tamil_chars = len(re.findall(r'[\u0B80-\u0BFF]', text))
hindi_chars = len(re.findall(r'[\u0900-\u097F]', text))
if tamil_chars > 3 or hindi_chars > 3:
return generate_fallback_response(context)
return text if text else generate_fallback_response(context)
except Exception:
return generate_fallback_response(context)
def generate_fallback_response(context: str) -> str:
lower = context.lower()
if "bp" in lower and ("record" in lower or "vital" in lower):
return "BP record ചെയ്തു. ശ്രദ്ധിക്കണം. 🩺"
if "sugar" in lower and ("record" in lower or "vital" in lower):
return "ഷുഗർ record ചെയ്തു. ട്രെൻഡ് ശ്രദ്ധിക്കാം. 🩸"
if "saved" in lower and "medication" in lower:
return "മരുന്ന് save ചെയ്തു. Reminder set ചെയ്തു. 💊"
if "medication" in lower or "medicine" in lower:
return "നിങ്ങളുടെ മരുന്നുകൾ ഇതാ."
if "took" in lower or "taken" in lower or "confirm" in lower:
return "നന്നായി! മരുന്ന് കഴിച്ചു. ✅"
if "reminder" in lower:
return "Reminder set ചെയ്തു. ⏰"
if "condition" in lower:
return "രേഖപ്പെടുത്തി. 🏥"
if "symptom" in lower or "report" in lower:
return "ലക്ഷണം record ചെയ്തു. ഡോക്ടറെ കാണുക. 🤒"
if "alert" in lower or "🚨" in lower:
return "⚠️ ശ്രദ്ധിക്കുക! ഡോക്ടറെ വിളിക്കുക."
return "എന്താ വിശേഷം? എന്തും ചോദിക്കാം. 😊"
# ═══════════════════════════════════════════════════════════════════════════
# STATE
# ═══════════════════════════════════════════════════════════════════════════
health_store = HealthStore()
def process_message(message: str, history: List[dict]) -> Tuple[List[dict], str]:
global health_store
if not message or not message.strip():
return history or [], ""
history = history or []
history.append({"role": "user", "content": message})
agent_result = run_agent(message, health_store)
llm_response = generate_llm_response(agent_result.context_for_llm, history)
parts = []
if agent_result.alert:
parts.append(agent_result.alert)
parts.append(llm_response)
if agent_result.badge:
parts.append(agent_result.badge)
if agent_result.tools_executed:
tools = " · ".join(f"✓{t['tool']}" for t in agent_result.tools_executed if t['success'])
if tools:
parts.append(f"⎯ {tools}")
history.append({"role": "assistant", "content": "\n".join(parts)})
return history, ""
def get_status_text() -> str:
global health_store
meds = health_store.get_active_medications()
vitals = health_store.get_recent_vitals(5)
conds = health_store.get_conditions()
rems = health_store.get_active_reminders()
adherence = health_store.get_adherence_rate()
lines = []
if conds:
lines.append("🏥 " + ", ".join(c.name for c in conds))
if meds:
lines.append("💊 " + " | ".join(f"{m.name} {m.dosage}" for m in meds))
else:
lines.append("💊 No medications")
if vitals:
labels = {'bp': 'BP', 'sugar': 'Sugar', 'spo2': 'SpO2', 'temperature': 'Temp', 'heart_rate': 'HR'}
for v in reversed(vitals[-3:]):
val = f"{v.primary}/{v.secondary}" if v.secondary else f"{v.primary}"
lines.append(f" 📊 {labels.get(v.vital_type, v.vital_type)}: {val}{v.unit}")
if rems:
lines.append(f"⏰ {len(rems)} reminder(s)")
lines.append(f"📈 Adherence: {adherence['rate']}%")
return "\n".join(lines)
def generate_report_text(rtype: str) -> str:
global health_store
if rtype == "Health Report":
return health_store.generate_health_report("Grandmother")
return health_store.generate_family_status("Grandmother")
def load_demo():
global health_store
health_store = HealthStore()
health_store.save_condition("Type 2 Diabetes", "moderate", "E11")
health_store.save_condition("Hypertension", "moderate", "I10")
health_store.save_medication("Metformin", "500mg", "twice daily", "", ["08:00", "20:00"], "After food")
health_store.save_medication("Amlodipine", "5mg", "once daily", "", ["08:00"], "Morning")
health_store.save_medication("Ecosprin", "75mg", "once daily", "", ["08:00"], "After food")
health_store.save_vital("bp", 138, 88, "mmHg")
health_store.save_vital("sugar", 165, 0, "mg/dL", "fasting")
health_store.save_vital("bp", 142, 92, "mmHg")
health_store.save_vital("sugar", 180, 0, "mg/dL", "post-meal")
health_store.mark_taken("Metformin")
health_store.mark_taken("Amlodipine")
welcome = (
"Demo loaded! 🎉\n\n"
"Grandmother: Diabetes + Hypertension\n"
"Meds: Metformin, Amlodipine, Ecosprin\n\n"
"Try: BP 145/92 · sugar 200 · took metformin · my medicines"
)
return [{"role": "assistant", "content": welcome}], get_status_text()
def reset_all():
global health_store
health_store = HealthStore()
return [{"role": "assistant", "content": "🌱 Fresh start! നമസ്കാരം, ഞാൻ തുണ. 😊"}], get_status_text()
# ═══════════════════════════════════════════════════════════════════════════
# CSS — Light theme, mobile-first, matching the Android app
# ═══════════════════════════════════════════════════════════════════════════
CSS = """
/* Force light theme everywhere */
.gradio-container, .dark, [data-testid] {
--background-fill-primary: #FAFBFC !important;
--background-fill-secondary: #FFFFFF !important;
--border-color-primary: #E8ECF0 !important;
--body-background-fill: #FAFBFC !important;
--block-background-fill: #FFFFFF !important;
--input-background-fill: #F5F7FA !important;
--body-text-color: #1F2937 !important;
--block-label-text-color: #6B7280 !important;
--color-accent: #0D7C66 !important;
}
.gradio-container {
max-width: 520px !important;
margin: 0 auto !important;
background: #FAFBFC !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif !important;
padding: 12px !important;
}
/* Header */
#header {
background: linear-gradient(135deg, #0D7C66, #065A4A);
padding: 18px 16px; border-radius: 16px; text-align: center;
margin-bottom: 12px;
}
#header h1 { color: white !important; font-size: 22px !important; margin: 0 !important; }
#header p { color: #D1FAE5 !important; font-size: 11px !important; margin: 4px 0 0 !important; }
/* Chat — light background */
#chatbox {
border-radius: 16px !important;
border: 1px solid #E8ECF0 !important;
background: #FFFFFF !important;
}
#chatbox .wrapper, #chatbox .message-wrap, #chatbox .messages { background: #FFFFFF !important; }
/* User bubble — GREEN */
#chatbox .user .message-bubble,
#chatbox .message-row.user .content,
.chatbot .user .message-bubble {
background: #0D7C66 !important; color: white !important;
border-radius: 16px 16px 4px 16px !important;
font-size: 15px !important; line-height: 1.5 !important;
padding: 10px 14px !important;
}
#chatbox .user .message-bubble *, .chatbot .user .message-bubble * { color: white !important; }
/* Bot bubble — LIGHT GRAY */
#chatbox .bot .message-bubble,
#chatbox .message-row.bot .content,
.chatbot .bot .message-bubble {
background: #F3F4F6 !important; color: #1F2937 !important;
border: 1px solid #E8ECF0 !important;
border-radius: 16px 16px 16px 4px !important;
font-size: 15px !important; line-height: 1.5 !important;
padding: 10px 14px !important;
}
#chatbox .bot .message-bubble *, .chatbot .bot .message-bubble * { color: #1F2937 !important; }
/* Code blocks — visible on both bubbles */
#chatbox code, .chatbot code {
background: rgba(0,0,0,0.08) !important; color: inherit !important;
border-radius: 4px !important; padding: 1px 5px !important; font-size: 13px !important;
}
#chatbox .user code, .chatbot .user code {
background: rgba(255,255,255,0.25) !important; color: white !important;
}
/* Chips row */
#chips { margin: 8px 0 !important; gap: 6px !important; }
#chips button {
border-radius: 16px !important; font-size: 13px !important; font-weight: 500 !important;
background: #F0FDF4 !important; border: 1px solid #D1FAE5 !important; color: #065A4A !important;
padding: 6px 12px !important; min-height: 32px !important;
}
#chips button:hover { background: #D1FAE5 !important; }
/* Input bar area */
#input-box textarea {
background: #F5F7FA !important; border: 1px solid #E8ECF0 !important;
border-radius: 22px !important;
padding: 10px 14px !important; font-size: 15px !important;
min-height: 40px !important; resize: none !important;
color: #1F2937 !important;
}
#input-box textarea::placeholder { color: #9CA3AF !important; }
#input-box textarea:focus {
border-color: #0D7C66 !important;
box-shadow: 0 0 0 2px rgba(13,124,102,0.1) !important;
}
#send-btn {
background: #0D7C66 !important; color: white !important; border-radius: 20px !important;
min-width: 40px !important; max-width: 40px !important;
min-height: 40px !important; max-height: 40px !important;
font-size: 16px !important; border: none !important;
}
#send-btn:hover { background: #065A4A !important; }
#mic-btn {
background: transparent !important; color: #0D7C66 !important;
border: 1px solid #D1FAE5 !important; border-radius: 20px !important;
min-width: 40px !important; max-width: 40px !important;
min-height: 40px !important; max-height: 40px !important;
font-size: 18px !important;
}
#mic-btn:hover { background: #F0FDF4 !important; }
/* Accordion — light */
.accordion {
border: 1px solid #E8ECF0 !important;
border-radius: 12px !important;
background: #FFFFFF !important;
margin-top: 8px !important;
}
.accordion > .label-wrap {
background: #FFFFFF !important;
color: #374151 !important;
padding: 10px 14px !important;
}
.accordion > .label-wrap span { color: #374151 !important; font-size: 13px !important; }
/* Status box */
#status-box textarea {
font-size: 12px !important; font-family: monospace !important;
background: #F8FAFB !important; border: 1px solid #E8ECF0 !important;
border-radius: 8px !important; color: #374151 !important;
}
/* Buttons inside accordions */
.accordion button {
border-radius: 10px !important; font-size: 13px !important;
}
/* Report */
#report-box textarea {
font-family: monospace !important; font-size: 11px !important;
background: #F8FAFB !important; color: #374151 !important;
}
/* Hide dark mode artifacts */
footer { display: none !important; }
.built-with { display: none !important; }
/* Audio component styling */
#voice-section audio { border-radius: 8px !important; }
#voice-section .wrap { background: #F8FAFB !important; border: 1px solid #E8ECF0 !important; border-radius: 10px !important; }
/* Image upload */
#voice-section .image-container { border-radius: 10px !important; border: 1px solid #E8ECF0 !important; }
"""
# ═══════════════════════════════════════════════════════════════════════════
# UI
# ═══════════════════════════════════════════════════════════════════════════
with gr.Blocks(css=CSS, title="Thuna — AI Health Companion", theme=gr.themes.Default()) as demo:
# Header
gr.HTML('<div id="header"><h1>🤝 തുണ — Thuna</h1><p>AI Health Companion • Gemma 4 E2B (2B) • Malayalam • Offline</p></div>')
# Chat
chatbot = gr.Chatbot(
value=[{"role": "assistant", "content": "നമസ്കാരം! 🙏 ഞാൻ തുണ.\n\nBP, ഷുഗർ, മരുന്ന് — എന്തും ചോദിക്കാം.\n\n💡 Try: BP 140/90 · sugar 180 · took metformin\n\n⚙️ Tap Load Demo in Health Status below."}],
type="messages", height=380, elem_id="chatbox", show_copy_button=True,
)
# Quick chips
with gr.Row(elem_id="chips"):
chip_bp = gr.Button("❤️ BP", size="sm")
chip_sugar = gr.Button("🩸 ഷുഗർ", size="sm")
chip_taken = gr.Button("💊 കഴിച്ചു", size="sm")
chip_meds = gr.Button("📋 മരുന്നുകൾ", size="sm")
chip_today = gr.Button("📆 ഇന്ന്", size="sm")
# Input bar: text + mic + send
with gr.Row():
msg = gr.Textbox(
placeholder="തുണയോട് പറയുക...",
show_label=False, scale=5, elem_id="input-box",
lines=1, max_lines=2,
)
mic_btn = gr.Button("🎤", elem_id="mic-btn", scale=0, min_width=40)
send = gr.Button("↑", elem_id="send-btn", scale=0, min_width=40)
# Hidden audio for voice recording (shown when mic is clicked)
with gr.Accordion("🎤 Voice Recording", open=False, visible=True) as voice_acc:
audio_in = gr.Audio(sources=["microphone"], type="filepath", label="Tap record, speak in Malayalam, then stop")
# Health Status
with gr.Accordion("📊 Health Status", open=False):
status_box = gr.Textbox(value=get_status_text(), label="", lines=7, interactive=False, elem_id="status-box")
with gr.Row():
demo_btn = gr.Button("🎬 Load Demo", size="sm", variant="primary")
refresh_btn = gr.Button("🔄", size="sm")
reset_btn = gr.Button("Reset", size="sm")
# Reports
with gr.Accordion("📄 Reports", open=False):
with gr.Row():
rtype = gr.Radio(["Health Report", "Family Status"], value="Health Report", label="", scale=3)
gen_btn = gr.Button("Generate", size="sm", variant="primary", scale=1)
report_box = gr.Textbox(label="", lines=12, interactive=False, elem_id="report-box", show_copy_button=True)
# Prescription
with gr.Accordion("📷 Scan Prescription", open=False):
img_in = gr.Image(type="filepath", label="Upload or take photo", sources=["upload", "webcam"], height=150)
# ═══════════════════════════════════════════════════════════════════════
# EVENTS
# ═══════════════════════════════════════════════════════════════════════
def on_submit(message, history):
history, _ = process_message(message, history)
return history, "", get_status_text()
def on_chip(text, history):
history, _ = process_message(text, history)
return history, get_status_text()
def on_voice(audio_path, history):
if not audio_path:
return history or [], get_status_text()
transcription = ""
try:
if client:
result = client.automatic_speech_recognition(audio_path, model="openai/whisper-large-v3")
transcription = result.get("text", "") if isinstance(result, dict) else str(result)
except Exception:
pass
if not transcription:
history = history or []
history.append({"role": "assistant", "content": "🎤 Voice received — for this web demo, type your message. Mobile app has full Malayalam STT."})
return history, get_status_text()
history, _ = process_message(transcription, history)
return history, get_status_text()
def on_image(image_path, history):
if not image_path:
return history or [], get_status_text()
history = history or []
history.append({"role": "user", "content": "📷 Prescription"})
history.append({"role": "assistant", "content": "📷 Got it! Type medications:\n• `metformin 500mg twice daily after food`\n• `amlodipine 5mg once daily`\n\nEach saves with auto-reminder. 💊"})
return history, get_status_text()
# Wire
msg.submit(on_submit, [msg, chatbot], [chatbot, msg, status_box])
send.click(on_submit, [msg, chatbot], [chatbot, msg, status_box])
chip_bp.click(lambda: "BP ", outputs=[msg])
chip_sugar.click(lambda: "sugar ", outputs=[msg])
chip_taken.click(lambda h: on_chip("took medicine", h), [chatbot], [chatbot, status_box])
chip_meds.click(lambda h: on_chip("my medicines", h), [chatbot], [chatbot, status_box])
chip_today.click(lambda h: on_chip("did I take medicine today", h), [chatbot], [chatbot, status_box])
demo_btn.click(lambda: load_demo(), outputs=[chatbot, status_box])
refresh_btn.click(get_status_text, outputs=[status_box])
reset_btn.click(lambda: reset_all(), outputs=[chatbot, status_box])
gen_btn.click(generate_report_text, [rtype], [report_box])
audio_in.stop_recording(on_voice, [audio_in, chatbot], [chatbot, status_box])
img_in.change(on_image, [img_in, chatbot], [chatbot, status_box])
# TTS — browser speaks responses in Malayalam
tts_js = """
() => {
const chatEl = document.querySelector('#chatbox');
if (!chatEl) return;
const observer = new MutationObserver(() => {
const msgs = chatEl.querySelectorAll('.bot .message-bubble');
if (!msgs.length) return;
const last = msgs[msgs.length - 1];
if (last.dataset.spoken === 'true') return;
last.dataset.spoken = 'true';
let text = (last.innerText || '')
.replace(/[✓⎯·🩺🩸💊⏰🏥🤒✅❤️📊📈🚨⚠️📷🎉🌱🙏]/g, '')
.replace(/```[\\s\\S]*?```/g, '').replace(/`[^`]*`/g, '')
.replace(/\\n+/g, '. ').trim();
if (text.length < 3 || text.length > 250) return;
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.rate = 0.85; u.lang = 'ml-IN';
const voices = window.speechSynthesis.getVoices();
const ml = voices.find(v => v.lang.startsWith('ml'));
if (ml) u.voice = ml;
window.speechSynthesis.speak(u);
});
observer.observe(chatEl, { childList: true, subtree: true });
window.speechSynthesis.getVoices();
}
"""
demo.load(fn=None, js=tts_js)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)