Spaces:
Running
Running
| """ | |
| WOOF — What's Our Output, Fido? | |
| Built for the Build Small Hackathon 2026. | |
| Upload a photo of what your dog left you this morning. | |
| Find out what they ate, whether to worry, and when to call the vet. | |
| Every dog owner has stood over a mystery pile at 6am thinking | |
| "what the FUCK did you eat." This app answers that question. | |
| Also tracks gut health over time because your dog can't tell you | |
| their stomach hurts but their output can. | |
| """ | |
| import gradio as gr | |
| import os | |
| import io | |
| import base64 | |
| from openai import OpenAI | |
| # --- NVIDIA Nemotron Nano Omni (sponsor model) --- | |
| NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "") | |
| NEMOTRON_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" | |
| nvidia_client = None | |
| if NVIDIA_API_KEY: | |
| nvidia_client = OpenAI( | |
| base_url="https://integrate.api.nvidia.com/v1", | |
| api_key=NVIDIA_API_KEY, | |
| ) | |
| print("NVIDIA Nemotron connected.") | |
| else: | |
| print("No NVIDIA_API_KEY — running in fallback mode.") | |
| # --- Dog Health Knowledge Base --- | |
| TOXIC_FOODS = { | |
| "chocolate": {"severity": "HIGH", "note": "Theobromine toxicity. Dark chocolate is worse. Call vet if large amount."}, | |
| "grapes": {"severity": "HIGH", "note": "Can cause kidney failure even in small amounts. Call vet immediately."}, | |
| "raisins": {"severity": "HIGH", "note": "Same as grapes. Kidney failure risk. Call vet."}, | |
| "xylitol": {"severity": "HIGH", "note": "Found in sugar-free gum/candy. Causes liver failure. Emergency vet NOW."}, | |
| "onion": {"severity": "MEDIUM", "note": "Damages red blood cells. Toxic in quantity."}, | |
| "garlic": {"severity": "MEDIUM", "note": "Related to onion toxicity. Small amounts less dangerous than onion."}, | |
| "avocado": {"severity": "LOW", "note": "Persin in skin/pit can cause vomiting. Flesh is mostly fine."}, | |
| "macadamia": {"severity": "MEDIUM", "note": "Causes weakness, vomiting, tremors. Usually resolves in 48h."}, | |
| "alcohol": {"severity": "HIGH", "note": "Even small amounts dangerous. Causes vomiting, breathing problems."}, | |
| "coffee": {"severity": "MEDIUM", "note": "Caffeine toxicity similar to chocolate."}, | |
| "cooked bones": {"severity": "HIGH", "note": "Can splinter and puncture intestines. Watch for bloody stool."}, | |
| "corn cob": {"severity": "HIGH", "note": "Cannot be digested. Common cause of intestinal blockage. Vet if suspected."}, | |
| "sock": {"severity": "HIGH", "note": "Fabric doesn't digest. Intestinal blockage risk. Vet if not passed in 24h."}, | |
| "toy": {"severity": "HIGH", "note": "Foreign body obstruction. Watch for vomiting, lethargy, no appetite."}, | |
| "grass": {"severity": "LOW", "note": "Normal. Dogs eat grass to settle their stomach or because they like it."}, | |
| "cat poop": {"severity": "LOW", "note": "Gross but usually harmless. Dogs do this. It's disgusting. They don't care."}, | |
| "dirt": {"severity": "LOW", "note": "Usually fine. Can indicate mineral deficiency if persistent."}, | |
| "plastic": {"severity": "MEDIUM", "note": "Small pieces may pass. Large pieces can obstruct. Monitor closely."}, | |
| } | |
| STOOL_GUIDE = { | |
| "normal": {"color": "chocolate brown", "icon": "🟢", "note": "Healthy. Good job, dog."}, | |
| "black": {"color": "black/tarry", "icon": "🔴", "note": "Could indicate upper GI bleeding. Vet today."}, | |
| "red": {"color": "red/bloody", "icon": "🔴", "note": "Lower GI bleeding or colitis. Vet today if more than streaks."}, | |
| "yellow": {"color": "yellow/greasy", "icon": "🟡", "note": "Possible liver, gallbladder, or pancreas issue. Vet if persistent."}, | |
| "orange": {"color": "orange", "icon": "🟡", "note": "Bile or food-related. Monitor. Vet if continues 2+ days."}, | |
| "green": {"color": "green", "icon": "🟡", "note": "Ate grass, or gallbladder issue. Usually fine if one-time."}, | |
| "white": {"color": "white/chalky", "icon": "🟡", "note": "Too much calcium/bone. Adjust diet. Vet if can't poop."}, | |
| "grey": {"color": "grey", "icon": "🔴", "note": "Possible pancreas or biliary issue. Vet visit recommended."}, | |
| "mucus": {"color": "mucus-covered", "icon": "🟡", "note": "Some mucus is normal. Excessive = inflammation. Vet if bloody."}, | |
| } | |
| VET_NOW = [ | |
| "bloody diarrhea lasting more than a few hours", | |
| "vomiting AND diarrhea together", | |
| "lethargy or won't get up", | |
| "bloated/distended abdomen", | |
| "known ingestion of xylitol, grapes, or large amount of chocolate", | |
| "foreign object suspected (sock, toy, corn cob)", | |
| "hasn't eaten in 24+ hours", | |
| "straining to poop with nothing coming out", | |
| ] | |
| SYSTEM_PROMPT = """You are WOOF — a dog gut health analyzer. A dog owner is describing what they found in their dog's output (poop or vomit) or uploading a photo of it. | |
| Your job: | |
| 1. Figure out what the dog likely ate or what's going on | |
| 2. Assess whether it's concerning | |
| 3. Tell them what to do in plain language | |
| Use the Bristol Stool Scale adapted for dogs: | |
| - Type 1-2: Hard pellets = constipation, needs more water/fiber | |
| - Type 3-4: Firm log, easy to pick up = ideal, healthy | |
| - Type 5: Soft blob, loses shape = diet change or mild issue | |
| - Type 6: Mushy, no shape = concerning if persistent | |
| - Type 7: Liquid = diarrhea, monitor closely | |
| Be direct. Be practical. Use humor LIGHTLY — these people are worried about their dog but also standing over poop at 6am. Don't be clinical. Be the friend who happens to know a lot about dog health. | |
| Return your analysis in this format: | |
| WHAT YOU'RE LOOKING AT: [brief assessment] | |
| LIKELY CAUSE: [what probably happened] | |
| CONCERN LEVEL: [🟢 LOW / 🟡 MONITOR / 🔴 VET NOW] | |
| WHAT TO DO: [specific next steps] | |
| WATCH FOR: [warning signs that mean escalate] | |
| FUN FACT: [one actually interesting dog gut fact to lighten the mood]""" | |
| def analyze_output(description, output_type, dog_size, photo): | |
| """Analyze dog output from description and/or photo.""" | |
| if not description and not photo: | |
| return """<div class="wf-empty">Describe what you found or upload a photo. No judgment here.</div>""" | |
| # Check for known toxic items in description | |
| alerts = [] | |
| if description: | |
| lower = description.lower() | |
| for item, info in TOXIC_FOODS.items(): | |
| if item in lower: | |
| alerts.append(f"⚠️ {item.upper()}: {info['note']} (Severity: {info['severity']})") | |
| # Check for color keywords | |
| color_match = None | |
| if description: | |
| lower = description.lower() | |
| for key, info in STOOL_GUIDE.items(): | |
| if key in lower: | |
| color_match = info | |
| # Build the prompt | |
| context_parts = [] | |
| if output_type: context_parts.append(f"Type: {output_type}") | |
| if dog_size: context_parts.append(f"Dog size: {dog_size}") | |
| context = " | ".join(context_parts) | |
| user_text = f"My dog's {output_type or 'poop'}: {description or 'see photo'}" | |
| if context: user_text += f"\n({context})" | |
| # Try Nemotron | |
| if nvidia_client: | |
| try: | |
| content = [{"type": "text", "text": user_text}] | |
| if photo: | |
| buffered = io.BytesIO() | |
| photo.save(buffered, format="JPEG", quality=85) | |
| b64 = base64.b64encode(buffered.getvalue()).decode() | |
| content.append({ | |
| "type": "image_url", | |
| "image_url": {"url": f"data:image/jpeg;base64,{b64}"} | |
| }) | |
| response = nvidia_client.chat.completions.create( | |
| model=NEMOTRON_MODEL, | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": content} | |
| ], | |
| max_tokens=600, | |
| temperature=0.3, | |
| ) | |
| raw = response.choices[0].message.content.strip() | |
| return format_analysis(raw, alerts, color_match) | |
| except Exception as e: | |
| print(f"Nemotron error: {e}") | |
| return format_analysis(fallback_analysis(description, output_type), alerts, color_match) | |
| else: | |
| return format_analysis(fallback_analysis(description, output_type), alerts, color_match) | |
| def fallback_analysis(description, output_type): | |
| """Rule-based fallback.""" | |
| if not description: | |
| return """WHAT YOU'RE LOOKING AT: Can't tell without a description or working AI model. | |
| LIKELY CAUSE: Unknown | |
| CONCERN LEVEL: 🟡 MONITOR | |
| WHAT TO DO: Describe what you see — color, consistency, anything unusual in it. | |
| WATCH FOR: Blood, lethargy, vomiting, or loss of appetite. | |
| FUN FACT: Dogs poop in alignment with Earth's magnetic field. North-south. Scientists confirmed this. Nobody knows why.""" | |
| lower = description.lower() | |
| if any(w in lower for w in ["blood", "bloody", "red"]): | |
| return """WHAT YOU'RE LOOKING AT: Blood in the output. This needs attention. | |
| LIKELY CAUSE: Could be colitis, parasites, foreign object, or dietary issue. Bright red = lower GI. Dark/tarry = upper GI (more serious). | |
| CONCERN LEVEL: 🔴 VET NOW if more than small streaks or if it continues. | |
| WHAT TO DO: Note the color (bright red vs dark), amount, and how long it's been happening. Call your vet today. | |
| WATCH FOR: Lethargy, loss of appetite, vomiting, or worsening. These make it urgent. | |
| FUN FACT: A dog's GI tract is about 5x their body length. That's a lot of pipe to troubleshoot.""" | |
| elif any(w in lower for w in ["liquid", "watery", "diarrhea"]): | |
| return """WHAT YOU'RE LOOKING AT: Diarrhea. Very common. Usually resolves on its own. | |
| LIKELY CAUSE: Dietary change, ate something questionable, stress, or mild infection. | |
| CONCERN LEVEL: 🟡 MONITOR for 24 hours. | |
| WHAT TO DO: Withhold food for 12 hours (water always available). Then bland diet — boiled chicken and rice, small portions. Pumpkin puree (plain, not pie filling) helps firm things up. | |
| WATCH FOR: Blood, vomiting, lethargy, or diarrhea lasting more than 48 hours. Then it's vet time. | |
| FUN FACT: Dogs have about 1,700 bacterial species in their gut. Yours has about 1,000. Their microbiome is more diverse than yours.""" | |
| elif any(w in lower for w in ["grass", "green"]): | |
| return """WHAT YOU'RE LOOKING AT: Grass in the output. Classic. | |
| LIKELY CAUSE: Your dog ate grass. They do this. Theories: settling an upset stomach, boredom, they just like it, or instinct from wild ancestors. | |
| CONCERN LEVEL: 🟢 LOW. This is normal dog behavior. | |
| WHAT TO DO: Nothing unless it's happening constantly. Frequent grass eating CAN indicate nausea or dietary deficiency. | |
| WATCH FOR: Vomiting after eating grass (sometimes that's the point — they're self-medicating), or doing it obsessively. | |
| FUN FACT: About 80% of dogs eat grass regularly. Only about 25% vomit after. The rest are just vibing.""" | |
| else: | |
| return """WHAT YOU'RE LOOKING AT: Need more detail for a specific read. | |
| LIKELY CAUSE: Hard to say without more info. | |
| CONCERN LEVEL: 🟡 MONITOR | |
| WHAT TO DO: Note the color, consistency (hard pellets? soft? liquid?), any foreign material visible, and frequency. The more detail, the better the read. | |
| WATCH FOR: Blood, mucus, foreign objects, very dark/tarry color, or any change in your dog's behavior. | |
| FUN FACT: The average dog poops 1-5 times per day. If your dog suddenly changes frequency, that tells you something even if the poop looks normal.""" | |
| def format_analysis(text, alerts, color_match): | |
| """Format into the WOOF output.""" | |
| alert_html = "" | |
| if alerts: | |
| alert_items = "".join(f'<div class="wf-alert-item">{a}</div>' for a in alerts) | |
| alert_html = f'<div class="wf-alerts">{alert_items}</div>' | |
| color_html = "" | |
| if color_match: | |
| color_html = f'<div class="wf-color">{color_match["icon"]} Color match: {color_match["color"]} — {color_match["note"]}</div>' | |
| # Parse sections from response | |
| sections = {} | |
| current = None | |
| for line in text.split('\n'): | |
| upper = line.strip().upper() | |
| if "LOOKING AT" in upper: current = "looking"; continue | |
| elif "LIKELY CAUSE" in upper: current = "cause"; continue | |
| elif "CONCERN LEVEL" in upper: current = "concern"; continue | |
| elif "WHAT TO DO" in upper: current = "do"; continue | |
| elif "WATCH FOR" in upper: current = "watch"; continue | |
| elif "FUN FACT" in upper: current = "fun"; continue | |
| if current and line.strip(): | |
| sections[current] = sections.get(current, "") + line.strip() + " " | |
| concern = sections.get("concern", "🟡 MONITOR").strip() | |
| concern_class = "wf-red" if "🔴" in concern or "VET" in concern.upper() else "wf-yellow" if "🟡" in concern else "wf-green" | |
| html = f""" | |
| <div class="wf-container"> | |
| {alert_html} | |
| {color_html} | |
| <div class="wf-concern {concern_class}"> | |
| {concern} | |
| </div> | |
| <div class="wf-section"> | |
| <div class="wf-icon">🔍</div> | |
| <div class="wf-content"> | |
| <div class="wf-title">What you're looking at</div> | |
| <div class="wf-body">{sections.get('looking', 'Analyzing...')}</div> | |
| </div> | |
| </div> | |
| <div class="wf-section"> | |
| <div class="wf-icon">🤔</div> | |
| <div class="wf-content"> | |
| <div class="wf-title">Likely cause</div> | |
| <div class="wf-body">{sections.get('cause', 'Need more info.')}</div> | |
| </div> | |
| </div> | |
| <div class="wf-section"> | |
| <div class="wf-icon">📋</div> | |
| <div class="wf-content"> | |
| <div class="wf-title">What to do</div> | |
| <div class="wf-body">{sections.get('do', 'Monitor and note any changes.')}</div> | |
| </div> | |
| </div> | |
| <div class="wf-section"> | |
| <div class="wf-icon">👀</div> | |
| <div class="wf-content"> | |
| <div class="wf-title">Watch for</div> | |
| <div class="wf-body">{sections.get('watch', 'Blood, lethargy, loss of appetite.')}</div> | |
| </div> | |
| </div> | |
| <div class="wf-section wf-fun"> | |
| <div class="wf-icon">🐕</div> | |
| <div class="wf-content"> | |
| <div class="wf-body">{sections.get('fun', 'Dogs can smell about 10,000 times better than humans. Yes, even THAT.')}</div> | |
| </div> | |
| </div> | |
| <div class="wf-vet-box"> | |
| <div class="wf-vet-title">Call the vet immediately if:</div> | |
| <ul class="wf-vet-list"> | |
| <li>Bloody diarrhea lasting more than a few hours</li> | |
| <li>Vomiting AND diarrhea together</li> | |
| <li>Bloated or distended abdomen</li> | |
| <li>Lethargy or won't get up</li> | |
| <li>Known ingestion of xylitol, grapes, or chocolate</li> | |
| <li>Straining with nothing coming out</li> | |
| </ul> | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| # --- CSS --- | |
| CUSTOM_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&display=swap'); | |
| .wf-empty { | |
| text-align: center; padding: 40px; color: #888; | |
| font-family: 'Quicksand', sans-serif; font-size: 1.1em; | |
| } | |
| .wf-container { | |
| font-family: 'Quicksand', sans-serif; | |
| background: #FAFAF7; border-radius: 16px; padding: 24px; | |
| max-width: 700px; margin: 0 auto; | |
| } | |
| .wf-alerts { | |
| background: #FFF3F0; border: 1px solid #E07A5F; border-radius: 10px; | |
| padding: 14px; margin-bottom: 16px; | |
| } | |
| .wf-alert-item { color: #C0392B; font-weight: 600; padding: 4px 0; font-size: 0.95em; } | |
| .wf-color { | |
| background: #F5F5F0; border-radius: 8px; padding: 10px 14px; | |
| margin-bottom: 16px; font-size: 0.9em; color: #666; | |
| } | |
| .wf-concern { | |
| text-align: center; font-size: 1.3em; font-weight: 700; | |
| padding: 16px; border-radius: 12px; margin-bottom: 20px; | |
| } | |
| .wf-green { background: #E8F5E9; color: #2E7D32; } | |
| .wf-yellow { background: #FFF8E1; color: #F57F17; } | |
| .wf-red { background: #FFEBEE; color: #C62828; } | |
| .wf-section { | |
| display: flex; gap: 14px; padding: 14px 0; | |
| border-bottom: 1px solid #EEECE8; align-items: flex-start; | |
| } | |
| .wf-section:last-of-type { border-bottom: none; } | |
| .wf-icon { font-size: 1.4em; min-width: 32px; text-align: center; } | |
| .wf-title { | |
| font-size: 0.75em; font-weight: 700; color: #999; | |
| text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px; | |
| } | |
| .wf-body { font-size: 0.95em; line-height: 1.7; color: #333; } | |
| .wf-fun { border-top: 1px dashed #DDD; margin-top: 8px; padding-top: 12px; } | |
| .wf-fun .wf-body { color: #888; font-style: italic; font-size: 0.85em; } | |
| .wf-vet-box { | |
| background: #FFF3F0; border-radius: 10px; padding: 16px; | |
| margin-top: 20px; border-left: 4px solid #E07A5F; | |
| } | |
| .wf-vet-title { font-weight: 700; color: #C0392B; font-size: 0.85em; margin-bottom: 8px; } | |
| .wf-vet-list { font-size: 0.85em; color: #666; padding-left: 20px; line-height: 1.8; } | |
| .gradio-container { | |
| background: #F5F4F0 !important; max-width: 800px !important; margin: 0 auto !important; | |
| } | |
| .gr-button-primary { | |
| background: #8B6914 !important; border: none !important; | |
| font-family: 'Quicksand', sans-serif !important; font-size: 1.05em !important; | |
| padding: 12px 36px !important; border-radius: 12px !important; | |
| } | |
| .gr-button-primary:hover { background: #7A5B10 !important; } | |
| footer { display: none !important; } | |
| """ | |
| # --- Examples --- | |
| EXAMPLES = [ | |
| ["Found something bright green and mushy this morning. She was in the yard a lot yesterday.", "Poop", "Medium (20-50 lbs)"], | |
| ["He threw up what looks like a piece of a sock. Only half of it.", "Vomit", "Large (50+ lbs)"], | |
| ["Liquid brown diarrhea three times since last night. She ate some table scraps.", "Poop", "Small (under 20 lbs)"], | |
| ["Normal looking but there's a weird white thing in it that moves. Like a grain of rice.", "Poop", "Medium (20-50 lbs)"], | |
| ["She got into the Halloween candy. Definitely ate some chocolate. Maybe 3-4 pieces.", "Vomit", "Small (under 20 lbs)"], | |
| ["Black and tarry. Smells worse than usual which is saying something.", "Poop", "Large (50+ lbs)"], | |
| ] | |
| # --- App --- | |
| with gr.Blocks(css=CUSTOM_CSS, title="WOOF", theme=gr.themes.Soft()) as app: | |
| gr.HTML(""" | |
| <div style="text-align:center; padding: 20px 0 8px;"> | |
| <h1 style="font-family: 'Quicksand', sans-serif; color: #333; font-size: 2.4em; font-weight: 700;"> | |
| 🐕 WOOF | |
| </h1> | |
| <p style="font-family: 'Quicksand', sans-serif; color: #999; font-size: 1.0em;"> | |
| What's Our Output, Fido? | |
| </p> | |
| <p style="font-family: 'Quicksand', sans-serif; color: #BBB; font-size: 0.8em; max-width: 450px; margin: 4px auto 0;"> | |
| Every dog owner has stood over a mystery pile at 6am. This app answers the question you're too embarrassed to Google. | |
| </p> | |
| </div> | |
| """) | |
| description = gr.Textbox( | |
| label="What did you find?", | |
| placeholder="Describe it. Color, consistency, anything weird in it. We've seen worse, promise.", | |
| lines=3, | |
| ) | |
| with gr.Row(): | |
| output_type = gr.Radio( | |
| choices=["Poop", "Vomit", "Both (oh no)"], | |
| value="Poop", | |
| label="Type", | |
| ) | |
| dog_size = gr.Radio( | |
| choices=["Small (under 20 lbs)", "Medium (20-50 lbs)", "Large (50+ lbs)"], | |
| value="Medium (20-50 lbs)", | |
| label="Dog size", | |
| ) | |
| photo = gr.Image(label="Upload a photo (optional — yes really)", type="pil") | |
| btn = gr.Button("Analyze the Situation", variant="primary", size="lg") | |
| output = gr.HTML() | |
| btn.click( | |
| fn=analyze_output, | |
| inputs=[description, output_type, dog_size, photo], | |
| outputs=[output], | |
| ) | |
| gr.Examples( | |
| examples=EXAMPLES, | |
| inputs=[description, output_type, dog_size], | |
| label="Common mysteries", | |
| ) | |
| gr.HTML(""" | |
| <div style="text-align:center; padding: 16px 0 4px; color: #CCC; font-size: 0.7em; font-family: 'Quicksand', sans-serif; line-height: 1.8;"> | |
| Not a substitute for veterinary care. When in doubt, call your vet.<br> | |
| Powered by NVIDIA Nemotron Nano Omni<br> | |
| <span style="color:#8B6914;">Heuremen — Build Small Hackathon 2026</span> | |
| </div> | |
| """) | |
| if __name__ == "__main__": | |
| app.launch() | |