diff --git a/community/pet-care-assistant/README.md b/community/pet-care-assistant/README.md index df81d74..18ee882 100644 --- a/community/pet-care-assistant/README.md +++ b/community/pet-care-assistant/README.md @@ -3,22 +3,88 @@ ![Author](https://img.shields.io/badge/Author-@megz2020-lightgrey?style=flat-square) ## What It Does -A comprehensive voice-first assistant for managing your pets' daily lives. Track feeding, medications, walks, weight changes, vet visits, and more — all through natural voice commands. Get emergency vet locations, weather safety alerts, and food recall notifications. + +A voice-first assistant for managing your pets' daily lives. Track feeding, medications, walks, weight changes, and vet visits. Get emergency vet locations, weather safety alerts, food recall notifications, and set reminders — all through natural voice commands. **Why This is an Ability (Not Just LLM Chat):** -- ✅ **Persists data** across sessions (pet profiles, activity logs) -- ✅ **Calls external APIs** for real-time info (weather, vets, recalls) -- ✅ **Tracks changes over time** (weight trends, medication schedules) -- ✅ **Multi-pet management** with automatic name resolution +- **Persists data** across sessions (pet profiles, activity logs, reminders) +- **Calls external APIs** for real-time info (weather, vets, recalls) +- **Tracks changes over time** (weight trends, medication schedules) +- **Multi-pet management** with automatic name resolution + +--- + +## Architecture + +All logic lives in a single `main.py` file, following the OpenHome Ability pattern. + +``` + User Voice Input + | + OpenHome STT + | + ┌─────▼──────────────────────────────────────────┐ + │ main.py │ + │ │ + │ ┌─────────────────────────────────────────┐ │ + │ │ PetCareAssistantCapability │ │ + │ │ - call() → session_tasks.create(run()) │ │ + │ │ - run() → intent router │ │ + │ │ - _handle_log / _handle_weather / etc. │ │ + │ └─────────────────────────────────────────┘ │ + │ │ + │ Helper classes (inlined): │ + │ ┌─────────────┐ ┌─────────────────────────┐ │ + │ │ LLMService │ │ PetDataService │ │ + │ │ - classify │ │ - load/save JSON │ │ + │ │ intent │ │ - resolve pet name │ │ + │ │ - extract │ │ - fuzzy match │ │ + │ │ values │ └─────────────────────────┘ │ + │ │ - is_exit │ │ + │ └─────────────┘ ┌─────────────────────────┐ │ + │ │ ActivityLogService │ │ + │ ┌─────────────┐ │ - add entry │ │ + │ │ExternalAPI │ │ - filter/query │ │ + │ │Service │ │ - enforce size limit │ │ + │ │ - weather │ └─────────────────────────┘ │ + │ │ - vet search│ │ + │ │ - recalls │ │ + │ │ - geocoding │ │ + │ └─────────────┘ │ + └─────────────────────────────────────────────────┘ + │ + ┌─────────────────────────┼──────────────────────────┐ + ▼ ▼ ▼ + google.serper.dev/maps api.open-meteo.com api.fda.gov + google.serper.dev/news geocoding-api.open-meteo.com ip-api.com + + Persistent Storage (JSON files on OpenHome server): + ┌──────────────────────┐ ┌────────────────────────┐ ┌──────────────────────┐ + │ petcare_pets.json │ │petcare_activity_log │ │petcare_reminders │ + │ │ │.json │ │.json │ + │ - Pet profiles │ │ │ │ │ + │ - Vet info │ │ - Activity entries │ │ - Reminder entries │ + │ - Location (lat/lon) │ │ - Capped at 500 │ │ - due_at timestamp │ + └──────────────────────┘ └────────────────────────┘ └──────────────────────┘ +``` + +### Data Flow + +1. User speaks → OpenHome STT converts to text +2. `main.py` checks for exit intent (3-tier fast check, then LLM fallback) +3. `LLMService.classify_intent_async()` identifies the mode (log, lookup, vet, weather, etc.) +4. `main.py` routes to the appropriate handler (`_handle_log`, `_handle_weather`, etc.) +5. Handlers call `ExternalAPIService` for live data (non-blocking via `asyncio.to_thread`) +6. Results are saved via `PetDataService` (backup-write-delete pattern for data safety) +7. OpenHome TTS speaks the response + +--- ## Suggested Trigger Words ### Activity Logging - "I fed [pet name]" -- "I just fed [pet]" -- "[Pet] ate breakfast" - "[Pet] got her medicine" -- "gave [pet] medication" - "we walked for 30 minutes" - "[Pet] weighs 48 pounds" - "log pet activity" @@ -28,7 +94,6 @@ A comprehensive voice-first assistant for managing your pets' daily lives. Track - "has [pet] had heartworm pill this month" - "how many walks this week" - "last vet visit" -- "check on [pet]" ### Emergency Vet Finder - "emergency vet" @@ -37,7 +102,6 @@ A comprehensive voice-first assistant for managing your pets' daily lives. Track ### Weather Safety - "is it safe outside for [pet]" -- "pet weather check" - "can I walk my dog" - "too hot for [pet]" @@ -46,271 +110,146 @@ A comprehensive voice-first assistant for managing your pets' daily lives. Track - "is my dog food safe" - "any food recalls" +### Reminders +- "remind me to feed Luna in 2 hours" +- "set a reminder for Max's medication at 8 PM" +- "what reminders do I have" +- "delete reminder" + ### Profile Management - "add a new pet" - "update pet info" - "change my vet" - "remove pet" +- "start over" / "reset everything" / "delete everything" + +--- ## Setup ### Step 1: Add Ability to OpenHome -1. Go to your [OpenHome Dashboard](https://app.openhome.com/dashboard) +1. Go to your OpenHome Dashboard 2. Navigate to **Abilities** section 3. Find **Pet Care Assistant** in the community library 4. Click **Add to Personality** -### Step 2: Configure API Key (Optional but Recommended) -The emergency vet finder requires a Google Places API key: +### Step 2: Configure Serper API Key (Optional but Recommended) +The emergency vet finder and food recall news headlines require a Serper API key. -1. **Get Google Places API Key:** - - Go to [Google Cloud Console](https://console.cloud.google.com/) - - Create a new project (or select existing) - - Enable **Places API (New)** - - Go to **Credentials** → **Create Credentials** → **API Key** - - Copy your API key +1. Go to [serper.dev](https://serper.dev) and sign up +2. Copy your API key (2,500 free queries included) +3. At the top of `main.py`, find: + ```python + SERPER_API_KEY = "your_serper_api_key_here" + ``` +4. Replace with your actual key -2. **Add Key to Code:** - - Open `main.py` in the ability - - Find line: `GOOGLE_PLACES_API_KEY = "your_google_places_api_key_here"` - - Replace with: `GOOGLE_PLACES_API_KEY = "YOUR_ACTUAL_KEY_HERE"` - -**Note:** If you skip this step, emergency vet search will fall back to showing your saved vet info instead. +**Note:** Without the key, emergency vet search shows only your saved vet, and news headlines are skipped. Weather checks and FDA recall data work without a key. ### Step 3: First-Time Onboarding -On first activation, the ability will walk you through setup via voice: - +On first activation, the assistant walks you through setup via voice: 1. Say any trigger phrase (e.g., "I fed my dog") -2. The assistant will detect it's your first time -3. Follow the voice prompts to add your first pet: - - Pet's name - - Species (dog, cat, etc.) - - Breed - - Age or birthday - - Weight - - Allergies (if any) - - Medications (if any) - - Regular vet info (optional) - - Your location (for weather & vet search) - -4. Add additional pets when prompted (optional) -5. Setup complete! You can now start logging activities. - -## How It Works - -### First-Time Users -1. Trigger the ability with any pet-related command -2. Guided voice onboarding collects pet profiles -3. Data saved to persistent JSON files -4. Ready to use immediately after setup - -### Returning Users -1. Trigger phrase is analyzed by AI to determine intent -2. **Quick Mode:** If intent is clear (e.g., "I fed Luna"), executes and offers one follow-up -3. **Full Mode:** If vague (e.g., "pet care"), enters multi-turn conversation loop -4. Idle detection exits gracefully after 2 empty responses - -### Data Storage -Two JSON files store all your data: -- `petcare_pets.json` — Pet profiles, vet info, user location -- `petcare_activity_log.json` — Activity entries (capped at 500, auto-trims oldest) - -## Features Breakdown +2. Follow the voice prompts to add your first pet (name, species, breed, age, weight, allergies, medications, vet, location) +3. Add additional pets when prompted (optional) + +--- + +## Features ### 1. Activity Logging -**What you can log:** -- **Feeding:** "I just fed Luna" -- **Medication:** "Luna got her flea medicine" -- **Walks:** "We walked for 30 minutes" -- **Weight:** "Luna weighs 48 pounds now" -- **Vet visits:** "Luna went to the vet today" -- **Grooming:** "Luna got a bath" -- **Other:** Any custom activity - -**How it works:** -1. AI extracts pet name, activity type, and details from voice -2. Timestamps the entry automatically -3. Saves to activity log -4. Confirms with brief spoken response -5. Offers quick re-log: "Anything else to log?" +Log feeding, medication, walks, weight, vet visits, grooming, or any custom activity. + +**Example:** +> **User:** "I just fed Luna" +> **AI:** "Got it. Logged Luna's feeding at 8:30 AM. Anything else to log?" ### 2. Quick Lookups -**Ask questions like:** -- "When did I last feed Luna?" -- "Has Max had his heartworm pill this month?" -- "How many walks this week?" +Ask questions about your pet's history — the AI searches the activity log and answers in natural language. -**How it works:** -1. AI searches activity log for relevant entries -2. LLM analyzes entries and answers in natural language -3. Provides context (e.g., "3 days ago", "this morning") +**Example:** +> **User:** "When did I last feed Luna?" +> **AI:** "You fed Luna this morning at 8:30 AM." ### 3. Weight Tracking -**Usage:** -- "Luna weighs 48 pounds now" (logs weight) -- "How much has Luna's weight changed?" (shows trend) +Weight entries are stored separately and summarized with trends. -**How it works:** -1. Weight logs are tagged separately in activity log -2. Updates pet profile with current weight -3. AI summarizes weight history and trends -4. Alerts to significant changes (if pattern detected) +**Example:** +> **User:** "How much has Luna's weight changed?" +> **AI:** "Luna is at 62 pounds. She's lost 3 pounds since February, trending down gradually." ### 4. Emergency Vet Finder -**Usage:** -- "Find an emergency vet" -- "I need a vet near me" +Shows your saved regular vet first, then searches nearby emergency vets via Serper Maps API. -**How it works:** -1. Shows your saved regular vet first (if configured) -2. Uses Google Places API to search nearby emergency vets -3. Prioritizes currently open locations -4. Speaks top 3 results with names, ratings, and open status -5. Offers addresses on request +- Prioritizes currently open locations +- Reads vet **names first** (short, interruptible), then asks which one you want details for +- Speaks phone number digit-by-digit (easy to write down) -**Fallback:** If no API key, shows only saved vet info. +**Example:** +> **User:** "Find an emergency vet" +> **AI:** "Your regular vet is Dr. Smith at 5, 1, 2, ..." +> **AI:** "I found 3 nearby vets: Austin Vet Emergency, BluePearl Pet Hospital, VCA Animal Hospital. Which one do you want the number for?" +> **User:** "Austin Vet" +> **AI:** "Austin Vet Emergency, open now, rated 4.5. Number: 5, 1, 2, ..." ### 5. Weather Safety Check -**Usage:** -- "Is it safe for Luna outside?" -- "Pet weather check" -- "Too hot for my dog?" - -**How it works:** -1. Fetches current weather from Open-Meteo (free API) -2. AI analyzes temperature, wind, UV index for pet safety -3. Considers pet species, breed, and weight -4. Applies safety thresholds: - - **>100°F:** Danger (heatstroke risk) - - **>90°F:** Warning (hot pavement, bring water) - - **<20°F:** Danger (too cold for more than a few minutes) - - **<32°F:** Warning for short-haired breeds - - **Wind >30mph:** Caution for small pets - - **UV >7:** Caution for light-colored/short-haired dogs -5. Provides breed-specific advice - -### 6. Food Recall Checker -**Usage:** -- "Any pet food recalls?" -- "Is my dog food safe?" - -**How it works:** -1. Queries openFDA API for recent adverse event reports -2. Filters by your pets' species (dog/cat) -3. AI summarizes findings in 2-3 sentences -4. Mentions specific brands if found -5. No alarmist language — factual summary - -### 7. Multi-Pet Support -**How it works:** -- If you have **one pet:** AI always uses that pet (no need to say name) -- If you have **multiple pets:** - - Include name in command: "I fed Luna" - - If name not mentioned, AI asks: "Which pet? Luna or Max?" - - Fuzzy name matching handles typos/variations - -## Example Conversations - -### First-Time User Onboarding -> **User:** "I fed my dog" -> **AI:** "Hi! I'm your pet care assistant. Let's get set up. What's your pet's name?" -> **User:** "Luna" -> **AI:** "Great! What kind of animal is Luna? Dog, cat, or something else?" -> **User:** "Dog" -> **AI:** "What breed is Luna?" -> **User:** "Golden Retriever" -> **AI:** "How old is Luna, or do you know their birthday?" -> **User:** "She's 3 years old" -> **AI:** "Roughly how much does Luna weigh?" -> **User:** "65 pounds" -> **AI:** "Does Luna have any allergies I should know about?" -> **User:** "No allergies" -> **AI:** "Is Luna on any medications?" -> **User:** "Heartgard monthly" -> **AI:** "Do you have a regular vet? If so, what's their name?" -> **User:** "Dr. Smith at Austin Animal Hospital" -> **AI:** "What's their phone number?" -> **User:** "512-555-1234" -> **AI:** "Last thing. What city are you in? This helps me check weather and find vets nearby." -> **User:** "Austin, Texas" -> **AI:** "All set! I've saved Luna's info. You can say things like 'I just fed Luna' to log activities, or 'find an emergency vet' if you ever need one. Do you have any other pets to add?" -> **User:** "No" -> **AI:** "Perfect! Take care of Luna!" - ---- - -### Activity Logging -> **User:** "I just fed Luna" -> **AI:** "Got it. Logged Luna's feeding at 8:30 AM. Anything else to log?" -> **User:** "She got her flea medicine" -> **AI:** "Got it. Logged Luna's medication at 8:31 AM. Anything else to log?" -> **User:** "No" +Fetches current conditions from Open-Meteo (free, no key required) and assesses safety for your specific pet's breed. ---- - -### Quick Lookup -> **User:** "When did I last feed Luna?" -> **AI:** "You fed Luna this morning at 8:30 AM." - ---- +Safety thresholds applied: +- **>100°F:** Danger (heatstroke risk, stay indoors) +- **>90°F:** Warning (hot pavement, bring water) +- **<20°F:** Danger (too cold for more than a few minutes) +- **<32°F:** Warning for short-haired breeds +- **Wind >30 mph:** Caution for small pets +- **UV >7:** Caution for light-colored/short-haired dogs -> **User:** "Has Luna had her heartworm pill this month?" -> **AI:** "Yes, Luna got her Heartgard on March 15th, about 2 weeks ago." - ---- - -### Weight Tracking -> **User:** "Luna weighs 62 pounds now" -> **AI:** "Got it. Logged Luna's weight at 8:45 AM. Anything else to log?" -> **User:** "No thanks" +### 6. Food Recall Checker +Runs openFDA adverse event queries and Serper News searches **in parallel** for performance. ---- +**Example:** +> **User:** "Any pet food recalls?" +> **AI:** "Let me check for recent pet food alerts." +> **AI:** "I found 2 recent FDA adverse event reports for dogs, involving Acme Dog Food. These are general reports, not necessarily recalls." -> **User:** "How much has Luna's weight changed?" -> **AI:** "Luna is currently at 62 pounds. She's lost 3 pounds since your last recording in February. Her weight is trending down gradually." +### 7. Reminders +Set time-based reminders using natural language — no external service needed (Python `datetime` only). ---- +**Supported time formats:** +- "in 2 hours" / "in 30 minutes" +- "at 6 PM" / "at 18:00" +- "tomorrow at 8 AM" -### Emergency Vet Finder -> **User:** "Find an emergency vet" -> **AI:** "Your regular vet is Dr. Smith at Austin Animal Hospital at 5, 1, 2, 5, 5, 5, 1, 2, 3, 4." -> **AI:** "Let me find emergency vets near you." -> **AI:** "I found 3 emergency vets near you. Austin Emergency Animal Hospital, open now, rated 4.5. North Austin Veterinary Emergency Center, open now, rated 4.3. Central Texas Veterinary Hospital, may be closed, rated 4.7. Want the address for any of them?" +**Example:** +> **User:** "Remind me to give Luna her medication in 2 hours" +> **AI:** "Got it. I'll remind you Thursday at 10:30 AM: Reminder for Luna: medication." ---- +Reminders are announced at startup if they're due or overdue. -### Weather Safety -> **User:** "Is it safe for Luna outside?" -> **AI:** "Let me check the weather for you." -> **AI:** "It's 95 degrees with high UV. It's warm for Luna, so keep walks short, stick to shaded areas, and bring water. Her Golden Retriever coat handles heat okay, but watch for panting." +### 8. Reset / Start Over +Wipe all data and start fresh with a new onboarding session. ---- +> **User:** "Start over" / "Reset everything" / "Delete everything" +> **AI:** "This will delete all pets, activity logs, and reminders — a completely fresh start. Say yes to confirm." -### Food Recall Check -> **User:** "Any pet food recalls?" -> **AI:** "Let me check for recent pet food alerts." -> **AI:** "I found 2 recent adverse event reports in the FDA database for dogs. One involved Blue Buffalo brand and another mentioned Purina Pro Plan. These are general reports, not necessarily recalls. Want more details?" +### 9. Multi-Pet Support +- One pet: AI always uses it (no need to say name) +- Multiple pets: AI asks "Which pet? Luna or Max?" if the name isn't mentioned +- Fuzzy name matching handles typos and abbreviations --- -### Multi-Pet Management -> **User:** "I fed my dog" -> **AI:** "Which pet? Luna or Max?" -> **User:** "Luna" -> **AI:** "Got it. Logged Luna's feeding at 6:00 PM. Anything else to log?" - -## Services & APIs Used +## Services & APIs | Service | Purpose | Authentication | Cost | |---------|---------|----------------|------| -| **Google Places API** | Emergency vet search | API key (user provides) | Pay-per-use (~$17/1000 requests) | +| **Serper Maps** | Emergency vet search | API key (user provides) | 2,500 free, then pay-per-use | +| **Serper News** | Food recall headlines | Same key | Same | | **Open-Meteo** | Weather data | None | Free | -| **ip-api.com** | Auto-detect location | None | Free (up to 45 req/min) | -| **openFDA** | Pet food recalls | None | Free | -| **LLM (OpenHome)** | Intent classification, data extraction | Built-in | Free (included) | -| **File Storage** | Pet profiles & activity logs | Built-in | Free (included) | +| **ip-api.com** | Auto-detect location from IP | None | Free (45 req/min) | +| **openFDA** | Pet food adverse events | None | Free | +| **OpenHome LLM** | Intent classification, data extraction | Built-in | Free (included) | +| **OpenHome File Storage** | Pet profiles, activity logs, reminders | Built-in | Free (included) | -**Note:** Only Google Places API requires setup. All other features work immediately. +--- ## Data Model @@ -326,12 +265,7 @@ Two JSON files store all your data: "birthday": "2021-03-15", "weight_lbs": 65, "allergies": [], - "medications": [ - { - "name": "Heartgard", - "frequency": "monthly" - } - ] + "medications": [{ "name": "Heartgard", "frequency": "monthly" }] } ], "vet_name": "Dr. Smith", @@ -365,151 +299,72 @@ Two JSON files store all your data: ] ``` -## Advanced Features - -### Automatic Log Trimming -- Activity log capped at **500 entries** -- Oldest entries auto-deleted when limit reached -- Prevents file bloat and performance issues - -### Idle Detection -- After 2 empty/silent responses, asks: "Still here if you need me. Otherwise I'll close." -- Waits for final confirmation before exiting -- Prevents hanging sessions - -### Delete-Then-Write Pattern -- All JSON saves use delete-before-write -- Prevents file corruption from append operations -- Ensures data integrity +### Reminders (`petcare_reminders.json`) +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "pet_name": "Luna", + "activity": "medication", + "message": "Reminder for Luna: medication.", + "due_at": "2024-03-15T18:00:00", + "created_at": "2024-03-15T10:00:00" + } +] +``` -### Voice-Friendly Phone Numbers -- Phone numbers spoken digit-by-digit -- Example: "5125551234" → "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" -- Easier for users to write down +--- -### Fuzzy Name Matching -- Handles typos and variations -- "Lona" → matches "Luna" -- "max" → matches "Max" -- Partial matches supported +## Technical Notes -## Troubleshooting +### Single-File Architecture +All logic — including helper classes (`ActivityLogService`, `PetDataService`, `LLMService`, `ExternalAPIService`) — is inlined directly into `main.py`. This follows the OpenHome platform requirement that only `main.py` is loaded; sibling module imports are not supported. -### "I don't have any pets set up yet" -**Problem:** You triggered the ability but haven't completed onboarding. +### Parallel LLM Extraction +Onboarding collects all raw user answers first (Phase 1), then extracts all values in a single `asyncio.gather()` call (Phase 2). This reduces onboarding from ~30 seconds to ~3-4 seconds. -**Solution:** The ability should automatically start onboarding. If not, say "add a new pet" to start setup. +### Non-blocking API Calls +All external HTTP calls use `asyncio.to_thread(requests.get/post, ...)` to avoid blocking the event loop. This allows exit commands to be detected between API calls. ---- +### Backup-Write-Delete Safety +All JSON saves use a backup-before-write pattern: +1. Copy existing file to `*.backup` +2. Write new data +3. Delete backup on success (backup retained on failure for manual recovery) -### Emergency vet search returns no results -**Possible causes:** -1. No Google Places API key configured -2. Location not detected correctly -3. No emergency vets within 10-mile radius +### Exit Detection (4 tiers) +1. **Force-exit phrases** — "exit petcare", "close petcare" (instant, phrase match) +2. **Exit commands** — "stop", "quit", "exit", "cancel" (word-boundary match) +3. **Exit responses** — "no", "done", "bye", "no thanks" (prefix match) +4. **LLM fallback** — short ambiguous inputs (≤4 words) sent to LLM classifier -**Solutions:** -1. Add API key in `main.py` -2. Say "update my location" to manually set city -3. Try "find a vet near me" (broader search) +### Voice-Friendly Phone Numbers +Numbers are spoken digit-by-digit: `5125551234` → "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" --- -### Weather check fails -**Possible causes:** -1. Location not configured -2. Open-Meteo API timeout - -**Solutions:** -1. Say "update my location" to set your city -2. Try again in a moment (API may be temporarily down) - ---- +## Troubleshooting -### "Which pet?" asked every time -**Problem:** You have multiple pets but aren't mentioning a name. +**"I don't have any pets set up yet"** +Say "add a new pet" to start setup manually. -**Solution:** Include pet name in your command: "I fed Luna" instead of just "I fed my dog" +**Emergency vet search returns no results** +1. No Serper API key configured — add one in `main.py` +2. Location not detected — say "update my location" to set your city ---- +**Weather check fails** +Say "update my location" to set your city, then try again. -### Voice transcription errors -**Problem:** AI doesn't understand your speech correctly. +**"Which pet?" asked every time** +Include the pet name in your command: "I fed Luna" not "I fed my dog". -**Solutions:** -- Speak clearly and at moderate pace -- Use simple phrasing: "I fed Luna" vs "I gave Luna her dinner" -- Spell out breed names if unusual: "L-A-B-R-A-D-O-R" +**Reminder not announced** +Reminders fire at ability startup. If the ability wasn't running when the reminder was due, it will announce on next startup. --- -### Activity log seems incomplete -**Problem:** Only recent 500 entries are kept. - -**Explanation:** Log auto-trims to prevent file bloat. This is intentional for performance. - -**Workaround:** For long-term tracking, export logs periodically (future feature). - ## Privacy & Security -### Data Storage -- All data stored **locally** in JSON files on OpenHome server -- No data sent to third parties except APIs you've configured -- Files namespaced with `petcare_` prefix to avoid collisions - -### API Data Sharing -- **Google Places:** Only location coordinates sent, no pet data -- **Open-Meteo:** Only location coordinates sent, no pet data -- **ip-api.com:** Only IP address (automatic), no pet data -- **openFDA:** Only pet species sent (dog/cat), no personal data - -### Deleting Data -To completely remove all pet data: -1. Say "remove pet" for each pet -2. Say "clear activity log" to delete all logs -3. Or manually delete `petcare_pets.json` and `petcare_activity_log.json` - -## Tips for Best Experience - -### ✅ Do's -- **Be specific with names:** "I fed Luna" vs "I fed my dog" -- **Use natural language:** "Luna got her flea medicine" works great -- **Log immediately:** Best to log activities right after they happen -- **Check weather before walks:** "Is it safe outside for Luna?" -- **Update weight monthly:** Helps track health trends - -### ❌ Don'ts -- **Don't use complex sentences:** Keep it simple for voice recognition -- **Don't forget to mention pet names:** Required with multiple pets -- **Don't expect perfect recall beyond 500 entries:** Log trimming is automatic -- **Don't rely solely on this for medical records:** Consult your vet's records - -## Extending the Ability - -Want to add custom features? Here are some ideas: - -### Custom Activity Types -Modify `ACTIVITY_TYPES` in `main.py`: -```python -ACTIVITY_TYPES = { - "feeding", - "medication", - "walk", - "weight", - "vet_visit", - "grooming", - "training", # NEW - "playtime", # NEW - "other", -} -``` - -### Medication Reminders -Add a scheduled task in `run()` to check medication schedules and speak reminders. - -### Integration with Pet Cameras -Use `exec_local_command()` to trigger pet camera snapshots when logging activities. - -### Export to CSV -Add a function to export activity log to CSV for spreadsheet analysis. - +- All data stored **locally** in JSON files on your OpenHome server +- No personal data sent to third parties — only coordinates to weather/vet APIs, IP to ip-api.com, and species to FDA +- Delete all data by saying "start over" and confirming, or manually removing the three `petcare_*.json` files diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 4d6a493..66a6445 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -1,76 +1,233 @@ +import asyncio import json -import os import re import uuid -from datetime import datetime +from datetime import datetime, timedelta import requests from src.agent.capability import MatchingCapability from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker -# ============================================================================= -# PET CARE ASSISTANT -# A voice-first ability that helps users track and manage their pets' daily -# lives. Stores pet info, logs activities, finds emergency vets, warns -# about dangerous weather, and checks for pet food recalls. -# -# What this ability does that the LLM cannot do alone: -# - Persist data across sessions (pet info, activity logs) -# - Call external APIs for real-time information (weather, vets, recalls) -# - Track activity over time (feeding, medication, walks, weight) -# ============================================================================= - -EXIT_WORDS = { - "stop", - "exit", - "quit", +# =========================================================================== +# Activity Log Service +# =========================================================================== + + +class ActivityLogService: + def __init__(self, worker, max_log_entries=500): + self.worker = worker + self.max_log_entries = max_log_entries + + def add_activity( + self, activity_log, pet_name, activity_type, details="", value=None + ): + entry = { + "pet_name": pet_name, + "type": activity_type, + "timestamp": datetime.now().isoformat(), + "details": details, + } + if value is not None: + entry["value"] = value + activity_log.append(entry) + if len(activity_log) > self.max_log_entries: + removed = len(activity_log) - self.max_log_entries + activity_log = activity_log[-self.max_log_entries:] + self.worker.editor_logging_handler.warning( + f"[PetCare] Activity log size limit reached. Removed {removed} old entries." + ) + return activity_log + + def get_recent_activities( + self, activity_log, pet_name=None, activity_type=None, limit=10 + ): + filtered = activity_log + if pet_name: + filtered = [a for a in filtered if a.get("pet_name") == pet_name] + if activity_type: + filtered = [a for a in filtered if a.get("type") == activity_type] + return list(reversed(filtered[-limit:])) + + +# =========================================================================== +# Pet Data Service +# =========================================================================== + + +class PetDataService: + def __init__(self, capability_worker, worker): + self.capability_worker = capability_worker + self.worker = worker + + async def load_json(self, filename, default=None): + backup_filename = f"{filename}.backup" + if await self.capability_worker.check_if_file_exists(filename, False): + try: + raw = await self.capability_worker.read_file(filename, False) + if not raw or not raw.strip(): + return default if default is not None else {} + return json.loads(raw) + except json.JSONDecodeError: + self.worker.editor_logging_handler.error( + f"[PetCare] Corrupt file {filename}, trying backup." + ) + await self.capability_worker.delete_file(filename, False) + if await self.capability_worker.check_if_file_exists(backup_filename, False): + try: + raw = await self.capability_worker.read_file(backup_filename, False) + if raw and raw.strip(): + data = json.loads(raw) + self.worker.editor_logging_handler.info( + f"[PetCare] Recovered {filename} from backup." + ) + await self.capability_worker.write_file( + filename, json.dumps(data), False + ) + await self.capability_worker.delete_file(backup_filename, False) + return data + except (json.JSONDecodeError, Exception) as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Backup {backup_filename} also corrupt: {e}" + ) + await self.capability_worker.delete_file(backup_filename, False) + return default if default is not None else {} + + async def save_json(self, filename, data): + backup_filename = f"{filename}.backup" + try: + if await self.capability_worker.check_if_file_exists(filename, False): + content = await self.capability_worker.read_file(filename, False) + await self.capability_worker.write_file(backup_filename, content, False) + self.worker.editor_logging_handler.info( + f"[PetCare] Created backup: {backup_filename}" + ) + await self.capability_worker.delete_file(filename, False) + await self.capability_worker.write_file(filename, json.dumps(data), False) + if await self.capability_worker.check_if_file_exists( + backup_filename, False + ): + await self.capability_worker.delete_file(backup_filename, False) + self.worker.editor_logging_handler.info( + f"[PetCare] Successfully saved {filename}, backup cleaned up" + ) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Failed to save {filename}: {e}" + ) + if await self.capability_worker.check_if_file_exists( + backup_filename, False + ): + self.worker.editor_logging_handler.warning( + f"[PetCare] Backup file {backup_filename} retained for recovery" + ) + raise + + def resolve_pet(self, pet_data, pet_name=None): + pets = pet_data.get("pets", []) + if not pets: + return None + if len(pets) == 1: + return pets[0] + if pet_name: + name_lower = pet_name.lower().strip() + for p in pets: + if p["name"].lower() == name_lower: + return p + for p in pets: + if p["name"].lower().startswith(name_lower) or name_lower.startswith( + p["name"].lower() + ): + return p + return pets[0] + + async def resolve_pet_async(self, pet_data, pet_name=None, is_exit_fn=None): + pets = pet_data.get("pets", []) + if not pets: + await self.capability_worker.speak("You don't have any pets set up yet.") + return None + if len(pets) == 1: + return pets[0] + if pet_name: + name_lower = pet_name.lower().strip() + for p in pets: + if p["name"].lower() == name_lower: + return p + for p in pets: + if p["name"].lower().startswith(name_lower) or name_lower.startswith( + p["name"].lower() + ): + return p + names = " or ".join(p["name"] for p in pets) + await self.capability_worker.speak(f"Which pet? {names}?") + response = await self.capability_worker.user_response() + if response and (not is_exit_fn or not is_exit_fn(response)): + return self.resolve_pet(pet_data, response) + return None + + +# =========================================================================== +# LLM Service +# =========================================================================== + +_FORCE_EXIT_PHRASES = [ + "exit petcare", + "close petcare", + "shut down pets", + "petcare out", +] + +_EXIT_COMMANDS = ["exit", "stop", "quit", "cancel"] + +_EXIT_RESPONSES = [ + "no", + "nope", "done", - "cancel", "bye", "goodbye", - "leave", - "that's all", - "that's it", + "thanks", + "thank you", "no thanks", - "i'm done", "nothing else", "all good", - "nope", "i'm good", -} - -PETS_FILE = "petcare_pets.json" -ACTIVITY_LOG_FILE = "petcare_activity_log.json" - -MAX_LOG_ENTRIES = 500 - -ACTIVITY_TYPES = { - "feeding", - "medication", - "walk", - "weight", - "vet_visit", - "grooming", - "other", -} - -# Replace with your own Google Places API key -GOOGLE_PLACES_API_KEY = "your_google_places_api_key_here" + "that's all", + "that's it", + "i'm done", + "we're done", +] -CLASSIFY_PROMPT = ( +_CLASSIFY_PROMPT = ( "You are an intent classifier for a pet care assistant. " - "The user manages one or more pets.\n" - "Known pets: {pet_names}.\n\n" - "Classify the user's intent. Return ONLY valid JSON with no markdown fences.\n\n" + "The user manages one or more pets. Known pets: {pet_names}.\n\n" + "CRITICAL INSTRUCTIONS:\n" + "1. Input comes from speech-to-text and WILL be garbled, noisy, or incomplete. " + "Always try to infer the most plausible intent, even from fragments.\n" + "2. Only return mode 'unknown' if you truly cannot extract ANY plausible intent " + "after trying hard. When in doubt, pick the closest match.\n" + "3. Ignore filler words, background noise, repeated words, or STT artifacts.\n" + "4. If the input sounds like an information request — contains words like 'give me', " + "'tell me', 'show me', 'get me', 'what', 'when', 'how', 'information', 'info', " + "'details', 'data', 'history', 'record' — classify as 'lookup', NOT 'unknown'.\n" + "5. If the input sounds like reporting an activity (feeding, walk, weight, medication, " + "grooming) — classify as 'log', NOT 'unknown'.\n" + "6. IMPORTANT - past vs future distinction:\n" + " - PAST events (already happened): 'I fed', 'we walked', 'she ate', 'went to vet', " + "'got groomed' -> 'log'\n" + " - FUTURE plans (haven't happened yet): 'I wanna go', 'need to go', 'going to', " + "'have an appointment', 'scheduled for', 'plan to', 'want to take', 'next Monday', " + "'tomorrow', 'next week', 'this Friday' -> 'reminder' with action 'set'\n" + " - If the input mentions a future time reference AND an activity, it is a REMINDER, not a LOG.\n\n" + "Return ONLY valid JSON with no markdown fences.\n\n" "Possible modes:\n" '- {{"mode": "log", "pet_name": "", "activity_type": "feeding|medication|walk|weight|vet_visit|grooming|other", "details": "", "value": null}}\n' - " (value is a number ONLY for weight entries, null otherwise)\n" '- {{"mode": "lookup", "pet_name": "", "query": ""}}\n' '- {{"mode": "emergency_vet"}}\n' '- {{"mode": "weather", "pet_name": ""}}\n' '- {{"mode": "food_recall"}}\n' - '- {{"mode": "edit_pet", "action": "add_pet|update_pet|change_vet|update_weight|remove_pet|clear_log", "pet_name": "", "details": ""}}\n' + '- {{"mode": "edit_pet", "action": "add_pet|update_pet|change_vet|update_weight|remove_pet|clear_log|reset_all", "pet_name": "", "details": ""}}\n' + '- {{"mode": "reminder", "action": "set|list|delete", "pet_name": "", "activity": "", "time_description": ""}}\n' + '- {{"mode": "greeting"}}\n' '- {{"mode": "exit"}}\n' '- {{"mode": "unknown"}}\n\n' "Rules:\n" @@ -80,30 +237,277 @@ "- 'weighs', 'pounds', 'lbs', 'kilos', 'weight is' => log weight (extract numeric value)\n" "- 'vet visit', 'went to vet', 'checkup' => log vet_visit\n" "- 'groom', 'bath', 'nails', 'haircut' => log grooming\n" - "- 'when did', 'last time', 'how many', 'has had', 'check on' => lookup\n" + "- 'when did', 'last time', 'how many', 'has had', 'check on', 'tell me about' => lookup\n" "- 'emergency vet', 'find a vet', 'vet near me', 'need a vet' => emergency_vet\n" - "- 'safe outside', 'weather', 'too hot', 'too cold', 'can I walk' => weather\n" + "- 'safe outside', 'weather', 'too hot', 'too cold', 'can I walk', 'go outside' => weather\n" "- 'food recall', 'recall check', 'food safe' => food_recall\n" "- 'add a pet', 'new pet', 'update', 'change vet', 'edit pet' => edit_pet\n" "- 'remove pet', 'delete pet' => edit_pet with action remove_pet\n" - "- 'clear log', 'clear activity log', 'delete all logs' => edit_pet with action clear_log\n" + "- 'clear log', 'clear activity log', 'delete all logs', 'clear history' => edit_pet with action clear_log\n" + "- 'start over', 'reset everything', 'delete everything', 'wipe all data', 'fresh start' => edit_pet with action reset_all\n" + "- 'what pets', 'do I have any pets', 'any animals', 'list my pets', 'how many pets' => lookup with query 'list registered pets'\n" + "- 'remind me', 'set a reminder', 'alert me' => reminder with action set\n" + "- 'my reminders', 'list reminders', 'what reminders' => reminder with action list\n" + "- 'delete reminder', 'cancel reminder', 'remove reminder' => reminder with action delete\n" "- 'stop', 'done', 'quit', 'exit', 'bye' => exit\n" + "- Trigger phrases with no specific action ('pet care', 'hello', 'hey', 'hi') => greeting\n" "- If only one pet exists and no name is mentioned, use that pet's name.\n" - "- If multiple pets and no name mentioned, set pet_name to null.\n" - "- Transcription may be garbled from speech-to-text. Be flexible.\n\n" + "- If multiple pets and no name mentioned, set pet_name to null.\n\n" "Examples:\n" '"I just fed Luna" -> {{"mode": "log", "pet_name": "Luna", "activity_type": "feeding", "details": "fed", "value": null}}\n' - '"Luna got her flea medicine" -> {{"mode": "log", "pet_name": "Luna", "activity_type": "medication", "details": "flea medicine", "value": null}}\n' - '"We walked for 30 minutes" -> {{"mode": "log", "pet_name": null, "activity_type": "walk", "details": "30 minute walk", "value": null}}\n' '"Luna weighs 48 pounds now" -> {{"mode": "log", "pet_name": "Luna", "activity_type": "weight", "details": "48 lbs", "value": 48}}\n' '"When did I last feed Luna?" -> {{"mode": "lookup", "pet_name": "Luna", "query": "when was last feeding"}}\n' - '"Has Max had his heartworm pill this month?" -> {{"mode": "lookup", "pet_name": "Max", "query": "heartworm pill this month"}}\n' '"Find an emergency vet" -> {{"mode": "emergency_vet"}}\n' '"Is it safe for Luna outside?" -> {{"mode": "weather", "pet_name": "Luna"}}\n' '"Any pet food recalls?" -> {{"mode": "food_recall"}}\n' - '"Add a new pet" -> {{"mode": "edit_pet", "action": "add_pet", "pet_name": null, "details": "add new pet"}}\n' + '"Start over" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' + '"Remind me to feed Luna in 2 hours" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "feeding", "time_description": "in 2 hours"}}\n' + '"pet care" -> {{"mode": "greeting"}}\n' ) + +def _strip_llm_fences(text): + text = text.strip() + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + return text.strip() + + +class LLMService: + def __init__(self, capability_worker, worker, pet_data): + self.capability_worker = capability_worker + self.worker = worker + self.pet_data = pet_data + + def classify_intent(self, user_input): + pet_names = [p["name"] for p in self.pet_data.get("pets", [])] + prompt_filled = _CLASSIFY_PROMPT.format( + pet_names=", ".join(pet_names) if pet_names else "none", + ) + try: + raw = self.capability_worker.text_to_text_response( + f"User said: {user_input}", + system_prompt=prompt_filled, + ) + return json.loads(_strip_llm_fences(raw)) + except (json.JSONDecodeError, Exception) as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Classification error: {e}" + ) + return {"mode": "unknown"} + + async def classify_intent_async(self, user_input): + return await asyncio.to_thread(self.classify_intent, user_input) + + def extract_value(self, raw_input, instruction): + if not raw_input: + return "" + try: + result = self.capability_worker.text_to_text_response( + f"Input: {raw_input}", + system_prompt=instruction, + ) + return _strip_llm_fences(result).strip().strip('"') + except Exception: + return raw_input.strip() + + async def extract_value_async(self, raw_input, instruction): + return await asyncio.to_thread(self.extract_value, raw_input, instruction) + + async def extract_pet_name_async(self, raw_input): + return await self.extract_value_async( + raw_input, "Extract the pet's name from this. Return just the name." + ) + + async def extract_species_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract the animal species ONLY if it is explicitly mentioned in the text. " + "Return one word: dog, cat, bird, rabbit, hamster, etc. " + "If no species is clearly stated, return 'unknown'. " + "Do NOT guess from pet names or context.", + ) + + async def extract_breed_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract the breed name ONLY if explicitly mentioned in the text. " + "If they say mixed or don't know, return 'mixed'. " + "If no breed is mentioned at all, return 'unknown'. " + "Do NOT guess from pet names or context.", + ) + + async def extract_birthday_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract a birthday in YYYY-MM-DD format if possible. " + "If they give an age like '3 years old', calculate the approximate birthday " + f"from today ({datetime.now().strftime('%Y-%m-%d')}). " + "Return just the date string.", + ) + + async def extract_weight_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract the weight as a number in pounds. If they give kilos, convert to pounds. " + "Return just the number.", + ) + + async def extract_allergies_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract allergies as a JSON array of strings. " + 'If none, return []. Example: ["chicken", "grain"]. Return only the array.', + ) + + async def extract_medications_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract medications as a JSON array of objects with 'name' and 'frequency' keys. " + 'If none, return []. Example: [{"name": "Heartgard", "frequency": "monthly"}]. ' + "Return only the array.", + ) + + async def extract_vet_name_async(self, raw_input): + return await self.extract_value_async( + raw_input, "Extract the veterinarian's name. Return just the name." + ) + + async def extract_phone_number_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract the phone number as digits only (e.g., 5125551234). Return just digits.", + ) + + async def extract_location_async(self, raw_input): + return await self.extract_value_async( + raw_input, + "Extract the city and state/country. Return in format 'City, State' or 'City, Country'.", + ) + + @staticmethod + def clean_input(text): + if not text: + return "" + cleaned = text.lower().strip() + cleaned = re.sub(r"[^\w\s']", "", cleaned) + return cleaned.strip() + + def is_exit(self, text): + if not text: + return False + cleaned = self.clean_input(text) + if not cleaned: + return False + for phrase in _FORCE_EXIT_PHRASES: + if phrase in cleaned: + return True + words = cleaned.split() + for cmd in _EXIT_COMMANDS: + if cmd in words: + return True + for resp in _EXIT_RESPONSES: + if cleaned == resp: + return True + if cleaned.startswith(f"{resp} "): + return True + return False + + def is_hard_exit(self, text: str) -> bool: + """Exit detection for mid-question contexts (Tier 1 + 2 only). + + Use instead of is_exit() when 'no', 'done', 'thanks', etc. are valid + answers (e.g. onboarding). Only matches explicit abort/reset commands. + """ + if not text: + return False + cleaned = self.clean_input(text) + if not cleaned: + return False + for phrase in _FORCE_EXIT_PHRASES: + if phrase in cleaned: + return True + words = cleaned.split() + for cmd in _EXIT_COMMANDS: + if cmd in words: + return True + reset_phrases = [ + "start over", + "wanna start over", + "want to start over", + "start from scratch", + "restart", + "reset everything", + "start from beginning", + ] + return any(phrase in cleaned for phrase in reset_phrases) + + def is_exit_llm(self, text): + try: + result = self.capability_worker.text_to_text_response( + "Does this message mean the user wants to END the conversation? " + "Reply with ONLY 'yes' or 'no'.\n\n" + f'Message: "{text}"' + ) + return result.strip().lower().startswith("yes") + except Exception: + return False + + async def is_exit_llm_async(self, text): + return await asyncio.to_thread(self.is_exit_llm, text) + + def get_trigger_context(self): + initial_request = None + try: + initial_request = self.worker.transcription + except (AttributeError, Exception): + pass + if not initial_request: + try: + initial_request = self.worker.last_transcription + except (AttributeError, Exception): + pass + return initial_request.strip() if initial_request else "" + + +# =========================================================================== +# External API Service (unused directly — logic inlined in main capability) +# =========================================================================== + + +class ExternalAPIService: + def __init__(self, worker, serper_api_key=None): + self.worker = worker + self.serper_api_key = serper_api_key + + +"""Pet Care Assistant — voice-first ability for tracking pets' daily lives. + +Stores pet profiles and activity logs, finds emergency vets, checks weather +safety, and monitors food recalls. Persists data across sessions using JSON files. +""" + +EXIT_MESSAGE = "Take care of those pets! See you next time." + +PETS_FILE = "petcare_pets.json" +ACTIVITY_LOG_FILE = "petcare_activity_log.json" +REMINDERS_FILE = "petcare_reminders.json" + +MAX_LOG_ENTRIES = 500 + +ACTIVITY_TYPES = { + "feeding", + "medication", + "walk", + "weight", + "vet_visit", + "grooming", + "other", +} + +# Serper API key placeholder — get a free key at serper.dev (2,500 free queries) +SERPER_API_KEY = "your_serper_api_key_here" + LOOKUP_SYSTEM_PROMPT = ( "You are a pet care assistant answering a question about the user's " "pet activity log. Given the log entries and the user's question, " @@ -137,102 +541,218 @@ ) +def _strip_json_fences(text: str) -> str: + """Strip markdown code fences from LLM output (e.g. ```json ... ```).""" + text = text.strip() + if text.startswith("```"): + lines = text.split("\n") + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines) + return text.strip() + + def _fmt_phone_for_speech(phone: str) -> str: - """Format a phone number for spoken output, digit by digit.""" + """Format a phone number for spoken output, digit by digit. + + Handles multiple formats: + - 10-digit US: (512) 555-1234 → "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + - 11-digit US with country code: 1-512-555-1234 → "1, 5, 1, 2, 5, 5, 5, ..." + - International (7-15 digits): grouped by 3s for readability + - Invalid lengths (<7 or >15): all digits or error message + """ + if not phone: + return "no number provided" + digits = re.sub(r"\D", "", phone) + + if not digits: + return "no number provided" + if len(digits) == 10: return ( f"{', '.join(digits[:3])}, " f"{', '.join(digits[3:6])}, " f"{', '.join(digits[6:])}" ) - return ", ".join(digits) - - -def _strip_json_fences(text: str) -> str: - """Remove markdown code fences from LLM JSON output.""" - text = text.strip() - text = re.sub(r"^```(?:json)?\s*", "", text) - text = re.sub(r"\s*```$", "", text) - return text.strip() + elif len(digits) == 11 and digits[0] == "1": + return ( + f"1, " + f"{', '.join(digits[1:4])}, " + f"{', '.join(digits[4:7])}, " + f"{', '.join(digits[7:])}" + ) + elif 7 <= len(digits) <= 15: + groups = [digits[i: i + 3] for i in range(0, len(digits), 3)] + return ", ".join(", ".join(group) for group in groups) + elif len(digits) < 7: + return "incomplete phone number" + else: + return "phone number too long, please check" class PetCareAssistantCapability(MatchingCapability): """OpenHome ability for multi-pet care tracking with persistent storage, emergency vet finder, weather safety, and food recall checks.""" + # {{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None pet_data: dict = None activity_log: list = None + _geocode_cache: dict = None - @classmethod - def register_capability(cls) -> "MatchingCapability": - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - ) as file: - data = json.load(file) - return cls( - unique_name=data["unique_name"], - matching_hotwords=data["matching_hotwords"], - ) + # Services initialized in run() + pet_data_service: "PetDataService" = None + activity_log_service: "ActivityLogService" = None + external_api_service: "ExternalAPIService" = None + llm_service: "LLMService" = None + reminders: list = None + # Stash for a command embedded in a "no more pets" response during onboarding + _pending_intent_text: str = None def call(self, worker: AgentWorker): self.worker = worker self.capability_worker = CapabilityWorker(self.worker) self.worker.session_tasks.create(self.run()) - # ------------------------------------------------------------------ - # Main flow - # ------------------------------------------------------------------ + def _is_hard_exit(self, text: str) -> bool: + """Exit check for onboarding — ignores 'no'/'done'/'bye' etc. + + Only matches explicit abort/reset commands so that 'no' to 'any allergies?' + is treated as an answer, not an exit. + """ + if not text: + return False + cleaned = re.sub(r"[^\w\s']", "", text.lower().strip()) + # Single-word abort commands + if any(w in cleaned.split() for w in ["stop", "quit", "exit", "cancel"]): + return True + # Reset/restart phrases + reset_phrases = [ + "start over", + "wanna start over", + "want to start over", + "start from scratch", + "restart", + "reset everything", + "start from beginning", + ] + return any(phrase in cleaned for phrase in reset_phrases) + + # === Main flow === async def run(self): try: self.worker.editor_logging_handler.info("[PetCare] Ability started") - # Load persistent data - self.pet_data = await self._load_json(PETS_FILE, default={}) - self.activity_log = await self._load_json(ACTIVITY_LOG_FILE, default=[]) + self.pet_data_service = PetDataService(self.capability_worker, self.worker) + self.activity_log_service = ActivityLogService(self.worker, MAX_LOG_ENTRIES) + self.external_api_service = ExternalAPIService(self.worker, SERPER_API_KEY) + + self.pet_data = await self.pet_data_service.load_json(PETS_FILE, default={}) + self.llm_service = LLMService( + self.capability_worker, self.worker, self.pet_data + ) + self.activity_log = await self.pet_data_service.load_json( + ACTIVITY_LOG_FILE, default=[] + ) + self.reminders = await self.pet_data_service.load_json( + REMINDERS_FILE, default=[] + ) + + self._geocode_cache = {} + self._corrected_name = None + await self._check_due_reminders() + + trigger = self.llm_service.get_trigger_context() - # Check if first-time user (no pet data file) has_pet_data = await self.capability_worker.check_if_file_exists( PETS_FILE, False ) - if not has_pet_data or not self.pet_data.get("pets"): - await self.run_onboarding() - return + if not has_pet_data or not self.pet_data.get("pets"): + await self.run_onboarding(initial_context=trigger) + if not self.pet_data.get("pets"): + await self.capability_worker.speak(EXIT_MESSAGE) + return + # If the user embedded a command in their "no more pets" answer + # (e.g. "No, is it safe to walk Luna?"), handle it now instead of + # prompting "What would you like to do?" and making them repeat. + if self._pending_intent_text: + pending = self._pending_intent_text + self._pending_intent_text = None + pending_intent = await self.llm_service.classify_intent_async( + pending + ) + if pending_intent.get("mode") not in ("unknown", "exit"): + await self._route_intent(pending_intent) + else: + await self.capability_worker.speak( + "What would you like to do? You can log activities, " + "look up history, find vets, check weather, or set reminders." + ) + else: + await self.capability_worker.speak( + "What would you like to do? You can log activities, " + "look up history, find vets, check weather, or set reminders." + ) - # Returning user — classify trigger context - trigger = self._get_trigger_context() - if trigger: - intent = self._classify_intent(trigger) + elif trigger: + intent = await self.llm_service.classify_intent_async(trigger) mode = intent.get("mode", "unknown") if mode not in ("unknown", "exit"): await self._route_intent(intent) - # Offer one follow-up await self.capability_worker.speak("Anything else for your pets?") follow_up = await self.capability_worker.user_response() - if follow_up and not self._is_exit(follow_up): - follow_intent = self._classify_intent(follow_up) + if follow_up and not self.llm_service.is_exit(follow_up): + follow_intent = await self.llm_service.classify_intent_async( + follow_up + ) if follow_intent.get("mode") not in ("unknown", "exit"): await self._route_intent(follow_intent) - await self.capability_worker.speak( - "Take care of those pets! See you next time." - ) + await self.capability_worker.speak(EXIT_MESSAGE) return - # Full mode — conversation loop - pet_names = [p["name"] for p in self.pet_data.get("pets", [])] - names_str = ", ".join(pet_names) - await self.capability_worker.speak( - f"Pet Care here. You have {len(pet_names)} " - f"pet{'s' if len(pet_names) != 1 else ''}: {names_str}. " - "What would you like to do?" - ) + else: + # Returning user (pet data already exists, no trigger matched) + pet_names = [p["name"] for p in self.pet_data.get("pets", [])] + names_str = ", ".join(pet_names) + greeting = ( + f"Pet Care here. You have {len(pet_names)} " + f"pet{'s' if len(pet_names) != 1 else ''}: {names_str}." + ) + + # Announce pending reminders and offer to read them + pending = len(self.reminders) if self.reminders else 0 + if pending > 0: + greeting += ( + f" You also have {pending} " + f"reminder{'s' if pending != 1 else ''} set." + ) + await self.capability_worker.speak( + greeting + " Want me to read your reminders?" + ) + resp = await self.capability_worker.user_response() + if resp and any( + w in resp.lower() + for w in ["yes", "yeah", "yep", "sure", "read", "go", "yup"] + ): + await self._handle_reminder({"action": "list"}) + elif resp and not self.llm_service.is_exit(resp): + # Route non-exit response as the initial command + intent = await self.llm_service.classify_intent_async(resp) + if intent.get("mode") not in ("unknown", "exit"): + await self._route_intent(intent) + else: + await self.capability_worker.speak( + greeting + " What would you like to do?" + ) idle_count = 0 + consecutive_unknown = 0 for _ in range(20): user_input = await self.capability_worker.user_response() @@ -243,10 +763,12 @@ async def run(self): "Still here if you need me. Otherwise I'll close." ) final = await self.capability_worker.user_response() - if not final or not final.strip() or self._is_exit(final): - await self.capability_worker.speak( - "Take care of those pets! See you next time." - ) + if ( + not final + or not final.strip() + or self.llm_service.is_exit(final) + ): + await self.capability_worker.speak(EXIT_MESSAGE) break user_input = final idle_count = 0 @@ -255,23 +777,65 @@ async def run(self): idle_count = 0 - if self._is_exit(user_input): - await self.capability_worker.speak( - "Take care of those pets! See you next time." - ) - break + cleaned = self.llm_service.clean_input(user_input) + + # Reset/restart phrases map to edit_pet+reset_all and must not + # be classified as exits; guard both code paths against that. + _reset_phrases = [ + "start over", + "start from scratch", + "restart", + "reset everything", + "start from beginning", + ] + _is_reset = any(p in cleaned for p in _reset_phrases) + + # Long inputs bypass keyword checks: "no " would + # false-positive as an exit via Tier-3 prefix match, so send + # them straight to the LLM classifier for accurate intent detection. + if len(cleaned.split()) > 4: + intent = await self.llm_service.classify_intent_async(user_input) + mode = intent.get("mode", "unknown") + if mode == "exit" and not _is_reset: + await self.capability_worker.speak(EXIT_MESSAGE) + break + else: + if not _is_reset: + if self.llm_service.is_exit(user_input): + await self.capability_worker.speak(EXIT_MESSAGE) + break + if await self.llm_service.is_exit_llm_async(cleaned): + await self.capability_worker.speak(EXIT_MESSAGE) + break - intent = self._classify_intent(user_input) - mode = intent.get("mode", "unknown") + intent = await self.llm_service.classify_intent_async(user_input) + mode = intent.get("mode", "unknown") - if mode == "exit": - await self.capability_worker.speak( - "Take care of those pets! See you next time." - ) + if mode == "exit" and not _is_reset: + await self.capability_worker.speak(EXIT_MESSAGE) break self.worker.editor_logging_handler.info(f"[PetCare] Intent: {intent}") + + if mode == "unknown": + consecutive_unknown += 1 + if consecutive_unknown >= 2: + consecutive_unknown = 0 + await self.capability_worker.speak( + "Here's what I can do: log activities like feeding or walks, " + "look up history, find emergency vets, check weather safety, " + "check food recalls, or set reminders. What would you like?" + ) + else: + await self.capability_worker.speak( + "Sorry, I didn't catch that. Could you say that again?" + ) + continue + + consecutive_unknown = 0 await self._route_intent(intent) + else: + await self.capability_worker.speak(EXIT_MESSAGE) except Exception as e: self.worker.editor_logging_handler.error(f"[PetCare] Unexpected error: {e}") @@ -282,32 +846,7 @@ async def run(self): self.worker.editor_logging_handler.info("[PetCare] Ability ended") self.capability_worker.resume_normal_flow() - # ------------------------------------------------------------------ - # Intent classification - # ------------------------------------------------------------------ - - def _classify_intent(self, user_input: str) -> dict: - """Use LLM to classify user intent and extract structured data.""" - pet_names = [p["name"] for p in self.pet_data.get("pets", [])] - prompt_filled = CLASSIFY_PROMPT.format( - pet_names=", ".join(pet_names) if pet_names else "none", - ) - try: - raw = self.capability_worker.text_to_text_response( - f"User said: {user_input}", - system_prompt=prompt_filled, - ) - clean = _strip_json_fences(raw) - return json.loads(clean) - except (json.JSONDecodeError, Exception) as e: - self.worker.editor_logging_handler.error( - f"[PetCare] Classification error: {e}" - ) - return {"mode": "unknown"} - - # ------------------------------------------------------------------ - # Intent router - # ------------------------------------------------------------------ + # === Intent router === async def _route_intent(self, intent: dict): """Route to the correct handler based on classified intent.""" @@ -325,211 +864,717 @@ async def _route_intent(self, intent: dict): await self._handle_food_recall() elif mode == "edit_pet": await self._handle_edit_pet(intent) + elif mode == "reminder": + await self._handle_reminder(intent) + elif mode == "greeting": + await self.capability_worker.speak( + "What can I help with? I can log activities, look up history, " + "find emergency vets, check weather, check food recalls, or set reminders." + ) elif mode == "onboarding": await self.run_onboarding() else: await self.capability_worker.speak( - "I can log activities, look up pet history, find emergency vets, " - "check weather safety, or check food recalls. What would you like?" + "Sorry, I didn't catch that. Could you say that again?" ) - # ------------------------------------------------------------------ - # Onboarding - # ------------------------------------------------------------------ + # === Onboarding === - async def run_onboarding(self): - """Guided voice onboarding for first-time users.""" + async def run_onboarding(self, initial_context: str = ""): + """Guided voice onboarding for first-time users. + + Args: + initial_context: Trigger phrase already captured (e.g. "Pet care Luna"). + Passed to _collect_pet_info() to avoid re-consuming it + from the STT queue as the first user response. + """ self.worker.editor_logging_handler.info("[PetCare] Starting onboarding") await self.capability_worker.speak( - "Hi! I'm your pet care assistant. Let's get set up. " - "What's your pet's name?" + "Hi! I'm your pet care assistant. I'd love to help you out! " + "Let's get started — what's your pet's name?" ) while True: - pet = await self._collect_pet_info() + pet = await self._collect_pet_info(initial_context=initial_context) + initial_context = "" # Only use trigger for the first pet if pet is None: await self.capability_worker.speak("No problem. Come back anytime!") return - # Add pet to data if "pets" not in self.pet_data: self.pet_data["pets"] = [] self.pet_data["pets"].append(pet) await self._save_json(PETS_FILE, self.pet_data) await self.capability_worker.speak( - f"All set! I've saved {pet['name']}'s info. " + f"Awesome, {pet['name']} is all set! " f"You can say things like 'I just fed {pet['name']}' to log activities, " - "or 'find an emergency vet' if you ever need one." + "set reminders, check the weather, or find an emergency vet." ) - # Ask about additional pets await self.capability_worker.speak("Do you have any other pets to add?") response = await self.capability_worker.user_response() - if not response or self._is_exit(response): + if not response: break cleaned = response.lower().strip() - if any( - w in cleaned for w in ["no", "nope", "nah", "that's it", "that's all"] + + # If the response is only an exit phrase, leave + if self.llm_service.is_exit(response) and len(cleaned.split()) <= 2: + break + + # Only continue if user explicitly says yes — default to done + if not any( + w in cleaned + for w in ["yes", "yeah", "yep", "yup", "sure", "another", "more", "add"] ): + # User said no (possibly with an embedded follow-up command, + # e.g. "No, is it safe to walk Luna?"). Strip leading negation + # and stash any remaining content so the main loop handles it. + _no_strip = re.compile( + r"^(?:no[,.]?|nope[,.]?|nah[,.]?)\s*", re.IGNORECASE + ) + remainder = _no_strip.sub("", response).strip() + if remainder and len(remainder.split()) >= 3: + self._pending_intent_text = remainder break - # They said yes or gave a name — loop for another pet await self.capability_worker.speak("Great! What's your next pet's name?") - async def _collect_pet_info(self) -> dict: - """Collect one pet's data through guided voice questions.""" - # Name - name_input = await self.capability_worker.user_response() - if not name_input or self._is_exit(name_input): - return None - name = self._extract_value( - name_input, "Extract the pet's name from this. Return just the name." - ) + async def _ask_onboarding_step(self, prompt: str) -> str | None: + """Ask an onboarding question, handling hard-exit and inline pet queries. - # Species - species_input = await self.capability_worker.run_io_loop( - f"Great! What kind of animal is {name}? Dog, cat, or something else?" - ) - if not species_input or self._is_exit(species_input): - return None - species = self._extract_value( - species_input, - "Extract the animal species. Return one word: dog, cat, bird, rabbit, etc.", - ).lower() - - # Breed - breed_input = await self.capability_worker.run_io_loop(f"What breed is {name}?") - if not breed_input or self._is_exit(breed_input): - return None - breed = self._extract_value( - breed_input, - "Extract the breed name. If they don't know or say mixed, return 'mixed'.", - ) + Wraps run_io_loop with two guards applied in order: + 1. Hard-exit detection (returns None → caller should abort onboarding). + 2. Inline pet inventory query (re-asks the prompt once after answering). - # Age / birthday - age_input = await self.capability_worker.run_io_loop( - f"How old is {name}, or do you know their birthday?" - ) - if not age_input or self._is_exit(age_input): + Returns: + User response string (may be empty), or None if hard exit detected. + """ + response = await self.capability_worker.run_io_loop(prompt) + if not response: + return "" + if self._is_hard_exit(response): return None - birthday = self._extract_value( - age_input, - "Extract a birthday in YYYY-MM-DD format if possible. " - "If they give an age like '3 years old', calculate the approximate birthday " - f"from today ({datetime.now().strftime('%Y-%m-%d')}). " - "Return just the date string.", + if await self._answer_inline_query(response): + response = await self.capability_worker.run_io_loop(prompt) + if not response: + return "" + if self._is_hard_exit(response): + return None + return response + + async def _answer_inline_query(self, response: str) -> bool: + """Detect and answer a question embedded in an onboarding response. + + Handles two tiers: + 1. Fast keyword check — pet inventory questions ("do you have any animal?"). + 2. LLM-based classification — general stored-info lookups (pet profile, + activity history, vet info) for longer question-like inputs. + + Returns: + True — inline query found and answered; caller should re-ask its prompt. + False — no inline query; caller should treat response as a normal answer. + """ + if not response: + return False + lower = response.lower() + + # ── Tier 1: fast keyword check for pet inventory ────────────────── + inventory_patterns = [ + "do you have any", + "do i have any", + "have any animal", + "have any pet", + "any animals", + "any pets", + "what animals", + "what pets", + "what the animal", + "what animal", + "the animal do i have", + "the pet do i have", + "how many pets", + "how many animals", + "list pet", + "list animal", + "animal do i have", + "pets do i have", + ] + if any(p in lower for p in inventory_patterns): + pets = self.pet_data.get("pets", []) + if not pets: + await self.capability_worker.speak( + "No pets are fully registered yet — we're setting one up right now!" + ) + elif len(pets) == 1: + p = pets[0] + await self.capability_worker.speak( + f"You have one pet registered: {p['name']}, a {p.get('species', 'pet')}." + ) + else: + names = ", ".join(p["name"] for p in pets) + await self.capability_worker.speak( + f"You have {len(pets)} pets registered: {names}." + ) + return True + + # ── Tier 2: LLM classification for questions, commands & corrections ─ + # Only classify inputs that contain a question or correction signal; + # plain answers are returned immediately. + _question_signals = { + "what", + "when", + "how", + "who", + "where", + "why", + "tell me", + "give me", + "show me", + "do you", + "can you", + "did i", + "did we", + "have i", + } + _correction_signals = { + "change", + "changing", + "rename", + "wrong", + "fix", + "correct", + "update", + "redo", + "go back", + "not right", + "wanna", + "want to", + } + words = lower.split() + cleaned_words = {w.strip(".,!?'\"") for w in words} + has_signal = any(q in lower for q in _question_signals) or bool( + cleaned_words & _correction_signals ) + if len(words) < 4 or not has_signal: + return False - # Weight - weight_input = await self.capability_worker.run_io_loop( - f"Roughly how much does {name} weigh?" - ) - if not weight_input or self._is_exit(weight_input): - return None - weight_str = self._extract_value( - weight_input, - "Extract the weight as a number in pounds. If they give kilos, convert to pounds. " - "Return just the number.", - ) try: - weight_lbs = float(weight_str) - except (ValueError, TypeError): - weight_lbs = 0 + intent = await self.llm_service.classify_intent_async(response) + mode = intent.get("mode", "unknown") - # Allergies - allergy_input = await self.capability_worker.run_io_loop( - f"Does {name} have any allergies I should know about?" - ) - if not allergy_input or self._is_exit(allergy_input): - return None - allergies_str = self._extract_value( - allergy_input, - "Extract allergies as a JSON array of strings. " - 'If none, return []. Example: ["chicken", "grain"]. Return only the array.', - ) + # Modes that can be handled inline during onboarding + if mode == "lookup": + await self._handle_lookup(intent) + return True + if mode == "weather": + await self._handle_weather(intent) + return True + if mode == "emergency_vet": + await self._handle_emergency_vet() + return True + if mode == "reminder": + await self._handle_reminder(intent) + return True + if mode == "food_recall": + await self._handle_food_recall() + return True + + # Handle edit_pet during onboarding: name corrections inline + if mode == "edit_pet": + action = intent.get("action", "") + details = (intent.get("details") or "").lower() + # Name correction: prompt for the updated name + if action in ("update_pet", "add_pet") or "name" in details: + resp = await self.capability_worker.run_io_loop( + "Sure! What should the name be?" + ) + if resp and not self._is_hard_exit(resp): + new_name = await self.llm_service.extract_pet_name_async(resp) + if new_name and new_name.lower().strip() not in ( + "unknown", + "none", + "", + ): + self._corrected_name = new_name + await self.capability_worker.speak( + f"Got it, I'll use {new_name}." + ) + return True + # Other edit_pet actions: defer until after onboarding + await self.capability_worker.speak( + "I can help with that once we finish setting up! " + "Let's continue for now." + ) + return True + + # Pet-care-related but not actionable inline (log, greeting) + _PET_CARE_MODES = {"log", "greeting"} + if mode in _PET_CARE_MODES: + await self.capability_worker.speak( + "I can help with that once we finish setting up! " + "Let's continue for now." + ) + return True + + # mode == "unknown" or "exit" — not related to assistant's role + # Use LLM to confirm whether it's pet-care-related or truly off-topic + is_related = await self._is_pet_care_related(response) + if is_related: + await self.capability_worker.speak( + "Good question! I'll be able to help with that once we finish " + "getting your pet set up. Let's keep going." + ) + return True + + # Genuinely off-topic — not a question for this assistant + return False + + except Exception as e: + self.worker.editor_logging_handler.warning( + f"[PetCare] Inline query classification failed: {e}" + ) + + return False + + async def _is_pet_care_related(self, text: str) -> bool: + """Use LLM to check if a question is related to pet care. + + Returns True if the question is about pets, animals, or pet care tasks. + Returns False for completely unrelated questions. + """ try: - allergies = json.loads(allergies_str) - if not isinstance(allergies, list): - allergies = [] - except (json.JSONDecodeError, TypeError): - allergies = [] + result = await asyncio.to_thread( + self.capability_worker.text_to_text_response, + f"Is this question related to pets, animals, or pet care? " + f'Reply with ONLY "yes" or "no".\n\n' + f'Question: "{text}"', + ) + return result.strip().lower().startswith("yes") + except Exception: + return False - # Medications - med_input = await self.capability_worker.run_io_loop( - f"Is {name} on any medications?" - ) - if not med_input or self._is_exit(med_input): + async def _collect_pet_info(self, initial_context: str = "") -> dict: + """Collect one pet's profile through natural conversation. + + Asks one open-ended question and extracts all fields from the answer. + Only asks follow-ups for fields that are genuinely missing. + Minimum 3 questions (overview + health + location), maximum 6. + + Args: + initial_context: Pre-captured text (e.g. trigger phrase "Pet care Luna") + used as the overview so it isn't re-consumed from the queue. + """ + # ── Step 1: free-form overview ─────────────────────────────────────── + if initial_context and not self._is_hard_exit(initial_context): + overview = initial_context + # If the trigger is a question, answer it and collect a + # new overview from the user. + if await self._answer_inline_query(overview): + overview = await self.capability_worker.user_response() + else: + overview = await self.capability_worker.user_response() + if not overview or self._is_hard_exit(overview): return None - meds_str = self._extract_value( - med_input, - "Extract medications as a JSON array of objects with 'name' and 'frequency' keys. " - 'If none, return []. Example: [{"name": "Heartgard", "frequency": "monthly"}]. ' - "Return only the array.", + + # Skip parallel extraction if the overview lacks species/breed keywords: + # without explicit animal words the model may infer a species from the + # pet's name alone, causing follow-up questions to be skipped incorrectly. + _SPECIES_KEYWORDS = { + # Species + "dog", + "cat", + "bird", + "rabbit", + "hamster", + "fish", + "turtle", + "snake", + "lizard", + "parrot", + "puppy", + "kitten", + "guinea", + "pig", + "ferret", + "horse", + "pony", + # Breed words that imply a species + "retriever", + "shepherd", + "bulldog", + "poodle", + "terrier", + "labrador", + "husky", + "beagle", + "chihuahua", + "dachshund", + "corgi", + "spaniel", + "collie", + "rottweiler", + "doberman", + "persian", + "siamese", + "tabby", + "bengal", + "sphynx", + "cockatiel", + "parakeet", + "canary", + "macaw", + } + overview_words = set(overview.lower().split()) + has_species_info = bool(overview_words & _SPECIES_KEYWORDS) + + if has_species_info: + # Overview mentions an animal type — extract everything in parallel + name, species, breed, birthday, weight_str = await asyncio.gather( + self.llm_service.extract_pet_name_async(overview), + self.llm_service.extract_species_async(overview), + self.llm_service.extract_breed_async(overview), + self.llm_service.extract_birthday_async(overview), + self.llm_service.extract_weight_async(overview), + ) + # Short overviews cannot reliably contain all fields. + # Only trust breed/birthday/weight from longer, detailed inputs. + if len(overview.split()) <= 8: + breed, birthday, weight_str = "unknown", "", "" + else: + # No species info — only extract the name, force everything else + # to "unknown" so the follow-up questions always trigger. + name = await self.llm_service.extract_pet_name_async(overview) + species, breed, birthday, weight_str = "unknown", "unknown", "", "" + + # Pronouns, articles, species words, and short strings the model + # may return from noisy input — treat these as missing pet names. + _INVALID_NAMES = { + # Articles / determiners + "it", + "its", + "the", + "a", + "an", + # Pronouns / short function words + "do", + "to", + "no", + "yes", + "none", + "unknown", + "not", + "my", + "your", + "their", + "him", + "her", + "he", + "she", + "they", + # Generic responses and fillers + "yeah", + "yep", + "yup", + "nah", + "ok", + "okay", + "sure", + "uh", + # Common species — valid species but not valid names + "dog", + "cat", + "bird", + "rabbit", + "hamster", + "fish", + "turtle", + "snake", + "lizard", + "guinea", + "pig", + "parrot", + # Generic terms + "pet", + "animal", + } + + _VALID_SPECIES = { + "dog", + "cat", + "bird", + "rabbit", + "hamster", + "fish", + "turtle", + "snake", + "lizard", + "parrot", + "puppy", + "kitten", + "guinea pig", + "ferret", + "horse", + "pony", + "gecko", + "frog", + "rat", + "mouse", + "chinchilla", + "hedgehog", + "hermit crab", + "cockatiel", + "parakeet", + "canary", + "macaw", + "iguana", + } + + def _missing(v): + return not v or v.lower().strip() in ("unknown", "none", "") + + def _valid_species(v): + """Check if extracted species is a known animal type.""" + if _missing(v): + return False + return v.lower().strip() in _VALID_SPECIES + + def _bad_name(v): + return _missing(v) or v.lower().strip() in _INVALID_NAMES + + if species and not _valid_species(species): + species = "unknown" + + # ── Follow-up only for fields not found ────────────────────────────── + if _bad_name(name): + resp = await self._ask_onboarding_step("What's your pet's name?") + if resp is None: + return None + if resp: + name = await self.llm_service.extract_pet_name_async(resp) + # Apply inline name correction if the user changed the name + if self._corrected_name: + name = self._corrected_name + self._corrected_name = None + + temp_name = name.strip().split()[0] if name.strip() else "your pet" + + if not _valid_species(species): + resp = await self._ask_onboarding_step( + f"Nice name! Is {temp_name} a dog, cat, or something else? And what breed?" + ) + if self._corrected_name: + name = self._corrected_name + temp_name = name.strip().split()[0] if name.strip() else "your pet" + self._corrected_name = None + if resp is None: + return None + if resp: + species, breed_from_resp = await asyncio.gather( + self.llm_service.extract_species_async(resp), + self.llm_service.extract_breed_async(resp), + ) + if _missing(breed): + breed = breed_from_resp + # Retry once if species is still not a valid animal + if not _valid_species(species): + resp2 = await self._ask_onboarding_step( + f"Sorry, I didn't catch that. Is {temp_name} a dog, cat, or something else?" + ) + if self._corrected_name: + name = self._corrected_name + temp_name = name.strip().split()[0] if name.strip() else "your pet" + self._corrected_name = None + if resp2 is None: + return None + if resp2: + species = await self.llm_service.extract_species_async(resp2) + if _missing(breed): + breed = await self.llm_service.extract_breed_async(resp2) + + # ── Step 1a: breed (if still unknown after species step) ───────────── + if _missing(breed): + breed_input = await self._ask_onboarding_step( + f"Got it! What breed is {temp_name}? Say 'skip' or 'mixed' if you're not sure." + ) + if self._corrected_name: + name = self._corrected_name + temp_name = name.strip().split()[0] if name.strip() else "your pet" + self._corrected_name = None + if breed_input is None: + return None + if breed_input and not any( + w in breed_input.lower() for w in ["skip", "don't know", "no idea"] + ): + breed = await self.llm_service.extract_breed_async(breed_input) + + # ── Step 1b: age/birthday (if not in overview) ─────────────────────── + if _missing(birthday): + age_input = await self._ask_onboarding_step( + f"How old is {temp_name}? You can say an age like '3 years old' or a birthday. " + "Say 'skip' if you're not sure." + ) + if self._corrected_name: + name = self._corrected_name + temp_name = name.strip().split()[0] if name.strip() else "your pet" + self._corrected_name = None + if age_input is None: + return None + if age_input and "skip" not in age_input.lower(): + birthday = await self.llm_service.extract_birthday_async(age_input) + + # ── Step 1c: weight (if not in overview) ───────────────────────────── + if _missing(weight_str): + weight_input = await self._ask_onboarding_step( + f"And how much does {temp_name} weigh? Say 'skip' if you don't know." + ) + if self._corrected_name: + name = self._corrected_name + temp_name = name.strip().split()[0] if name.strip() else "your pet" + self._corrected_name = None + if weight_input is None: + return None + if weight_input and "skip" not in weight_input.lower(): + weight_str = await self.llm_service.extract_weight_async(weight_input) + + # ── Step 2: health (allergies + medications in one question) ───────── + health_input = await self._ask_onboarding_step( + f"Does {temp_name} have any allergies or medications I should know about? " + "Say 'no' if none." ) - try: - medications = json.loads(meds_str) - if not isinstance(medications, list): + if self._corrected_name: + name = self._corrected_name + temp_name = name.strip().split()[0] if name.strip() else "your pet" + self._corrected_name = None + if health_input is None: + return None + if not health_input: + allergies, medications = [], [] + else: + allergies_str, meds_str = await asyncio.gather( + self.llm_service.extract_allergies_async(health_input), + self.llm_service.extract_medications_async(health_input), + ) + try: + allergies = json.loads(allergies_str) + if not isinstance(allergies, list): + allergies = [] + except (json.JSONDecodeError, TypeError): + allergies = [] + try: + medications = json.loads(meds_str) + if not isinstance(medications, list): + medications = [] + except (json.JSONDecodeError, TypeError): medications = [] - except (json.JSONDecodeError, TypeError): - medications = [] - # Vet info - vet_input = await self.capability_worker.run_io_loop( - "Do you have a regular vet? If so, what's their name?" - ) - vet_name = "" - vet_phone = "" - if vet_input and not self._is_exit(vet_input): - cleaned = vet_input.lower().strip() - if not any(w in cleaned for w in ["no", "nope", "skip", "don't have"]): - vet_name = self._extract_value( - vet_input, "Extract the veterinarian's name. Return just the name." + # ── Step 3: vet (optional, skip if already set) ──────────────────── + if not self.pet_data.get("vet_name"): + vet_input = await self._ask_onboarding_step( + f"Almost done! Do you have a regular vet for {temp_name}? " + "Say their name, or 'no' to skip." + ) + if vet_input is None: + return None # User wants to abort/restart + + def _is_skip(v): + return any( + w in v.lower() for w in ["no", "nope", "skip", "don't", "none"] ) - phone_input = await self.capability_worker.run_io_loop( - "What's their phone number?" + + # Affirmative without a name ("yes", "yeah", "sure") → ask for the name + + def _is_affirmative(v): + return v.lower().strip().rstrip(".!?") in { + "yes", + "yeah", + "yep", + "yup", + "sure", + "yea", + "uh huh", + "uh-huh", + } + + if vet_input and _is_affirmative(vet_input): + vet_input = await self.capability_worker.run_io_loop( + "What's their name?" ) - if phone_input and not self._is_exit(phone_input): - vet_phone = self._extract_value( - phone_input, - "Extract the phone number as digits only (e.g., 5125551234). Return just digits.", + if vet_input and self._is_hard_exit(vet_input): + return None + if vet_input and not _is_skip(vet_input): + vet_name = await self.llm_service.extract_vet_name_async(vet_input) + # Reject model error responses stored as vet names + _bad_vet = ( + not vet_name + or len(vet_name) > 60 + or any( + w in vet_name.lower() + for w in [ + "sorry", + "cannot", + "extract", + "provide", + "context", + "unknown", + "none", + ] ) + ) + if _bad_vet: + vet_name = vet_input.strip()[:60] # Use raw input as fallback + self.pet_data["vet_name"] = vet_name + phone_input = await self._ask_onboarding_step( + "What's their phone number? Say 'skip' if you don't know." + ) + if phone_input is None: + return None # User wants to abort/restart + if phone_input and "skip" not in phone_input.lower(): + vet_phone = await self.llm_service.extract_phone_number_async( + phone_input + ) + # Only save if it looks like a real number (≥ 7 digits) + if len(re.sub(r"\D", "", vet_phone)) >= 7: + self.pet_data["vet_phone"] = vet_phone + else: + await self.capability_worker.speak( + "That doesn't look like a complete number. " + "I'll skip it for now — you can update it later." + ) - if vet_name: - self.pet_data["vet_name"] = vet_name - self.pet_data["vet_phone"] = vet_phone - - # Location - location_input = await self.capability_worker.run_io_loop( - "Last thing. What city are you in? This helps me check weather and find vets nearby." - ) - if location_input and not self._is_exit(location_input): - location = self._extract_value( - location_input, - "Extract the city and state/country. Return in format 'City, State' or 'City, Country'.", - ) - self.pet_data["user_location"] = location - # Get lat/lon from location - coords = self._geocode_location(location) - if coords: - self.pet_data["user_lat"] = coords["lat"] - self.pet_data["user_lon"] = coords["lon"] + # ── Step 4: location (optional, skip if already set) ───────────── + if not self.pet_data.get("user_location"): + location_input = await self._ask_onboarding_step( + "One last thing — what city are you in? This helps me check weather and find nearby vets." + ) + if location_input is None: + return None # User wants to abort/restart + if location_input: + location = await self.llm_service.extract_location_async(location_input) + self.pet_data["user_location"] = location + coords = await self._geocode_location(location) + if coords: + self.pet_data["user_lat"] = coords["lat"] + self.pet_data["user_lon"] = coords["lon"] + + # ── Build pet dict ──────────────────────────────────────────────────── + try: + weight_lbs = float(weight_str) + except (ValueError, TypeError): + weight_lbs = 0 - pet_id = f"pet_{uuid.uuid4().hex[:6]}" return { - "id": pet_id, + "id": f"pet_{uuid.uuid4().hex[:6]}", "name": name, - "species": species, - "breed": breed, - "birthday": birthday, + "species": (species or "unknown").lower(), + "breed": breed or "unknown", + "birthday": birthday or "", "weight_lbs": weight_lbs, "allergies": allergies, "medications": medications, } - # ------------------------------------------------------------------ - # Log Activity - # ------------------------------------------------------------------ + # === Log Activity === async def _handle_log(self, intent: dict): """Log a pet activity (feeding, medication, walk, weight, etc.).""" @@ -552,63 +1597,111 @@ async def _handle_log(self, intent: dict): if activity_type == "weight" and value is not None: entry["value"] = value - # Also update weight in pet data for p in self.pet_data.get("pets", []): if p["id"] == pet["id"]: p["weight_lbs"] = value break await self._save_json(PETS_FILE, self.pet_data) - # Add to log (newest first) self.activity_log.insert(0, entry) - # Trim to MAX_LOG_ENTRIES if len(self.activity_log) > MAX_LOG_ENTRIES: self.activity_log = self.activity_log[:MAX_LOG_ENTRIES] await self._save_json(ACTIVITY_LOG_FILE, self.activity_log) - # Confirm briefly time_str = datetime.now().strftime("%I:%M %p").lstrip("0") await self.capability_worker.speak( f"Got it. Logged {pet['name']}'s {activity_type} at {time_str}." ) - # Quick re-log loop: ask if they want to log more - await self.capability_worker.speak("Anything else to log?") - await self.worker.session_tasks.sleep(4) - follow = await self.capability_worker.user_response() - if follow and not self._is_exit(follow): - cleaned = follow.lower().strip() - if any( - w in cleaned for w in ["no", "nope", "nah", "that's it", "that's all"] - ): - return - # They said something — classify and handle if it's another log - follow_intent = self._classify_intent(follow) - if follow_intent.get("mode") == "log": - await self._handle_log(follow_intent) - - # ------------------------------------------------------------------ - # Quick Lookup - # ------------------------------------------------------------------ + # === Quick Lookup === async def _handle_lookup(self, intent: dict): """Answer a question about pet activity history.""" - pet = await self._resolve_pet_async(intent.get("pet_name")) query = intent.get("query", "") - # Filter logs for the pet if specified + # Handle pet inventory queries directly from pet_data + if "list registered pets" in query.lower() or any( + w in query.lower() + for w in [ + "what pets", + "any pets", + "any animals", + "list pets", + "how many pets", + ] + ): + pets = self.pet_data.get("pets", []) + if not pets: + await self.capability_worker.speak( + "You don't have any pets set up yet. Say 'add a new pet' to get started." + ) + elif len(pets) == 1: + p = pets[0] + await self.capability_worker.speak( + f"You have one pet: {p['name']}, a {p.get('breed', '')} {p['species']}." + ) + else: + names = ", ".join( + f"{p['name']} ({p.get('breed', '')} {p['species']})" for p in pets + ) + await self.capability_worker.speak( + f"You have {len(pets)} pets: {names}." + ) + return + + pet = await self._resolve_pet_async(intent.get("pet_name")) + + # Handle pet profile queries — return registered info, not activity log + _profile_keywords = { + "info", + "information", + "profile", + "details", + "registered", + "register", + "about", + "stats", + "data", + "describe", + "record", + } + if pet and any(w in query.lower() for w in _profile_keywords): + parts = [ + f"{pet['name']} is a {pet.get('breed', 'unknown breed')} " + f"{pet.get('species', 'unknown')}" + ] + w = pet.get("weight_lbs", 0) + if w and float(w) > 0: + parts.append(f"weighing {w} pounds") + if pet.get("birthday"): + parts.append(f"born {pet['birthday']}") + allergies = pet.get("allergies", []) + if allergies: + parts.append(f"allergies: {', '.join(allergies)}") + else: + parts.append("no known allergies") + meds = pet.get("medications", []) + if meds: + med_names = [ + m.get("name", str(m)) if isinstance(m, dict) else str(m) + for m in meds + ] + parts.append(f"medications: {', '.join(med_names)}") + vet = self.pet_data.get("vet_name", "") + if vet: + parts.append(f"vet: {vet}") + await self.capability_worker.speak(". ".join(parts) + ".") + return + if pet: relevant_logs = [ e for e in self.activity_log if e.get("pet_id") == pet["id"] - ][ - :50 - ] # Last 50 entries for context + ][:50] else: relevant_logs = self.activity_log[:50] - # Check for weight-specific queries if any( w in query.lower() for w in ["weight", "weigh", "gained", "lost", "pounds", "lbs"] @@ -628,8 +1721,10 @@ async def _handle_lookup(self, intent: dict): prompt = f"User's question: {query}\n\n" f"Activity log entries:\n{log_text}" try: - response = self.capability_worker.text_to_text_response( - prompt, system_prompt=system + response = await asyncio.to_thread( + self.capability_worker.text_to_text_response, + prompt, + system_prompt=system, ) await self.capability_worker.speak(response) except Exception as e: @@ -665,8 +1760,10 @@ async def _handle_weight_lookup(self, pet: dict, logs: list): ) try: - response = self.capability_worker.text_to_text_response( - prompt, system_prompt=system + response = await asyncio.to_thread( + self.capability_worker.text_to_text_response, + prompt, + system_prompt=system, ) await self.capability_worker.speak(response) except Exception as e: @@ -677,13 +1774,10 @@ async def _handle_weight_lookup(self, pet: dict, logs: list): f"{pet['name']} is currently at {pet.get('weight_lbs', 'unknown')} pounds." ) - # ------------------------------------------------------------------ - # Emergency Vet Finder - # ------------------------------------------------------------------ + # === Emergency Vet Finder === async def _handle_emergency_vet(self): - """Find nearby emergency vets using Google Places API.""" - # Mention saved vet first if available + """Find nearby emergency vets using Serper Maps API.""" saved_vet = self.pet_data.get("vet_name", "") saved_phone = self.pet_data.get("vet_phone", "") @@ -697,43 +1791,68 @@ async def _handle_emergency_vet(self): f"Your regular vet is {saved_vet} at {phone_spoken}." ) - # Check for API key - if GOOGLE_PLACES_API_KEY == "your_google_places_api_key_here": + if SERPER_API_KEY == "your_serper_api_key_here": if not saved_vet: await self.capability_worker.speak( - "I need a Google Places API key to find nearby vets. " - "You can add one in your OpenHome settings. " + "I need a Serper API key to find nearby vets. " + "You can get one free at serper.dev and add it in main.py. " "In the meantime, try searching for 'emergency vet near me' on your phone." ) else: await self.capability_worker.speak( - "I need a Google Places API key to search for emergency vets nearby. " - "You can add one in your OpenHome settings." + "I need a Serper API key to search for emergency vets nearby. " + "You can get one free at serper.dev and add it in main.py." ) return - # Get location lat = self.pet_data.get("user_lat") lon = self.pet_data.get("user_lon") if not lat or not lon: - # Try IP-based location - await self.capability_worker.speak("Let me check your location first.") - coords = self._detect_location_by_ip() - if coords: - lat = coords["lat"] - lon = coords["lon"] - self.pet_data["user_lat"] = lat - self.pet_data["user_lon"] = lon - if coords.get("city"): - self.pet_data["user_location"] = coords["city"] - await self._save_json(PETS_FILE, self.pet_data) - else: + saved_location = self.pet_data.get("user_location", "") + if saved_location: await self.capability_worker.speak( - "I couldn't detect your location. " - "Try saying 'update my location' to set it manually." + f"I'll detect your location from your current IP address. " + f"Or, if you'd like to search near your registered location, {saved_location}, say that now." ) - return + loc_response = await self.capability_worker.user_response() + if loc_response and not self.llm_service.is_exit(loc_response): + use_saved = any( + word in loc_response.lower() + for word in [ + "registered", + "saved", + "that", + saved_location.lower().split(",")[0].lower(), + ] + ) + if use_saved: + coords = await self._geocode_location(saved_location) + if coords: + lat, lon = coords["lat"], coords["lon"] + else: + await self.capability_worker.speak( + f"Couldn't look up {saved_location}. Falling back to IP detection." + ) + if not lat or not lon: + await self.capability_worker.speak( + "Detecting your location from your current IP address." + ) + coords = await self._detect_location_by_ip() + if coords: + lat = coords["lat"] + lon = coords["lon"] + self.pet_data["user_lat"] = lat + self.pet_data["user_lon"] = lon + if coords.get("city"): + self.pet_data["user_location"] = coords["city"] + await self._save_json(PETS_FILE, self.pet_data) + else: + await self.capability_worker.speak( + "I couldn't detect your location automatically. " + "Try saying 'update my location' to save it for next time." + ) + return await self.capability_worker.speak("Let me find emergency vets near you.") @@ -742,72 +1861,197 @@ async def _handle_emergency_vet(self): query = ( f"emergency veterinarian near {location_str}" if location_str - else "emergency veterinarian" + else f"emergency veterinarian near {lat},{lon}" ) - url = "https://maps.googleapis.com/maps/api/place/textsearch/json" - params = { - "query": query, - "location": f"{lat},{lon}", - "radius": 16000, - "key": GOOGLE_PLACES_API_KEY, + url = "https://google.serper.dev/maps" + headers = { + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", } + payload = {"q": query, "num": 5} + + resp = await asyncio.to_thread( + requests.post, url, headers=headers, json=payload, timeout=10 + ) + + if resp.status_code == 401 or resp.status_code == 403: + self.worker.editor_logging_handler.error( + f"[PetCare] Serper API authentication failed: {resp.status_code}" + ) + await self.capability_worker.speak( + "Your Serper API key is invalid or expired. " + "Get a free key at serper.dev and set it as the SERPER_API_KEY environment variable." + ) + return + elif resp.status_code == 429: + self.worker.editor_logging_handler.warning( + "[PetCare] Serper API rate limit exceeded" + ) + await self.capability_worker.speak( + "The vet search rate limit was exceeded. Try again in a minute." + ) + return + elif resp.status_code != 200: + self.worker.editor_logging_handler.error( + f"[PetCare] Serper API returned error: {resp.status_code}" + ) + await self.capability_worker.speak( + f"The vet search service returned an error. " + f"Try searching on your phone or calling your regular vet." + ) + return - resp = requests.get(url, params=params, timeout=10) - data = resp.json() + try: + data = resp.json() + except json.JSONDecodeError as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Invalid JSON from Serper API: {e}" + ) + await self.capability_worker.speak( + "The vet search returned invalid data. The service might be having issues." + ) + return - results = data.get("results", []) - if not results: + places = data.get("places", []) + if not places: await self.capability_worker.speak( "I couldn't find any emergency vets nearby. " "Try searching on your phone or calling your regular vet." ) return - # Prioritize open locations, take top 3 - open_vets = [ - r for r in results if r.get("opening_hours", {}).get("open_now") - ] - closed_vets = [ - r for r in results if not r.get("opening_hours", {}).get("open_now") - ] - sorted_results = (open_vets + closed_vets)[:3] + # Open vets first, capped at 3 + open_vets = [p for p in places if p.get("openNow")] + closed_vets = [p for p in places if not p.get("openNow")] + top_results = (open_vets + closed_vets)[:3] + + names = [p.get("title", "Unknown") for p in top_results] + count = len(top_results) + await self.capability_worker.speak( + f"I found {count} nearby vet{'s' if count != 1 else ''}: " + + ", ".join(names) + + ". Which one do you want the number for?" + ) - parts = [] - for r in sorted_results: - name = r.get("name", "Unknown") - rating = r.get("rating", "") - is_open = r.get("opening_hours", {}).get("open_now", False) - status = "open now" if is_open else "may be closed" + pick = await self.capability_worker.user_response() + if not pick or self.llm_service.is_exit(pick): + return - part = f"{name}, {status}" - if rating: - part += f", rated {rating}" - parts.append(part) + pick_lower = pick.lower() + # Score each result against the user's pick. + # Uses three keyword tiers; if no confident match is found, + # falls back to an LLM call to handle paraphrases and nicknames. + _generic = { + "vet", + "veterinary", + "animal", + "hospital", + "clinic", + "pet", + "the", + "and", + "of", + "a", + } - count = len(sorted_results) - await self.capability_worker.speak( - f"I found {count} emergency vet{'s' if count != 1 else ''} near you. " - + ". ".join(parts) - + ". Want the address for any of them?" - ) + def _match_score(place): + title = place.get("title", "").lower() + title_words = set(title.split()) - _generic + pick_words = set(pick_lower.split()) - _generic + if not pick_words: + return 0 + # Tier 1: exact word overlap (highest confidence) + exact = len(title_words & pick_words) + # Tier 2: substring match — handles cases like "urgent" being + # contained within a single-word title such as "UrgentVet". + partial = sum( + 1 for pw in pick_words for tw in title_words if pw in tw or tw in pw + ) + # Tier 3: whole-title substring — catches business names written + # as one word when spaces are removed from the user's pick + title_compact = re.sub(r"\s+", "", title) + pick_compact = re.sub(r"\s+", "", pick_lower) + whole = 2 if pick_compact in title_compact else 0 + return exact * 3 + partial * 2 + whole + + best = max(top_results, key=_match_score) + best_score = _match_score(best) + + if best_score == 0: + best = await self._llm_pick_vet(pick, top_results) or top_results[0] + chosen = best + + name = chosen.get("title", "Unknown") + phone = chosen.get("phoneNumber", "") + is_open = chosen.get("openNow", False) + status = "open now" if is_open else "may be closed" + + detail = f"{name}, {status}" + if phone: + detail += f". Their number is {_fmt_phone_for_speech(phone)}" + else: + detail += ". No phone number listed" + await self.capability_worker.speak(detail) except requests.exceptions.Timeout: self.worker.editor_logging_handler.error( - "[PetCare] Google Places API timeout" + "[PetCare] Serper Maps API timeout" + ) + await self.capability_worker.speak( + "The vet search timed out. Check your internet connection and try again." + ) + except requests.exceptions.ConnectionError: + self.worker.editor_logging_handler.error( + "[PetCare] Could not connect to Serper Maps API" ) await self.capability_worker.speak( - "The vet search timed out. Try again in a moment." + "Couldn't connect to the vet search service. Check your internet connection." ) except Exception as e: - self.worker.editor_logging_handler.error(f"[PetCare] Vet search error: {e}") + self.worker.editor_logging_handler.error( + f"[PetCare] Unexpected vet search error: {e}" + ) await self.capability_worker.speak( - "I had trouble searching for vets right now. Try again later." + "An unexpected error occurred while searching for vets. Try again later." + ) + + async def _llm_pick_vet(self, user_pick: str, candidates: list) -> dict | None: + """Use the LLM to resolve which vet the user meant when keyword matching fails. + + Args: + user_pick: What the user said (e.g. "the second one", "urgent care") + candidates: List of place dicts from Serper Maps + + Returns: + The best-matching place dict, or None if the LLM cannot decide. + """ + numbered = "\n".join( + f"{i+1}. {p.get('title', 'Unknown')}" for i, p in enumerate(candidates) + ) + prompt = ( + f'A user was shown this list of vets and said: "{user_pick}"\n\n' + f"Vet options:\n{numbered}\n\n" + "Which number best matches what the user said? " + "Reply with ONLY the number (1, 2, 3, ...). " + "If none match at all, reply with 0." + ) + try: + raw = await asyncio.to_thread( + self.capability_worker.text_to_text_response, prompt ) + m = re.search(r"\d+", raw.strip()) + if m: + idx = int(m.group()) - 1 + if 0 <= idx < len(candidates): + return candidates[idx] + except Exception as e: + self.worker.editor_logging_handler.warning( + f"[PetCare] LLM vet picker failed: {e}" + ) + return None - # ------------------------------------------------------------------ - # Weather Safety Check - # ------------------------------------------------------------------ + # === Weather Safety Check === async def _handle_weather(self, intent: dict): """Check weather safety for a pet using Open-Meteo API.""" @@ -819,7 +2063,7 @@ async def _handle_weather(self, intent: dict): lon = self.pet_data.get("user_lon") if not lat or not lon: - coords = self._detect_location_by_ip() + coords = await self._detect_location_by_ip() if coords: lat = coords["lat"] lon = coords["lon"] @@ -849,15 +2093,42 @@ async def _handle_weather(self, intent: dict): "forecast_days": 1, } - resp = requests.get(url, params=params, timeout=10) - weather_data = resp.json() + resp = await asyncio.to_thread(requests.get, url, params=params, timeout=10) + + if resp.status_code != 200: + self.worker.editor_logging_handler.error( + f"[PetCare] Open-Meteo API returned error: {resp.status_code}" + ) + await self.capability_worker.speak( + "The weather service returned an error. Try again later." + ) + return + + try: + weather_data = resp.json() + except json.JSONDecodeError as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Invalid JSON from Open-Meteo: {e}" + ) + await self.capability_worker.speak( + "The weather service returned invalid data. Try again later." + ) + return + + current = weather_data.get("current") + if not current: + self.worker.editor_logging_handler.error( + "[PetCare] Open-Meteo response missing 'current' field" + ) + await self.capability_worker.speak( + "The weather data is incomplete. Try again later." + ) + return - current = weather_data.get("current", {}) temp_f = current.get("temperature_2m", 0) wind_mph = current.get("wind_speed_10m", 0) weather_code = current.get("weather_code", 0) - # Get UV from hourly data (current hour) hourly = weather_data.get("hourly", {}) uv_values = hourly.get("uv_index", []) current_hour = datetime.now().hour @@ -875,105 +2146,256 @@ async def _handle_weather(self, intent: dict): prompt = f"Current weather: {weather_info}\n{pet_info}" - response = self.capability_worker.text_to_text_response( - prompt, system_prompt=WEATHER_SYSTEM_PROMPT + response = await asyncio.to_thread( + self.capability_worker.text_to_text_response, + prompt, + system_prompt=WEATHER_SYSTEM_PROMPT, ) await self.capability_worker.speak(response) except requests.exceptions.Timeout: self.worker.editor_logging_handler.error("[PetCare] Weather API timeout") await self.capability_worker.speak( - "The weather check timed out. Try again in a moment." + "The weather check timed out. Check your internet connection and try again." + ) + except requests.exceptions.ConnectionError: + self.worker.editor_logging_handler.error( + "[PetCare] Could not connect to Weather API" + ) + await self.capability_worker.speak( + "Couldn't connect to the weather service. Check your internet connection." ) except Exception as e: - self.worker.editor_logging_handler.error(f"[PetCare] Weather error: {e}") + self.worker.editor_logging_handler.error( + f"[PetCare] Unexpected weather error: {e}" + ) await self.capability_worker.speak( - "I had trouble checking the weather right now." + "An unexpected error occurred while checking the weather." ) - # ------------------------------------------------------------------ - # Food Recall Checker - # ------------------------------------------------------------------ + # === Food Recall Checker === - async def _handle_food_recall(self): - """Check openFDA for recent pet food adverse events.""" - pets = self.pet_data.get("pets", []) - species_set = set(p.get("species", "").lower() for p in pets) - - await self.capability_worker.speak("Let me check for recent pet food alerts.") + async def _fetch_fda_events(self, species: str) -> list: + """Fetch FDA adverse events for a specific species (non-blocking). - all_results = [] + Args: + species: Animal species (dog, cat, etc.) - for species in species_set: - if species not in ("dog", "cat"): - continue - try: - url = "https://api.fda.gov/animalandtobacco/event.json" - params = { - "search": f'animal.species:"{species}"', - "limit": 5, - "sort": "original_receive_date:desc", - } + Returns: + List of FDA event dicts with source, species, brand, date + """ + results = [] + try: + url = "https://api.fda.gov/animalandtobacco/event.json" + params = { + "search": f'animal.species:"{species}"', + "limit": 5, + "sort": "original_receive_date:desc", + } - resp = requests.get(url, params=params, timeout=10) + resp = await asyncio.to_thread(requests.get, url, params=params, timeout=10) - if resp.status_code == 200: + if resp.status_code == 200: + try: data = resp.json() - results = data.get("results", []) - for r in results: - products = r.get("product", []) - for prod in products: - brand = prod.get("brand_name", "Unknown brand") - all_results.append( + except json.JSONDecodeError as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Invalid JSON from FDA API: {e}" + ) + return results + + for r in data.get("results", []): + products = r.get("product", []) + for prod in products: + brand = prod.get("brand_name", "Unknown brand") + results.append( + { + "source": "FDA", + "species": species, + "brand": brand, + "date": r.get("original_receive_date", "unknown date"), + } + ) + elif resp.status_code == 404: + # 404 is expected when no events exist for species + self.worker.editor_logging_handler.info( + f"[PetCare] No FDA events found for {species}" + ) + elif resp.status_code == 429: + self.worker.editor_logging_handler.warning( + "[PetCare] FDA API rate limit exceeded" + ) + else: + self.worker.editor_logging_handler.warning( + f"[PetCare] FDA API returned {resp.status_code}" + ) + + except requests.exceptions.Timeout: + self.worker.editor_logging_handler.error( + f"[PetCare] FDA API timeout for {species}" + ) + except requests.exceptions.ConnectionError: + self.worker.editor_logging_handler.error( + f"[PetCare] Could not connect to FDA API for {species}" + ) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Unexpected FDA error for {species}: {e}" + ) + + return results + + async def _fetch_serper_news(self, species_set: set) -> list: + """Fetch Serper News headlines for food recalls (non-blocking). + + Args: + species_set: Set of species to search for + + Returns: + List of news headline dicts with source, title, snippet, date + """ + headlines = [] + + if SERPER_API_KEY == "your_serper_api_key_here": + return headlines + + species_labels = " or ".join(s for s in species_set if s in ("dog", "cat")) + search_query = ( + f"pet food recall {species_labels} 2026" + if species_labels + else "pet food recall 2026" + ) + + try: + news_resp = await asyncio.to_thread( + requests.post, + "https://google.serper.dev/news", + headers={ + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", + }, + json={"q": search_query, "num": 5}, + timeout=10, + ) + + if news_resp.status_code == 200: + try: + news_data = news_resp.json() + except json.JSONDecodeError as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Invalid JSON from Serper News: {e}" + ) + else: + for item in news_data.get("news", [])[:5]: + title = item.get("title", "") + snippet = item.get("snippet", "") + date = item.get("date", "") + if title: + headlines.append( { - "species": species, - "brand": brand, - "date": r.get( - "original_receive_date", "unknown date" - ), + "source": "News", + "title": title, + "snippet": snippet, + "date": date, } ) - else: - self.worker.editor_logging_handler.warning( - f"[PetCare] FDA API returned {resp.status_code}" - ) + elif news_resp.status_code == 401 or news_resp.status_code == 403: + self.worker.editor_logging_handler.error( + f"[PetCare] Serper News authentication failed: {news_resp.status_code}" + ) + elif news_resp.status_code == 429: + self.worker.editor_logging_handler.warning( + "[PetCare] Serper News rate limit exceeded" + ) + else: + self.worker.editor_logging_handler.warning( + f"[PetCare] Serper News returned {news_resp.status_code}" + ) - except requests.exceptions.Timeout: - self.worker.editor_logging_handler.error("[PetCare] FDA API timeout") - except Exception as e: - self.worker.editor_logging_handler.error(f"[PetCare] FDA error: {e}") + except requests.exceptions.Timeout: + self.worker.editor_logging_handler.error("[PetCare] Serper News timeout") + except requests.exceptions.ConnectionError: + self.worker.editor_logging_handler.error( + "[PetCare] Could not connect to Serper News" + ) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Unexpected Serper News error: {e}" + ) + + return headlines + + async def _handle_food_recall(self): + """Check openFDA and Serper News for recent pet food recalls and adverse events. + + Runs all API calls in parallel for better performance (~50-70% faster). + """ + pets = self.pet_data.get("pets", []) + species_set = set(p.get("species", "").lower() for p in pets) + + await self.capability_worker.speak("Let me check for recent pet food alerts.") + + tasks = [] + for species in species_set: + if species in ("dog", "cat"): + tasks.append(self._fetch_fda_events(species)) + tasks.append(self._fetch_serper_news(species_set)) - if not all_results: + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_results = [] + for result in results[:-1]: # FDA results + if isinstance(result, list): + all_results.extend(result) + + news_headlines = [] + if len(results) > 0: + last_result = results[-1] + if isinstance(last_result, list): + news_headlines = last_result + elif isinstance(last_result, Exception): + pass # Logged in _fetch_serper_news + + if not all_results and not news_headlines: await self.capability_worker.speak( "No new pet food alerts found recently. Looks clear." ) return - # Summarize with LLM pet_names = [p["name"] for p in pets] + context_parts = [] + if all_results: + context_parts.append( + f"Recent FDA adverse event reports:\n{json.dumps(all_results, indent=2)}" + ) + if news_headlines: + context_parts.append( + f"Recent news headlines:\n{json.dumps(news_headlines, indent=2)}" + ) + prompt = ( - f"Recent FDA adverse event reports for pets:\n" - f"{json.dumps(all_results, indent=2)}\n\n" + "\n\n".join(context_parts) + "\n\n" f"User's pets: {', '.join(pet_names)}\n" - "Summarize these reports in 2-3 short spoken sentences. " - "Mention the brands involved. Don't be alarmist. " - "If none of them seem to match common pet food brands, say so." + "Summarize any recalls or safety concerns in 2-3 short spoken sentences. " + "Mention the brands involved if known. Don't be alarmist. " + "If nothing seems serious or relevant, say so clearly." ) try: - response = self.capability_worker.text_to_text_response(prompt) + response = await asyncio.to_thread( + self.capability_worker.text_to_text_response, + prompt, + ) await self.capability_worker.speak(response) except Exception: # Fallback to simple count - count = len(all_results) + count = len(all_results) + len(news_headlines) await self.capability_worker.speak( - f"I found {count} recent adverse event report{'s' if count != 1 else ''} " - "in the FDA database. Want more details?" + f"I found {count} recent pet food alert{'s' if count != 1 else ''} " + "from FDA and news sources. Want more details?" ) - # ------------------------------------------------------------------ - # Edit Pet Info - # ------------------------------------------------------------------ + # === Edit Pet Info === async def _handle_edit_pet(self, intent: dict): """Handle pet edits: add pet, update info, change vet.""" @@ -981,14 +2403,14 @@ async def _handle_edit_pet(self, intent: dict): if action == "add_pet": await self.capability_worker.speak( - "Let's add a new pet. What's their name?" + "I'd love to help with that! What's your new pet's name?" ) new_pet = await self._collect_pet_info() if new_pet: self.pet_data.setdefault("pets", []).append(new_pet) await self._save_json(PETS_FILE, self.pet_data) await self.capability_worker.speak( - f"{new_pet['name']} has been added to your pets!" + f"Awesome, {new_pet['name']} has been added to your pets!" ) else: await self.capability_worker.speak("Okay, not adding a new pet.") @@ -996,18 +2418,15 @@ async def _handle_edit_pet(self, intent: dict): elif action == "change_vet": await self.capability_worker.speak("What's your new vet's name?") vet_input = await self.capability_worker.user_response() - if vet_input and not self._is_exit(vet_input): - vet_name = self._extract_value( - vet_input, "Extract the veterinarian's name. Return just the name." - ) + if vet_input and not self.llm_service.is_exit(vet_input): + vet_name = await self.llm_service.extract_vet_name_async(vet_input) self.pet_data["vet_name"] = vet_name await self.capability_worker.speak("And their phone number?") phone_input = await self.capability_worker.user_response() - if phone_input and not self._is_exit(phone_input): - vet_phone = self._extract_value( - phone_input, - "Extract the phone number as digits only. Return just digits.", + if phone_input and not self.llm_service.is_exit(phone_input): + vet_phone = await self.llm_service.extract_phone_number_async( + phone_input ) self.pet_data["vet_phone"] = vet_phone @@ -1025,21 +2444,18 @@ async def _handle_edit_pet(self, intent: dict): f"What's {pet['name']}'s current weight?" ) weight_input = await self.capability_worker.user_response() - if weight_input and not self._is_exit(weight_input): - weight_str = self._extract_value( - weight_input, - "Extract the weight as a number in pounds. Return just the number.", + if weight_input and not self.llm_service.is_exit(weight_input): + weight_str = await self.llm_service.extract_weight_async( + weight_input ) try: new_weight = float(weight_str) - # Update pet data for p in self.pet_data.get("pets", []): if p["id"] == pet["id"]: p["weight_lbs"] = new_weight break await self._save_json(PETS_FILE, self.pet_data) - # Also log it weight_intent = { "pet_name": pet["name"], "activity_type": "weight", @@ -1060,7 +2476,7 @@ async def _handle_edit_pet(self, intent: dict): "You can change their breed, birthday, allergies, or medications." ) update_input = await self.capability_worker.user_response() - if update_input and not self._is_exit(update_input): + if update_input and not self.llm_service.is_exit(update_input): update_prompt = ( f"The user wants to update {pet['name']}'s info. " f"Current info: {json.dumps(pet)}\n" @@ -1071,8 +2487,9 @@ async def _handle_edit_pet(self, intent: dict): "allergies (array of strings), medications (array of objects with name and frequency)." ) try: - raw = self.capability_worker.text_to_text_response( - update_prompt + raw = await asyncio.to_thread( + self.capability_worker.text_to_text_response, + update_prompt, ) updates = json.loads(_strip_json_fences(raw)) for p in self.pet_data.get("pets", []): @@ -1124,134 +2541,291 @@ async def _handle_edit_pet(self, intent: dict): else: await self.capability_worker.speak("Okay, keeping your logs.") + elif action == "reset_all": + confirmed = await self.capability_worker.run_confirmation_loop( + "This will delete all pets, activity logs, and reminders — a completely fresh start. " + "Say yes to confirm." + ) + if confirmed: + self.pet_data = {} + self.activity_log = [] + self.reminders = [] + # Delete files directly rather than writing empty data. + # Writing {} then {"pets": [...]} in quick succession triggers + # append-corruption on OpenHome (write_file appends, not overwrites). + # load_json returns the correct empty defaults when files are absent. + # Also delete .backup files so no stale data survives a fresh start. + for fname in (PETS_FILE, ACTIVITY_LOG_FILE, REMINDERS_FILE): + for f in (fname, f"{fname}.backup"): + try: + if await self.capability_worker.check_if_file_exists( + f, False + ): + await self.capability_worker.delete_file(f, False) + except Exception as del_err: + self.worker.editor_logging_handler.warning( + f"[PetCare] Could not delete {f} during reset: {del_err}" + ) + await self.capability_worker.speak( + "All data has been wiped. Let's start fresh." + ) + await self.run_onboarding() + else: + await self.capability_worker.speak("Okay, keeping everything as is.") + else: await self.capability_worker.speak( - "I can add a new pet, remove a pet, update pet info, or change your vet. " + "I can add a new pet, remove a pet, update pet info, change your vet, or start over. " "What would you like to do?" ) - # ------------------------------------------------------------------ - # Helper: resolve pet from name - # ------------------------------------------------------------------ - - def _resolve_pet(self, pet_name: str) -> dict: - """Resolve a pet name to a pet dict. Asks user if ambiguous.""" - pets = self.pet_data.get("pets", []) - - if not pets: + # === Reminders === + + _WEEKDAY_MAP = { + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, + "sunday": 6, + } + + def _parse_reminder_time(self, time_description: str) -> datetime | None: + """Parse a natural language time description into a datetime using Python only. + + Supports: 'in X hours/minutes', 'at HH:MM', 'tomorrow at HH:MM', + 'next Monday at 5PM', 'on Friday', 'this Wednesday'. + Returns None if unparseable. + """ + if not time_description: return None + now = datetime.now() + text = time_description.lower().strip() + + # --- "in X minutes/hours" --- + m = re.search(r"in (\d+) minute", text) + if m: + return now + timedelta(minutes=int(m.group(1))) + + m = re.search(r"in (\d+) hour", text) + if m: + return now + timedelta(hours=int(m.group(1))) + + m = re.search(r"tomorrow.*?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", text) + if m: + hour, minute = self._parse_hm(m) + tomorrow = now + timedelta(days=1) + return tomorrow.replace(hour=hour, minute=minute, second=0, microsecond=0) + + day_pattern = "|".join(self._WEEKDAY_MAP.keys()) + m = re.search( + rf"(?:next|this|on)\s+({day_pattern})" + rf"(?:.*?(\d{{1,2}})(?::(\d{{2}}))?\s*(am|pm)?)?", + text, + ) + if m: + target_weekday = self._WEEKDAY_MAP[m.group(1)] + current_weekday = now.weekday() + days_ahead = (target_weekday - current_weekday) % 7 + # "next X" when today is X means 7 days, not 0 + if days_ahead == 0: + days_ahead = 7 + target_date = now + timedelta(days=days_ahead) + if m.group(2): + hour, minute = self._parse_hm(m, groups=(2, 3, 4)) + else: + hour, minute = 9, 0 # default 9 AM + return target_date.replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) - # If only one pet, always use it - if len(pets) == 1: - return pets[0] + m = re.search( + rf"({day_pattern})" rf"(?:.*?(\d{{1,2}})(?::(\d{{2}}))?\s*(am|pm)?)?", + text, + ) + if m: + target_weekday = self._WEEKDAY_MAP[m.group(1)] + current_weekday = now.weekday() + days_ahead = (target_weekday - current_weekday) % 7 + if days_ahead == 0: + days_ahead = 7 + target_date = now + timedelta(days=days_ahead) + if m.group(2): + hour, minute = self._parse_hm(m, groups=(2, 3, 4)) + else: + hour, minute = 9, 0 + return target_date.replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) - # If name given, try to match - if pet_name: - name_lower = pet_name.lower().strip() - for p in pets: - if p["name"].lower() == name_lower: - return p - # Fuzzy: check if name starts with input or vice versa - for p in pets: - if p["name"].lower().startswith(name_lower) or name_lower.startswith( - p["name"].lower() - ): - return p + m = re.search(r"at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?", text) + if m: + hour, minute = self._parse_hm(m) + candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if candidate <= now: + candidate += timedelta(days=1) + return candidate - # Multiple pets, no match — we can't block here with user_response - # since this may be called from sync context. Return first pet as default. - # The caller should handle ambiguity at a higher level. - return pets[0] + return None - async def _resolve_pet_async(self, pet_name: str) -> dict: - """Resolve a pet, asking the user if ambiguous.""" - pets = self.pet_data.get("pets", []) - if not pets: - await self.capability_worker.speak("You don't have any pets set up yet.") - return None + @staticmethod + def _parse_hm(m, groups=(1, 2, 3)) -> tuple[int, int]: + """Extract hour and minute from a regex match with am/pm handling. + + Args: + m: Regex match object + groups: Tuple of (hour_group, minute_group, meridiem_group) indices + + Returns: + (hour, minute) tuple in 24-hour format + """ + h_idx, m_idx, mer_idx = groups + hour = int(m.group(h_idx)) + minute = int(m.group(m_idx)) if m.group(m_idx) else 0 + meridiem = m.group(mer_idx) + if meridiem == "pm" and hour < 12: + hour += 12 + elif meridiem == "am" and hour == 12: + hour = 0 + return hour, minute + + async def _check_due_reminders(self): + """Announce and remove any reminders that are due or overdue.""" + if not self.reminders: + return + now = datetime.now() + due = [ + r + for r in self.reminders + if r.get("due_at") and datetime.fromisoformat(r["due_at"]) <= now + ] + if not due: + return + for r in due: + await self.capability_worker.speak( + r.get("message", "You have a pet reminder due.") + ) + self.reminders = [r for r in self.reminders if r not in due] + await self._save_json(REMINDERS_FILE, self.reminders) - if len(pets) == 1: - return pets[0] + async def _handle_reminder(self, intent: dict): + """Handle set / list / delete reminder actions.""" + action = intent.get("action", "set") - if pet_name: - name_lower = pet_name.lower().strip() - for p in pets: - if p["name"].lower() == name_lower: - return p - for p in pets: - if p["name"].lower().startswith(name_lower) or name_lower.startswith( - p["name"].lower() - ): - return p + if action == "list": + if not self.reminders: + await self.capability_worker.speak("You have no reminders set.") + return + await self.capability_worker.speak( + f"You have {len(self.reminders)} reminder{'s' if len(self.reminders) != 1 else ''}." + ) + for i, r in enumerate(self.reminders, 1): + due = datetime.fromisoformat(r["due_at"]).strftime("%A at %I:%M %p") + await self.capability_worker.speak( + f"{i}. {r.get('message', 'Reminder')} — {due}." + ) - # Ask user - names = " or ".join(p["name"] for p in pets) - await self.capability_worker.speak(f"Which pet? {names}?") - response = await self.capability_worker.user_response() - if response and not self._is_exit(response): - return self._resolve_pet(response) - return None + elif action == "delete": + if not self.reminders: + await self.capability_worker.speak("You have no reminders to delete.") + return + if len(self.reminders) == 1: + self.reminders = [] + await self._save_json(REMINDERS_FILE, self.reminders) + await self.capability_worker.speak("Reminder deleted.") + return + for i, r in enumerate(self.reminders, 1): + due = datetime.fromisoformat(r["due_at"]).strftime("%A at %I:%M %p") + await self.capability_worker.speak( + f"{i}. {r.get('message', 'Reminder')} — {due}." + ) + await self.capability_worker.speak("Which number would you like to delete?") + pick = await self.capability_worker.user_response() + if pick and not self.llm_service.is_exit(pick): + m = re.search(r"\d+", pick) + if m: + idx = int(m.group()) - 1 + if 0 <= idx < len(self.reminders): + removed = self.reminders.pop(idx) + await self._save_json(REMINDERS_FILE, self.reminders) + await self.capability_worker.speak( + f"Deleted reminder: {removed.get('message', 'Reminder')}." + ) + else: + await self.capability_worker.speak( + "That number wasn't in the list." + ) + else: + await self.capability_worker.speak( + "I didn't catch a number. Try again." + ) - # ------------------------------------------------------------------ - # Helper: trigger context - # ------------------------------------------------------------------ + else: # "set" + pet_name = intent.get("pet_name", "") + activity = intent.get("activity", "") + time_description = intent.get("time_description", "") - def _get_trigger_context(self) -> str: - """Get the transcription that triggered this ability.""" - initial_request = None - try: - initial_request = self.worker.transcription - except (AttributeError, Exception): - pass - if not initial_request: - try: - initial_request = self.worker.last_transcription - except (AttributeError, Exception): - pass - return initial_request.strip() if initial_request else "" + if not time_description: + await self.capability_worker.speak( + "When should I remind you? Say something like 'in 2 hours', 'at 6 PM', or 'next Monday'." + ) + time_description = await self.capability_worker.user_response() or "" - # ------------------------------------------------------------------ - # Helper: extract value from messy voice input - # ------------------------------------------------------------------ + due_at = self._parse_reminder_time(time_description) + if not due_at: + await self.capability_worker.speak( + "I couldn't understand that time. Try 'in 2 hours', 'at 6 PM', or 'next Monday at 5 PM'." + ) + return - def _extract_value(self, raw_input: str, instruction: str) -> str: - """Use LLM to extract a clean value from messy voice input.""" - try: - result = self.capability_worker.text_to_text_response( - f"Input: {raw_input}", - system_prompt=instruction, + pet_part = f" for {pet_name}" if pet_name else "" + activity_part = f" {activity}" if activity else "" + message = f"Reminder{pet_part}: {activity_part or 'pet care task'}.".strip() + + reminder = { + "id": str(uuid.uuid4()), + "pet_name": pet_name, + "activity": activity, + "message": message, + "due_at": due_at.isoformat(), + "created_at": datetime.now().isoformat(), + } + self.reminders.append(reminder) + await self._save_json(REMINDERS_FILE, self.reminders) + + spoken_time = due_at.strftime("%A at %I:%M %p") + await self.capability_worker.speak( + f"Got it. I'll remind you {spoken_time}: {message}" ) - return _strip_json_fences(result).strip().strip('"') - except Exception: - return raw_input.strip() - # ------------------------------------------------------------------ - # Helper: exit detection - # ------------------------------------------------------------------ + # === Helper: resolve pet === - @staticmethod - def _is_exit(text: str) -> bool: - """Check if user input indicates exit intent.""" - if not text: - return False - cleaned = text.lower().strip() - cleaned = re.sub(r"[^\w\s']", "", cleaned).strip() - if not cleaned: - return False - for word in EXIT_WORDS: - if word in cleaned: - return True - return False + def _resolve_pet(self, pet_name: str) -> dict: + """Resolve a pet name to a pet dict. + + Delegates to PetDataService. + """ + return self.pet_data_service.resolve_pet(self.pet_data, pet_name) - # ------------------------------------------------------------------ - # Helper: geolocation - # ------------------------------------------------------------------ + async def _resolve_pet_async(self, pet_name: str) -> dict: + """Resolve a pet, asking the user if ambiguous. + + Delegates to PetDataService. + """ + return await self.pet_data_service.resolve_pet_async( + self.pet_data, pet_name, self.llm_service.is_exit + ) + + # === Helper: geolocation === - def _detect_location_by_ip(self) -> dict: + async def _detect_location_by_ip(self) -> dict: """Auto-detect location using ip-api.com from user's IP.""" try: ip = self.worker.user_socket.client.host - resp = requests.get(f"http://ip-api.com/json/{ip}", timeout=5) + resp = await asyncio.to_thread( + requests.get, f"http://ip-api.com/json/{ip}", timeout=5 + ) if resp.status_code == 200: data = resp.json() if data.get("status") == "success": @@ -1279,44 +2853,60 @@ def _detect_location_by_ip(self) -> dict: ) return None - def _geocode_location(self, location_str: str) -> dict: - """Convert a city name to lat/lon using Open-Meteo geocoding.""" + async def _geocode_location(self, location_str: str) -> dict: + """Convert a city name to lat/lon using Open-Meteo geocoding. + + Uses in-memory cache to avoid redundant API calls within a session. + """ + if location_str in self._geocode_cache: + self.worker.editor_logging_handler.info( + f"[PetCare] Geocoding cache hit: {location_str}" + ) + return self._geocode_cache[location_str] + try: url = "https://geocoding-api.open-meteo.com/v1/search" - resp = requests.get( - url, params={"name": location_str, "count": 1}, timeout=10 + # Strip state/region suffix for better API results + city_only = location_str.split(",")[0].strip() + resp = await asyncio.to_thread( + requests.get, url, params={"name": city_only, "count": 1}, timeout=10 ) if resp.status_code == 200: data = resp.json() results = data.get("results", []) if results: - return { + coords = { "lat": results[0]["latitude"], "lon": results[0]["longitude"], } + self._geocode_cache[location_str] = coords + self.worker.editor_logging_handler.info( + f"[PetCare] Geocoded {location_str} -> {coords['lat']}, {coords['lon']}" + ) + return coords except Exception as e: self.worker.editor_logging_handler.error(f"[PetCare] Geocoding error: {e}") return None - # ------------------------------------------------------------------ - # Persistence (delete + write pattern for JSON) - # ------------------------------------------------------------------ + # === Persistence === async def _load_json(self, filename: str, default=None): - """Load a JSON file, returning default if not found or corrupt.""" - if await self.capability_worker.check_if_file_exists(filename, False): - try: - raw = await self.capability_worker.read_file(filename, False) - return json.loads(raw) - except json.JSONDecodeError: - self.worker.editor_logging_handler.error( - f"[PetCare] Corrupt file {filename}, resetting." - ) - await self.capability_worker.delete_file(filename, False) - return default if default is not None else {} + """Load a JSON file, returning default if not found or corrupt. + + Delegates to PetDataService. + """ + return await self.pet_data_service.load_json(filename, default) async def _save_json(self, filename: str, data): - """Save data using delete-then-write pattern.""" - if await self.capability_worker.check_if_file_exists(filename, False): - await self.capability_worker.delete_file(filename, False) - await self.capability_worker.write_file(filename, json.dumps(data), False) + """Save data using backup-write-delete pattern for data safety. + + Delegates to PetDataService. + + Args: + filename: Target filename to save to + data: Data to serialize as JSON and save + + Raises: + Exception: If write fails (backup file will remain) + """ + return await self.pet_data_service.save_json(filename, data) diff --git a/community/pet-care-assistant/pytest.ini b/community/pet-care-assistant/pytest.ini new file mode 100644 index 0000000..8c8f27a --- /dev/null +++ b/community/pet-care-assistant/pytest.ini @@ -0,0 +1,25 @@ +[pytest] +# Pytest configuration for Pet Care Assistant + +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Show extra test summary info +addopts = + -v + --tb=short + --strict-markers + --cov=main + --cov-report=term-missing + --cov-report=html + +# Markers for organizing tests +markers = + unit: Unit tests (fast, no external dependencies) + integration: Integration tests (may hit external APIs) + slow: Slow-running tests + +# Asyncio configuration +asyncio_mode = auto diff --git a/community/pet-care-assistant/requirements-test.txt b/community/pet-care-assistant/requirements-test.txt new file mode 100644 index 0000000..e417364 --- /dev/null +++ b/community/pet-care-assistant/requirements-test.txt @@ -0,0 +1,7 @@ +# Test dependencies for Pet Care Assistant + +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-mock>=3.11.0 +pytest-cov>=4.1.0 +hypothesis>=6.82.0 diff --git a/community/pet-care-assistant/tests/__init__.py b/community/pet-care-assistant/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/community/pet-care-assistant/tests/conftest.py b/community/pet-care-assistant/tests/conftest.py new file mode 100644 index 0000000..4301bb5 --- /dev/null +++ b/community/pet-care-assistant/tests/conftest.py @@ -0,0 +1,100 @@ +"""Shared test fixtures for Pet Care Assistant tests.""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +# Mock the OpenHome src modules before importing main +@pytest.fixture(scope="session", autouse=True) +def mock_src_modules(): + """Mock the src.agent modules that aren't available in test environment.""" + # Create mock modules + mock_capability = MagicMock() + mock_capability.MatchingCapability = type( + "MatchingCapability", + (), + {"__init__": lambda self, unique_name="", matching_hotwords=None: None}, + ) + + mock_capability_worker = MagicMock() + mock_capability_worker.CapabilityWorker = MagicMock + + mock_main = MagicMock() + mock_main.AgentWorker = MagicMock + + # Install mocks in sys.modules + sys.modules["src"] = MagicMock() + sys.modules["src.agent"] = MagicMock() + sys.modules["src.agent.capability"] = mock_capability + sys.modules["src.agent.capability_worker"] = mock_capability_worker + sys.modules["src.main"] = mock_main + + # Add parent directory to path + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + yield + + # Cleanup not strictly necessary for session-scope fixture + + +@pytest.fixture +def mock_worker(): + """Mock AgentWorker.""" + worker = MagicMock() + worker.editor_logging_handler = MagicMock() + worker.editor_logging_handler.info = MagicMock() + worker.editor_logging_handler.error = MagicMock() + worker.editor_logging_handler.warning = MagicMock() + return worker + + +@pytest.fixture +def mock_capability_worker(): + """Mock CapabilityWorker.""" + cw = MagicMock() + cw.speak = AsyncMock() + cw.user_response = AsyncMock() + cw.run_io_loop = AsyncMock() + cw.text_to_text_response = MagicMock() + cw.run_confirmation_loop = AsyncMock() + cw.check_if_file_exists = AsyncMock() + cw.read_file = AsyncMock() + cw.write_file = AsyncMock() + cw.delete_file = AsyncMock() + cw.resume_normal_flow = MagicMock() + return cw + + +@pytest.fixture +def capability(mock_worker, mock_capability_worker): + """Create a PetCareAssistantCapability instance with mocked dependencies.""" + from main import ( + ActivityLogService, + ExternalAPIService, + LLMService, + PetCareAssistantCapability, + PetDataService, + ) + + cap = PetCareAssistantCapability( + unique_name="test_pet_care", matching_hotwords=["pet care", "my pets"] + ) + cap.worker = mock_worker + cap.capability_worker = mock_capability_worker + cap.pet_data = {} + cap.activity_log = [] + cap._geocode_cache = {} + cap._corrected_name = None + + # Initialize all services + cap.pet_data_service = PetDataService(mock_capability_worker, mock_worker) + cap.activity_log_service = ActivityLogService(mock_worker, max_log_entries=500) + cap.external_api_service = ExternalAPIService( + mock_worker, serper_api_key="test_key" + ) + cap.llm_service = LLMService(mock_capability_worker, mock_worker, cap.pet_data) + + return cap diff --git a/community/pet-care-assistant/tests/test_api_integration.py b/community/pet-care-assistant/tests/test_api_integration.py new file mode 100644 index 0000000..94a1a33 --- /dev/null +++ b/community/pet-care-assistant/tests/test_api_integration.py @@ -0,0 +1,488 @@ +"""API Integration tests for Pet Care Assistant. + +Tests all external API integrations with mocked HTTP responses: +- Serper Maps API (emergency vet search) +- Open-Meteo API (weather safety) +- openFDA API (food recalls) +- Serper News API (recall headlines) + +Uses responses library to mock HTTP calls and test: +- Success paths with valid responses +- Error handling (401/403/429/timeout/connection) +- Response parsing and validation +- Edge cases (empty results, malformed JSON) +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import responses + + +class TestEmergencyVetAPI: + """Tests for Serper Maps API integration (emergency vet search).""" + + @pytest.fixture + def capability_with_location(self, capability): + """Capability with location data set.""" + capability.pet_data = { + "pets": [{"name": "Luna", "species": "dog"}], + "user_lat": 30.2672, + "user_lon": -97.7431, + "user_location": "Austin, Texas", + } + return capability + + @pytest.mark.asyncio + @responses.activate + @patch("main.SERPER_API_KEY", "test_api_key_123") + async def test_emergency_vet_success(self, capability_with_location): + """Should list vet names first, then give details after user picks one.""" + # Mock successful Serper Maps response + responses.add( + responses.POST, + "https://google.serper.dev/maps", + json={ + "places": [ + { + "title": "Austin Vet Emergency", + "rating": 4.5, + "openNow": True, + "phoneNumber": "512-555-1234", + "address": "123 Main St", + }, + { + "title": "BluePearl Pet Hospital", + "rating": 4.8, + "openNow": True, + "phoneNumber": "512-555-5678", + "address": "456 Oak Ave", + }, + ] + }, + status=200, + ) + + # User picks the first vet by name + capability_with_location.capability_worker.user_response = AsyncMock( + return_value="Austin Vet" + ) + + await capability_with_location._handle_emergency_vet() + + calls = capability_with_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + # Names list spoken first + assert "Austin Vet Emergency" in speak_text + assert "BluePearl Pet Hospital" in speak_text + # Details for chosen vet spoken after pick + assert "open now" in speak_text + assert "5, 1, 2" in speak_text # phone number spoken + + @pytest.mark.asyncio + @responses.activate + @patch("main.SERPER_API_KEY", "test_api_key_123") + async def test_emergency_vet_401_unauthorized(self, capability_with_location): + """Should handle 401 authentication error with actionable message.""" + responses.add( + responses.POST, + "https://google.serper.dev/maps", + json={"error": "Invalid API key"}, + status=401, + ) + + await capability_with_location._handle_emergency_vet() + + # Verify user gets actionable error message + calls = capability_with_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "invalid" in speak_text.lower() or "expired" in speak_text.lower() + assert "serper" in speak_text.lower() + + @pytest.mark.asyncio + @responses.activate + @patch("main.SERPER_API_KEY", "test_api_key_123") + async def test_emergency_vet_429_rate_limit(self, capability_with_location): + """Should handle 429 rate limiting with appropriate message.""" + responses.add( + responses.POST, + "https://google.serper.dev/maps", + json={"error": "Rate limit exceeded"}, + status=429, + ) + + await capability_with_location._handle_emergency_vet() + + calls = capability_with_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "rate limit" in speak_text.lower() + + @pytest.mark.asyncio + @responses.activate + @patch("main.SERPER_API_KEY", "test_api_key_123") + async def test_emergency_vet_empty_results(self, capability_with_location): + """Should handle empty results gracefully.""" + responses.add( + responses.POST, + "https://google.serper.dev/maps", + json={"places": []}, + status=200, + ) + + await capability_with_location._handle_emergency_vet() + + calls = capability_with_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "couldn't find" in speak_text.lower() or "no" in speak_text.lower() + + @pytest.mark.asyncio + @responses.activate + @patch("main.SERPER_API_KEY", "test_api_key_123") + async def test_emergency_vet_invalid_json(self, capability_with_location): + """Should handle malformed JSON response.""" + responses.add( + responses.POST, + "https://google.serper.dev/maps", + body="not valid json", + status=200, + ) + + await capability_with_location._handle_emergency_vet() + + calls = capability_with_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "invalid" in speak_text.lower() or "error" in speak_text.lower() + + +class TestWeatherAPI: + """Tests for Open-Meteo API integration (weather safety check).""" + + @pytest.fixture + def capability_with_pet_and_location(self, capability): + """Capability with pet and location data.""" + capability.pet_data = { + "pets": [ + { + "id": "pet_123", + "name": "Luna", + "species": "dog", + "breed": "golden retriever", + "weight_lbs": 55, + } + ], + "user_lat": 30.2672, + "user_lon": -97.7431, + } + capability.activity_log = [] + return capability + + @pytest.mark.asyncio + @responses.activate + async def test_weather_success(self, capability_with_pet_and_location): + """Should successfully check weather with valid response.""" + responses.add( + responses.GET, + "https://api.open-meteo.com/v1/forecast", + json={ + "current": { + "temperature_2m": 75.0, + "weather_code": 0, + "wind_speed_10m": 5.0, + }, + "hourly": {"uv_index": [0, 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1]}, + }, + status=200, + ) + + intent = {"pet_name": "Luna"} + await capability_with_pet_and_location._handle_weather(intent) + + # Verify weather check was performed + assert capability_with_pet_and_location.capability_worker.speak.called + + @pytest.mark.asyncio + @responses.activate + async def test_weather_http_error(self, capability_with_pet_and_location): + """Should handle HTTP error from weather API.""" + responses.add( + responses.GET, + "https://api.open-meteo.com/v1/forecast", + json={"error": "Service unavailable"}, + status=503, + ) + + intent = {"pet_name": "Luna"} + await capability_with_pet_and_location._handle_weather(intent) + + calls = capability_with_pet_and_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "error" in speak_text.lower() + + @pytest.mark.asyncio + @responses.activate + async def test_weather_invalid_json(self, capability_with_pet_and_location): + """Should handle malformed JSON from weather API.""" + responses.add( + responses.GET, + "https://api.open-meteo.com/v1/forecast", + body="Server Error", + status=200, + ) + + intent = {"pet_name": "Luna"} + await capability_with_pet_and_location._handle_weather(intent) + + calls = capability_with_pet_and_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "invalid" in speak_text.lower() or "error" in speak_text.lower() + + @pytest.mark.asyncio + @responses.activate + async def test_weather_missing_current_field( + self, capability_with_pet_and_location + ): + """Should handle response missing required 'current' field.""" + responses.add( + responses.GET, + "https://api.open-meteo.com/v1/forecast", + json={"hourly": {"uv_index": [1, 2, 3]}}, # Missing 'current' + status=200, + ) + + intent = {"pet_name": "Luna"} + await capability_with_pet_and_location._handle_weather(intent) + + calls = capability_with_pet_and_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "incomplete" in speak_text.lower() or "error" in speak_text.lower() + + +class TestFoodRecallAPI: + """Tests for openFDA and Serper News API integration (food recalls).""" + + @pytest.fixture + def capability_with_pets(self, capability): + """Capability with pet data.""" + capability.pet_data = { + "pets": [ + {"name": "Luna", "species": "dog"}, + {"name": "Max", "species": "cat"}, + ] + } + return capability + + @pytest.mark.asyncio + @responses.activate + async def test_food_recall_fda_success(self, capability_with_pets): + """Should successfully fetch FDA adverse events.""" + responses.add( + responses.GET, + "https://api.fda.gov/animalandtobacco/event.json", + json={ + "results": [ + { + "product": [{"brand_name": "Acme Dog Food"}], + "original_receive_date": "20250115", + } + ] + }, + status=200, + ) + + await capability_with_pets._handle_food_recall() + + # Verify recall check was performed + assert capability_with_pets.capability_worker.speak.called + + @pytest.mark.asyncio + @responses.activate + async def test_food_recall_fda_404_no_results(self, capability_with_pets): + """Should handle 404 (no results) gracefully.""" + responses.add( + responses.GET, + "https://api.fda.gov/animalandtobacco/event.json", + json={"error": {"message": "No matches found"}}, + status=404, + ) + + await capability_with_pets._handle_food_recall() + + # Should not crash, just log info + assert capability_with_pets.worker.editor_logging_handler.info.called + + @pytest.mark.asyncio + @responses.activate + async def test_food_recall_fda_429_rate_limit(self, capability_with_pets): + """Should handle FDA API rate limiting.""" + responses.add( + responses.GET, + "https://api.fda.gov/animalandtobacco/event.json", + json={"error": {"message": "Rate limit exceeded"}}, + status=429, + ) + + await capability_with_pets._handle_food_recall() + + # Should log warning about rate limit + assert capability_with_pets.worker.editor_logging_handler.warning.called + + @pytest.mark.asyncio + @responses.activate + async def test_food_recall_fda_invalid_json(self, capability_with_pets): + """Should handle malformed JSON from FDA API.""" + responses.add( + responses.GET, + "https://api.fda.gov/animalandtobacco/event.json", + body="Invalid JSON{", + status=200, + ) + + await capability_with_pets._handle_food_recall() + + # Should continue gracefully (may check other sources) + calls = capability_with_pets.capability_worker.speak.call_args_list + assert len(calls) > 0 # Should at least speak final summary + + @pytest.mark.asyncio + @responses.activate + async def test_food_recall_no_results(self, capability_with_pets): + """Should speak when no recalls found.""" + # Mock FDA with no results + responses.add( + responses.GET, + "https://api.fda.gov/animalandtobacco/event.json", + json={"results": []}, + status=200, + ) + + await capability_with_pets._handle_food_recall() + + calls = capability_with_pets.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "no" in speak_text.lower() and ( + "alert" in speak_text.lower() or "clear" in speak_text.lower() + ) + + +class TestGeolocationAPIs: + """Tests for geolocation APIs (IP-based and geocoding).""" + + @pytest.mark.asyncio + @responses.activate + async def test_ip_geolocation_success(self, capability): + """Should successfully detect location from IP.""" + responses.add( + responses.GET, + "http://ip-api.com/json/1.2.3.4", + json={ + "status": "success", + "lat": 30.2672, + "lon": -97.7431, + "city": "Austin", + "regionName": "Texas", + "isp": "AT&T", + }, + status=200, + ) + + capability.worker.user_socket = MagicMock() + capability.worker.user_socket.client.host = "1.2.3.4" + + result = await capability._detect_location_by_ip() + + assert result is not None + assert result["lat"] == 30.2672 + assert result["lon"] == -97.7431 + assert "Austin" in result["city"] + + @pytest.mark.asyncio + @responses.activate + async def test_ip_geolocation_cloud_ip(self, capability): + """Should detect and warn about cloud IPs.""" + responses.add( + responses.GET, + "http://ip-api.com/json/1.2.3.4", + json={ + "status": "success", + "lat": 39.0, + "lon": -77.0, + "city": "Ashburn", + "regionName": "Virginia", + "isp": "Amazon AWS", # Cloud indicator + }, + status=200, + ) + + capability.worker.user_socket = MagicMock() + capability.worker.user_socket.client.host = "1.2.3.4" + + result = await capability._detect_location_by_ip() + + # Should still return result but log warning + assert result is not None + assert capability.worker.editor_logging_handler.warning.called + + @pytest.mark.asyncio + @responses.activate + async def test_geocoding_success(self, capability): + """Should successfully geocode a location string.""" + capability._geocode_cache = {} + + responses.add( + responses.GET, + "https://geocoding-api.open-meteo.com/v1/search", + json={ + "results": [ + {"latitude": 30.2672, "longitude": -97.7431, "name": "Austin"} + ] + }, + status=200, + ) + + result = await capability._geocode_location("Austin, Texas") + + assert result is not None + assert result["lat"] == 30.2672 + assert result["lon"] == -97.7431 + + @pytest.mark.asyncio + async def test_geocoding_cache_hit(self, capability): + """Should return cached result without API call.""" + capability._geocode_cache = {"Austin, Texas": {"lat": 30.2672, "lon": -97.7431}} + + # No responses.add needed - should not hit API + result = await capability._geocode_location("Austin, Texas") + + assert result is not None + assert result["lat"] == 30.2672 + # Verify cache hit was logged + assert capability.worker.editor_logging_handler.info.called + + @pytest.mark.asyncio + @responses.activate + async def test_geocoding_no_results(self, capability): + """Should handle geocoding with no results.""" + capability._geocode_cache = {} + + responses.add( + responses.GET, + "https://geocoding-api.open-meteo.com/v1/search", + json={"results": []}, + status=200, + ) + + result = await capability._geocode_location("Nonexistent Place") + + assert result is None + + +# Run these tests with: pytest tests/test_api_integration.py -v diff --git a/community/pet-care-assistant/tests/test_exit_detection.py b/community/pet-care-assistant/tests/test_exit_detection.py new file mode 100644 index 0000000..effd662 --- /dev/null +++ b/community/pet-care-assistant/tests/test_exit_detection.py @@ -0,0 +1,497 @@ +"""Tests for exit detection logic in Pet Care Assistant. + +Tests the three-tier exit detection system: +1. Force-exit phrases (instant shutdown) +2. Exit commands (word-based matching) +3. Exit responses (exact or prefix matching) +4. LLM fallback for ambiguous inputs +""" + +import pytest +from hypothesis import given +from hypothesis import strategies as st + + +class TestCleanInput: + """Test input cleaning logic.""" + + def test_removes_punctuation(self): + """Should remove all punctuation except apostrophes.""" + from main import LLMService + + assert LLMService.clean_input("Stop!") == "stop" + assert LLMService.clean_input("Done.") == "done" + assert LLMService.clean_input("Quit???") == "quit" + assert LLMService.clean_input("No, thanks!") == "no thanks" + + def test_preserves_apostrophes(self): + """Should preserve apostrophes in contractions.""" + from main import LLMService + + assert LLMService.clean_input("I'm done") == "i'm done" + assert LLMService.clean_input("That's it") == "that's it" + assert LLMService.clean_input("We're good") == "we're good" + + def test_lowercases(self): + """Should convert all text to lowercase.""" + from main import LLMService + + assert LLMService.clean_input("STOP") == "stop" + assert LLMService.clean_input("Exit") == "exit" + assert LLMService.clean_input("DONE") == "done" + + def test_empty_input(self): + """Should handle empty strings.""" + from main import LLMService + + assert LLMService.clean_input("") == "" + + def test_whitespace_only(self): + """Should handle whitespace-only input.""" + from main import LLMService + + assert LLMService.clean_input(" ") == "" + assert LLMService.clean_input("\t\n") == "" + + def test_multiple_spaces(self): + """Should normalize multiple spaces.""" + from main import LLMService + + # Regex will remove extra spaces when punctuation is removed + result = LLMService.clean_input("no thanks") + assert "no" in result and "thanks" in result + + def test_mixed_punctuation(self): + """Should handle mixed punctuation.""" + from main import LLMService + + assert LLMService.clean_input("Done, thanks!!!") == "done thanks" + assert LLMService.clean_input("I'm done.") == "i'm done" + + +class TestIsExitTier1ForceExitPhrases: + """Test Tier 1: Force-exit phrases (instant shutdown).""" + + def test_exit_petcare_exact(self, capability): + """Should detect 'exit petcare' exactly.""" + assert capability.llm_service.is_exit("exit petcare") is True + + def test_close_petcare_exact(self, capability): + """Should detect 'close petcare' exactly.""" + assert capability.llm_service.is_exit("close petcare") is True + + def test_shut_down_pets_exact(self, capability): + """Should detect 'shut down pets' exactly.""" + assert capability.llm_service.is_exit("shut down pets") is True + + def test_petcare_out_exact(self, capability): + """Should detect 'petcare out' exactly.""" + assert capability.llm_service.is_exit("petcare out") is True + + def test_force_exit_with_extra_words(self, capability): + """Should detect force-exit phrases with extra words.""" + assert capability.llm_service.is_exit("please exit petcare now") is True + assert capability.llm_service.is_exit("I want to close petcare") is True + + def test_force_exit_with_punctuation(self, capability): + """Should detect force-exit phrases with punctuation.""" + assert capability.llm_service.is_exit("Exit petcare!") is True + assert capability.llm_service.is_exit("Close petcare.") is True + + def test_force_exit_case_insensitive(self, capability): + """Should detect force-exit phrases case-insensitively.""" + assert capability.llm_service.is_exit("EXIT PETCARE") is True + assert capability.llm_service.is_exit("Close PetCare") is True + + +class TestIsExitTier2ExitCommands: + """Test Tier 2: Exit commands (word-based matching).""" + + def test_stop_exact(self, capability): + """Should detect 'stop' as exact word.""" + assert capability.llm_service.is_exit("stop") is True + + def test_exit_exact(self, capability): + """Should detect 'exit' as exact word.""" + assert capability.llm_service.is_exit("exit") is True + + def test_quit_exact(self, capability): + """Should detect 'quit' as exact word.""" + assert capability.llm_service.is_exit("quit") is True + + def test_cancel_exact(self, capability): + """Should detect 'cancel' as exact word.""" + assert capability.llm_service.is_exit("cancel") is True + + def test_stop_in_sentence(self, capability): + """Should detect 'stop' as word within sentence.""" + assert capability.llm_service.is_exit("I want to stop now") is True + assert capability.llm_service.is_exit("please stop") is True + + def test_stop_not_as_substring(self, capability): + """Should NOT detect 'stop' as substring in other words.""" + assert capability.llm_service.is_exit("stopping by later") is False + assert capability.llm_service.is_exit("non-stop") is False + + def test_exit_commands_with_punctuation(self, capability): + """Should detect exit commands with punctuation.""" + assert capability.llm_service.is_exit("Stop!") is True + assert capability.llm_service.is_exit("Quit.") is True + + def test_exit_commands_case_insensitive(self, capability): + """Should detect exit commands case-insensitively.""" + assert capability.llm_service.is_exit("STOP") is True + assert capability.llm_service.is_exit("Exit") is True + + +class TestIsExitTier3ExitResponses: + """Test Tier 3: Exit responses (exact or prefix matching).""" + + def test_no_exact(self, capability): + """Should detect 'no' as exact match.""" + assert capability.llm_service.is_exit("no") is True + + def test_nope_exact(self, capability): + """Should detect 'nope' as exact match.""" + assert capability.llm_service.is_exit("nope") is True + + def test_done_exact(self, capability): + """Should detect 'done' as exact match.""" + assert capability.llm_service.is_exit("done") is True + + def test_bye_exact(self, capability): + """Should detect 'bye' as exact match.""" + assert capability.llm_service.is_exit("bye") is True + + def test_thanks_exact(self, capability): + """Should detect 'thanks' as exact match.""" + assert capability.llm_service.is_exit("thanks") is True + + def test_no_thanks_exact(self, capability): + """Should detect 'no thanks' as exact match.""" + assert capability.llm_service.is_exit("no thanks") is True + + def test_nothing_else_exact(self, capability): + """Should detect 'nothing else' as exact match.""" + assert capability.llm_service.is_exit("nothing else") is True + + def test_thats_all_exact(self, capability): + """Should detect 'that's all' as exact match.""" + assert capability.llm_service.is_exit("that's all") is True + + def test_im_done_exact(self, capability): + """Should detect 'i'm done' as exact match.""" + assert capability.llm_service.is_exit("i'm done") is True + + def test_no_prefix_match(self, capability): + """Should detect 'no' as prefix match.""" + assert capability.llm_service.is_exit("no thanks") is True + assert capability.llm_service.is_exit("No, I'm good") is True + + def test_done_prefix_match(self, capability): + """Should detect 'done' as prefix match.""" + assert capability.llm_service.is_exit("done for now") is True + + def test_no_in_sentence_not_prefix(self, capability): + """Should NOT detect 'no' in middle of sentence.""" + assert capability.llm_service.is_exit("I have no questions") is False + assert capability.llm_service.is_exit("there are no issues") is False + + def test_exit_responses_with_punctuation(self, capability): + """Should detect exit responses with punctuation.""" + assert capability.llm_service.is_exit("No!") is True + assert capability.llm_service.is_exit("Done, thanks!") is True + assert capability.llm_service.is_exit("Bye.") is True + + def test_exit_responses_case_insensitive(self, capability): + """Should detect exit responses case-insensitively.""" + assert capability.llm_service.is_exit("NO") is True + assert capability.llm_service.is_exit("Done") is True + + +class TestIsExitNonExitQueries: + """Test that non-exit queries are NOT detected as exits.""" + + def test_pet_care_queries(self, capability): + """Should NOT detect normal pet care queries as exits.""" + assert capability.llm_service.is_exit("What's Luna's weight?") is False + assert capability.llm_service.is_exit("How many walks today?") is False + assert capability.llm_service.is_exit("When did I last feed Max?") is False + + def test_questions_with_no(self, capability): + """Should NOT detect questions containing 'no' as exits.""" + assert capability.llm_service.is_exit("Does Luna have no allergies?") is False + assert capability.llm_service.is_exit("Are there no vets nearby?") is False + + def test_statements_with_exit_words_as_substrings(self, capability): + """Should NOT detect exit words as substrings.""" + assert capability.llm_service.is_exit("stopping by the vet") is False + assert capability.llm_service.is_exit("Luna is non-stop energy") is False + + def test_ok_and_okay(self, capability): + """Should NOT detect 'ok' or 'okay' as exits.""" + assert capability.llm_service.is_exit("ok") is False + assert capability.llm_service.is_exit("okay") is False + assert capability.llm_service.is_exit("OK, what's next?") is False + + +class TestIsExitEmptyAndWhitespace: + """Test edge cases: empty and whitespace-only input.""" + + def test_empty_string(self, capability): + """Should return False for empty string.""" + assert capability.llm_service.is_exit("") is False + + def test_whitespace_only(self, capability): + """Should return False for whitespace-only input.""" + assert capability.llm_service.is_exit(" ") is False + assert capability.llm_service.is_exit("\t\n") is False + + def test_none_input(self, capability): + """Should handle None gracefully.""" + # _is_exit checks for not text, which catches None + assert capability.llm_service.is_exit(None) is False + + +class TestIsExitLLMFallback: + """Test Tier 4: LLM fallback for ambiguous inputs.""" + + def test_llm_says_yes(self, capability): + """Should return True when LLM says user wants to exit.""" + capability.capability_worker.text_to_text_response.return_value = "yes" + assert capability.llm_service.is_exit_llm("maybe") is True + capability.capability_worker.text_to_text_response.assert_called_once() + + def test_llm_says_no(self, capability): + """Should return False when LLM says user doesn't want to exit.""" + capability.capability_worker.text_to_text_response.return_value = "no" + assert capability.llm_service.is_exit_llm("hmm") is False + capability.capability_worker.text_to_text_response.assert_called_once() + + def test_llm_says_yes_with_extra_text(self, capability): + """Should handle LLM response with extra text.""" + capability.capability_worker.text_to_text_response.return_value = ( + "yes, they want to exit" + ) + assert capability.llm_service.is_exit_llm("I guess") is True + + def test_llm_failure_returns_false(self, capability): + """Should return False (fail safe) when LLM call fails.""" + capability.capability_worker.text_to_text_response.side_effect = Exception( + "LLM error" + ) + assert capability.llm_service.is_exit_llm("maybe") is False + + def test_llm_prompt_structure(self, capability): + """Should call LLM with correct prompt structure.""" + capability.capability_worker.text_to_text_response.return_value = "no" + capability.llm_service.is_exit_llm("hmm") + + call_args = capability.capability_worker.text_to_text_response.call_args[0][0] + assert "END the conversation" in call_args + assert "hmm" in call_args + + +class TestIsExitPropertyBased: + """Property-based tests using Hypothesis.""" + + @given(st.text()) + def test_never_crashes(self, text): + """Should never crash regardless of input.""" + from unittest.mock import MagicMock + + from main import LLMService + from main import PetCareAssistantCapability + + # Create capability with mocked dependencies inside test + cap = PetCareAssistantCapability(unique_name="test", matching_hotwords=[]) + cap.worker = MagicMock() + cap.worker.editor_logging_handler = MagicMock() + cap.capability_worker = MagicMock() + cap._geocode_cache = {} + cap.pet_data = {} + cap.llm_service = LLMService(cap.capability_worker, cap.worker, cap.pet_data) + + try: + result = cap.llm_service.is_exit(text) + assert isinstance(result, bool) + except Exception: + pytest.fail("_is_exit should not raise exceptions") + + @given(st.text(min_size=1, max_size=100)) + def test_clean_input_returns_string(self, text): + """_clean_input should always return a string.""" + from main import LLMService + + result = LLMService.clean_input(text) + assert isinstance(result, str) + + @given(st.text(alphabet=st.characters(blacklist_categories=("Cs",)), min_size=1)) + def test_exit_detection_deterministic(self, text): + """Exit detection should be deterministic (same input = same output).""" + from unittest.mock import MagicMock + + from main import LLMService + from main import PetCareAssistantCapability + + # Create capability with mocked dependencies inside test + cap = PetCareAssistantCapability(unique_name="test", matching_hotwords=[]) + cap.worker = MagicMock() + cap.worker.editor_logging_handler = MagicMock() + cap.capability_worker = MagicMock() + cap._geocode_cache = {} + cap.pet_data = {} + cap.llm_service = LLMService(cap.capability_worker, cap.worker, cap.pet_data) + + result1 = cap.llm_service.is_exit(text) + result2 = cap.llm_service.is_exit(text) + assert result1 == result2 + + +class TestIsHardExit: + """Test is_hard_exit() — onboarding-safe exit detection that ignores 'no'/'done' etc.""" + + def test_no_is_not_hard_exit(self, capability): + """'no' must NOT trigger hard exit (it's a valid answer to allergy questions).""" + assert capability.llm_service.is_hard_exit("no") is False + + def test_done_is_not_hard_exit(self, capability): + """'done' must NOT trigger hard exit.""" + assert capability.llm_service.is_hard_exit("done") is False + + def test_bye_is_not_hard_exit(self, capability): + """'bye' must NOT trigger hard exit.""" + assert capability.llm_service.is_hard_exit("bye") is False + + def test_nope_is_not_hard_exit(self, capability): + """'nope' must NOT trigger hard exit.""" + assert capability.llm_service.is_hard_exit("nope") is False + + def test_stop_is_hard_exit(self, capability): + """'stop' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("stop") is True + + def test_quit_is_hard_exit(self, capability): + """'quit' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("quit") is True + + def test_exit_is_hard_exit(self, capability): + """'exit' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("exit") is True + + def test_cancel_is_hard_exit(self, capability): + """'cancel' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("cancel") is True + + def test_start_over_is_hard_exit(self, capability): + """'start over' must trigger hard exit (key onboarding bug fix).""" + assert capability.llm_service.is_hard_exit("start over") is True + + def test_wanna_start_over_is_hard_exit(self, capability): + """'wanna start over' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("Wanna start over") is True + assert capability.llm_service.is_hard_exit("wanna start over.") is True + + def test_want_to_start_over_is_hard_exit(self, capability): + """'want to start over' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("I want to start over") is True + + def test_restart_is_hard_exit(self, capability): + """'restart' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("restart") is True + assert capability.llm_service.is_hard_exit("I want to restart") is True + + def test_reset_everything_is_hard_exit(self, capability): + """'reset everything' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("reset everything") is True + + def test_start_from_scratch_is_hard_exit(self, capability): + """'start from scratch' must trigger hard exit.""" + assert capability.llm_service.is_hard_exit("start from scratch") is True + + def test_force_exit_phrase_is_hard_exit(self, capability): + """Force-exit phrases (Tier 1) must also trigger hard exit.""" + assert capability.llm_service.is_hard_exit("exit petcare") is True + assert capability.llm_service.is_hard_exit("close petcare") is True + + def test_normal_pet_answer_is_not_hard_exit(self, capability): + """Normal onboarding answers must NOT trigger hard exit.""" + assert capability.llm_service.is_hard_exit("golden retriever") is False + assert capability.llm_service.is_hard_exit("She has no allergies") is False + assert capability.llm_service.is_hard_exit("Dr Smith") is False + assert capability.llm_service.is_hard_exit("Austin Texas") is False + + def test_empty_is_not_hard_exit(self, capability): + """Empty string must NOT trigger hard exit.""" + assert capability.llm_service.is_hard_exit("") is False + + def test_none_is_not_hard_exit(self, capability): + """None must NOT trigger hard exit.""" + assert capability.llm_service.is_hard_exit(None) is False + + +class TestResetNotExit: + """Reset phrases must NOT be detected as exits by is_exit(). + + 'Start over', 'restart', etc. mean reset_all, not 'goodbye'. + The main loop guards against the LLM exit fallback misclassifying these. + """ + + def test_start_over_is_not_exit(self, capability): + """'Start over' should NOT be an exit — it's a reset command.""" + assert capability.llm_service.is_exit("start over") is False + assert capability.llm_service.is_exit("Start over.") is False + + def test_restart_is_not_exit(self, capability): + """'restart' should NOT be an exit — it's a reset command.""" + assert capability.llm_service.is_exit("restart") is False + + def test_start_from_scratch_is_not_exit(self, capability): + """'start from scratch' should NOT be an exit.""" + assert capability.llm_service.is_exit("start from scratch") is False + + def test_reset_everything_is_not_exit(self, capability): + """'reset everything' should NOT be an exit.""" + assert capability.llm_service.is_exit("reset everything") is False + + def test_wanna_start_over_is_not_exit(self, capability): + """'I wanna start over' should NOT be an exit.""" + assert capability.llm_service.is_exit("I wanna start over") is False + + +class TestIsExitIntegration: + """Integration tests for exit detection in realistic scenarios.""" + + def test_quick_mode_exit_flow(self, capability): + """Should detect exits in quick mode follow-up.""" + # User completes main task, says "no thanks" to follow-up + assert capability.llm_service.is_exit("no thanks") is True + + def test_full_mode_idle_exit(self, capability): + """Should detect exits after idle timeout.""" + # User says "done" after idle warning + assert capability.llm_service.is_exit("done") is True + + def test_onboarding_exit(self, capability): + """Should detect exits during onboarding.""" + # User says "cancel" during onboarding + assert capability.llm_service.is_exit("cancel") is True + + def test_multi_turn_conversation_exit(self, capability): + """Should detect exits after multiple turns.""" + # User says "that's all" after several interactions + assert capability.llm_service.is_exit("that's all") is True + + def test_false_positive_prevention(self, capability): + """Should NOT false-positive on common pet care queries.""" + queries = [ + "Luna has no allergies", + "How many walks this week?", + "Is Max okay?", + "What's the vet's number?", + "Can I walk Luna today?", + ] + for query in queries: + assert ( + capability.llm_service.is_exit(query) is False + ), f"False positive on: {query}" diff --git a/community/pet-care-assistant/tests/test_inline_query.py b/community/pet-care-assistant/tests/test_inline_query.py new file mode 100644 index 0000000..28da19e --- /dev/null +++ b/community/pet-care-assistant/tests/test_inline_query.py @@ -0,0 +1,323 @@ +"""Tests for _answer_inline_query — mid-onboarding pet inventory detection. + +When users ask "Do you have any animals?" embedded in an onboarding answer +(e.g. mixed into their allergy response), the system should: +1. Detect the inline question. +2. Answer with the current registered pets. +3. Return True so the caller re-asks its prompt. +""" + +from unittest.mock import AsyncMock + +import pytest + + +class TestAnswerInlineQuery: + """Tests for PetCareAssistantCapability._answer_inline_query.""" + + # ── Returns False (normal answers, no inventory question) ───────────── + + @pytest.mark.asyncio + async def test_normal_no_answer_returns_false(self, capability): + """'No' to allergies should not trigger inline query.""" + result = await capability._answer_inline_query("No") + assert result is False + capability.capability_worker.speak.assert_not_called() + + @pytest.mark.asyncio + async def test_normal_health_answer_returns_false(self, capability): + """A real health answer should not trigger inline query.""" + result = await capability._answer_inline_query("She's allergic to chicken") + assert result is False + capability.capability_worker.speak.assert_not_called() + + @pytest.mark.asyncio + async def test_weight_answer_returns_false(self, capability): + """'48 pounds' should not trigger inline query.""" + result = await capability._answer_inline_query("48 pounds") + assert result is False + capability.capability_worker.speak.assert_not_called() + + @pytest.mark.asyncio + async def test_empty_string_returns_false(self, capability): + """Empty string should return False without speaking.""" + result = await capability._answer_inline_query("") + assert result is False + capability.capability_worker.speak.assert_not_called() + + @pytest.mark.asyncio + async def test_none_returns_false(self, capability): + """None should return False without speaking.""" + result = await capability._answer_inline_query(None) + assert result is False + capability.capability_worker.speak.assert_not_called() + + @pytest.mark.asyncio + async def test_skip_returns_false(self, capability): + """'Skip' should not trigger inline query.""" + result = await capability._answer_inline_query("skip") + assert result is False + + # ── Returns True (inventory question detected) ───────────────────────── + + @pytest.mark.asyncio + async def test_do_you_have_any_animal_triggers(self, capability): + """Classic STT output: 'Do you have any animal?' triggers inline response.""" + capability.pet_data = {} + result = await capability._answer_inline_query("Do you have any animal?") + assert result is True + capability.capability_worker.speak.assert_called_once() + + @pytest.mark.asyncio + async def test_any_pets_triggers(self, capability): + """'Any pets?' triggers inline response.""" + capability.pet_data = {} + result = await capability._answer_inline_query("any pets registered?") + assert result is True + + @pytest.mark.asyncio + async def test_any_animals_triggers(self, capability): + """'Any animals?' triggers inline response.""" + capability.pet_data = {} + result = await capability._answer_inline_query("do you have any animals?") + assert result is True + + @pytest.mark.asyncio + async def test_what_pets_triggers(self, capability): + """'What pets do I have?' triggers inline response.""" + capability.pet_data = {} + result = await capability._answer_inline_query("what pets do I have") + assert result is True + + @pytest.mark.asyncio + async def test_how_many_pets_triggers(self, capability): + """'How many pets?' triggers inline response.""" + capability.pet_data = {} + result = await capability._answer_inline_query("how many pets are there") + assert result is True + + @pytest.mark.asyncio + async def test_garbled_stt_mixed_answer_triggers(self, capability): + """Garbled STT: 'No, no. Dog dog. Do you have any animal?' triggers.""" + capability.pet_data = {} + result = await capability._answer_inline_query( + "Dos. No, no. Dog dog. Do you have any animal?" + ) + assert result is True + + # ── Correct spoken response per pet count ───────────────────────────── + + @pytest.mark.asyncio + async def test_speaks_no_pets_yet_when_empty(self, capability): + """When no pets are set up, says we're setting one up now.""" + capability.pet_data = {} + await capability._answer_inline_query("do you have any animal") + spoken = capability.capability_worker.speak.call_args[0][0] + assert "setting one up right now" in spoken.lower() + + @pytest.mark.asyncio + async def test_speaks_one_pet_name_and_species(self, capability): + """When one pet exists, speaks their name and species.""" + capability.pet_data = { + "pets": [{"name": "Luna", "species": "dog", "breed": "golden retriever"}] + } + await capability._answer_inline_query("do you have any pets") + spoken = capability.capability_worker.speak.call_args[0][0] + assert "Luna" in spoken + assert "dog" in spoken + + @pytest.mark.asyncio + async def test_speaks_multiple_pet_names(self, capability): + """When multiple pets exist, lists all names.""" + capability.pet_data = { + "pets": [ + {"name": "Luna", "species": "dog"}, + {"name": "Max", "species": "cat"}, + ] + } + await capability._answer_inline_query("how many pets do I have") + spoken = capability.capability_worker.speak.call_args[0][0] + assert "Luna" in spoken + assert "Max" in spoken + assert "2" in spoken + + @pytest.mark.asyncio + async def test_case_insensitive_detection(self, capability): + """Detection should be case-insensitive.""" + capability.pet_data = {} + result = await capability._answer_inline_query("DO YOU HAVE ANY ANIMAL?") + assert result is True + + @pytest.mark.asyncio + async def test_does_not_speak_twice_for_single_call(self, capability): + """Should speak exactly once per call, even with multiple patterns in input.""" + capability.pet_data = {} + await capability._answer_inline_query( + "any animals any pets how many pets do you have any" + ) + assert capability.capability_worker.speak.call_count == 1 + + @pytest.mark.asyncio + async def test_do_i_have_any_triggers(self, capability): + """'Do I have any other I have any animal' (garbled STT) triggers.""" + capability.pet_data = {} + result = await capability._answer_inline_query( + "Do I have any other I have any animal I have any animal?" + ) + assert result is True + + # ── Tier 2: LLM-based general question detection ───────────────────── + + @pytest.mark.asyncio + async def test_short_question_words_do_not_trigger_llm(self, capability): + """Short inputs with question words shouldn't trigger the LLM (too expensive).""" + result = await capability._answer_inline_query("What? No.") + assert result is False + + @pytest.mark.asyncio + async def test_lookup_intent_triggers_handler(self, capability): + """A question about stored pet info should be answered via _handle_lookup.""" + capability.pet_data = { + "pets": [ + { + "id": "pet_abc", + "name": "Luna", + "species": "dog", + "breed": "golden retriever", + "weight_lbs": 48, + "birthday": "", + "allergies": [], + "medications": [], + } + ] + } + capability.activity_log = [] + # Mock the LLM classifier to return lookup intent + capability.llm_service.classify_intent_async = AsyncMock( + return_value={"mode": "lookup", "pet_name": "Luna", "query": "profile info"} + ) + result = await capability._answer_inline_query( + "Tell me about Luna's registered info" + ) + assert result is True + # Should have spoken Luna's profile + capability.capability_worker.speak.assert_called() + + @pytest.mark.asyncio + async def test_log_intent_defers_to_after_onboarding(self, capability): + """Log intents (question-like) should be acknowledged and deferred.""" + capability.llm_service.classify_intent_async = AsyncMock( + return_value={"mode": "log", "pet_name": "Luna", "activity_type": "feeding"} + ) + result = await capability._answer_inline_query( + "Can you log that I just fed Luna some food" + ) + assert result is True + spoken = capability.capability_worker.speak.call_args[0][0] + assert "finish" in spoken.lower() or "continue" in spoken.lower() + + @pytest.mark.asyncio + async def test_unknown_pet_related_defers(self, capability): + """Unknown intent that IS pet-related should defer, returning True.""" + capability.llm_service.classify_intent_async = AsyncMock( + return_value={"mode": "unknown"} + ) + capability._is_pet_care_related = AsyncMock(return_value=True) + result = await capability._answer_inline_query( + "What food is best for puppies in winter" + ) + assert result is True + capability.capability_worker.speak.assert_called() + + @pytest.mark.asyncio + async def test_unknown_unrelated_returns_false(self, capability): + """Unknown intent that is NOT pet-related should return False.""" + capability.llm_service.classify_intent_async = AsyncMock( + return_value={"mode": "unknown"} + ) + capability._is_pet_care_related = AsyncMock(return_value=False) + result = await capability._answer_inline_query( + "What is the meaning of life and everything" + ) + assert result is False + + @pytest.mark.asyncio + async def test_weather_intent_handled_inline(self, capability): + """Weather intent should be handled inline during onboarding.""" + capability.llm_service.classify_intent_async = AsyncMock( + return_value={"mode": "weather", "pet_name": "Luna"} + ) + capability._handle_weather = AsyncMock() + result = await capability._answer_inline_query( + "Can you tell me if it is safe for Luna outside" + ) + assert result is True + capability._handle_weather.assert_called_once() + + @pytest.mark.asyncio + async def test_reminder_intent_handled_inline(self, capability): + """Reminder intent should be handled inline during onboarding.""" + capability.llm_service.classify_intent_async = AsyncMock( + return_value={ + "mode": "reminder", + "action": "set", + "pet_name": "Luna", + "activity": "feeding", + "time_description": "in 2 hours", + } + ) + capability._handle_reminder = AsyncMock() + result = await capability._answer_inline_query( + "Can you remind me to feed Luna in two hours" + ) + assert result is True + capability._handle_reminder.assert_called_once() + + +class TestAskOnboardingStep: + """Tests for PetCareAssistantCapability._ask_onboarding_step.""" + + @pytest.mark.asyncio + async def test_returns_normal_response(self, capability): + """Normal answers pass through unchanged.""" + capability.capability_worker.run_io_loop = AsyncMock(return_value="48 pounds") + result = await capability._ask_onboarding_step("How much does Luna weigh?") + assert result == "48 pounds" + + @pytest.mark.asyncio + async def test_returns_none_on_hard_exit(self, capability): + """Hard exit (stop/quit) returns None.""" + capability.capability_worker.run_io_loop = AsyncMock(return_value="stop") + result = await capability._ask_onboarding_step("How much does Luna weigh?") + assert result is None + + @pytest.mark.asyncio + async def test_returns_empty_string_on_empty_input(self, capability): + """Empty user input returns empty string (not None).""" + capability.capability_worker.run_io_loop = AsyncMock(return_value="") + result = await capability._ask_onboarding_step("How much does Luna weigh?") + assert result == "" + + @pytest.mark.asyncio + async def test_reasks_after_inline_query(self, capability): + """If user asks about pets, answer then re-ask the prompt.""" + capability.pet_data = {} + # First call returns the inline query, second returns the real answer + capability.capability_worker.run_io_loop = AsyncMock( + side_effect=["do you have any animal?", "48 pounds"] + ) + result = await capability._ask_onboarding_step("How much does Luna weigh?") + assert result == "48 pounds" + # Should have spoken the inline answer AND called run_io_loop twice + capability.capability_worker.speak.assert_called_once() + assert capability.capability_worker.run_io_loop.call_count == 2 + + @pytest.mark.asyncio + async def test_hard_exit_after_inline_query_reask(self, capability): + """If user exits on the re-ask after inline query, returns None.""" + capability.pet_data = {} + capability.capability_worker.run_io_loop = AsyncMock( + side_effect=["do you have any animal?", "quit"] + ) + result = await capability._ask_onboarding_step("How much does Luna weigh?") + assert result is None diff --git a/community/pet-care-assistant/tests/test_llm_extraction.py b/community/pet-care-assistant/tests/test_llm_extraction.py new file mode 100644 index 0000000..e463d64 --- /dev/null +++ b/community/pet-care-assistant/tests/test_llm_extraction.py @@ -0,0 +1,598 @@ +"""Tests for LLM-based extraction methods in Pet Care Assistant. + +Tests the 10 typed extraction methods that parse user voice input: +- _extract_pet_name_async +- _extract_species_async +- _extract_breed_async +- _extract_birthday_async +- _extract_weight_async +- _extract_allergies_async +- _extract_medications_async +- _extract_vet_name_async +- _extract_phone_number_async +- _extract_location_async + +Uses mocked LLM responses to test extraction logic and edge cases. +""" + +from datetime import datetime + +import pytest + + +class TestExtractPetName: + """Tests for _extract_pet_name_async.""" + + @pytest.mark.asyncio + async def test_simple_name(self, capability): + """Should extract simple pet names.""" + capability.capability_worker.text_to_text_response.return_value = "Max" + + result = await capability.llm_service.extract_pet_name_async("His name is Max") + + assert result == "Max" + + @pytest.mark.asyncio + async def test_name_with_title(self, capability): + """Should extract names with titles.""" + capability.capability_worker.text_to_text_response.return_value = ( + "Princess Luna" + ) + + result = await capability.llm_service.extract_pet_name_async( + "She's called Princess Luna" + ) + + assert result == "Princess Luna" + + @pytest.mark.asyncio + async def test_name_with_extra_words(self, capability): + """Should extract name from verbose input.""" + capability.capability_worker.text_to_text_response.return_value = "Buddy" + + result = await capability.llm_service.extract_pet_name_async( + "Well, we named him Buddy after my grandfather" + ) + + assert result == "Buddy" + + @pytest.mark.asyncio + async def test_empty_input(self, capability): + """Should handle empty input gracefully.""" + capability.capability_worker.text_to_text_response.side_effect = Exception( + "LLM error" + ) + + result = await capability.llm_service.extract_pet_name_async("") + + assert result == "" + + @pytest.mark.asyncio + async def test_llm_failure_returns_raw_input(self, capability): + """Should return raw input if LLM fails.""" + capability.capability_worker.text_to_text_response.side_effect = Exception( + "LLM error" + ) + + result = await capability.llm_service.extract_pet_name_async("Max") + + assert result == "Max" + + +class TestExtractSpecies: + """Tests for _extract_species_async.""" + + @pytest.mark.asyncio + async def test_dog(self, capability): + """Should extract 'dog' species.""" + capability.capability_worker.text_to_text_response.return_value = "dog" + + result = await capability.llm_service.extract_species_async( + "He's a golden retriever" + ) + + assert result == "dog" + + @pytest.mark.asyncio + async def test_cat(self, capability): + """Should extract 'cat' species.""" + capability.capability_worker.text_to_text_response.return_value = "cat" + + result = await capability.llm_service.extract_species_async( + "She's a Maine Coon cat" + ) + + assert result == "cat" + + @pytest.mark.asyncio + async def test_exotic_species(self, capability): + """Should extract exotic species.""" + capability.capability_worker.text_to_text_response.return_value = "rabbit" + + result = await capability.llm_service.extract_species_async( + "A Holland Lop rabbit" + ) + + assert result == "rabbit" + + @pytest.mark.asyncio + async def test_species_with_breed(self, capability): + """Should extract species when breed is mentioned.""" + capability.capability_worker.text_to_text_response.return_value = "dog" + + result = await capability.llm_service.extract_species_async( + "German Shepherd dog" + ) + + assert result == "dog" + + @pytest.mark.asyncio + async def test_unclear_species_returns_unknown(self, capability): + """Should return 'unknown' when species is not explicitly mentioned.""" + capability.capability_worker.text_to_text_response.return_value = "unknown" + + result = await capability.llm_service.extract_species_async( + "Just a regular pet" + ) + + assert result == "unknown" + + @pytest.mark.asyncio + async def test_name_only_returns_unknown(self, capability): + """Should return 'unknown' for a pet name with no species info (no hallucination).""" + capability.capability_worker.text_to_text_response.return_value = "unknown" + + result = await capability.llm_service.extract_species_async("Luna") + + assert result == "unknown" + + @pytest.mark.asyncio + async def test_trigger_phrase_returns_unknown(self, capability): + """Trigger phrase 'Pet care Luna' must NOT hallucinate a species.""" + capability.capability_worker.text_to_text_response.return_value = "unknown" + + result = await capability.llm_service.extract_species_async("Pet care Luna") + + assert result == "unknown" + + +class TestExtractBreed: + """Tests for _extract_breed_async.""" + + @pytest.mark.asyncio + async def test_pure_breed(self, capability): + """Should extract pure breed names.""" + capability.capability_worker.text_to_text_response.return_value = ( + "golden retriever" + ) + + result = await capability.llm_service.extract_breed_async("Golden Retriever") + + assert result == "golden retriever" + + @pytest.mark.asyncio + async def test_mixed_breed(self, capability): + """Should return 'mixed' for mixed breeds.""" + capability.capability_worker.text_to_text_response.return_value = "mixed" + + result = await capability.llm_service.extract_breed_async("He's a mutt") + + assert result == "mixed" + + @pytest.mark.asyncio + async def test_unknown_breed(self, capability): + """Should return 'mixed' when breed unknown.""" + capability.capability_worker.text_to_text_response.return_value = "mixed" + + result = await capability.llm_service.extract_breed_async( + "I don't know the breed" + ) + + assert result == "mixed" + + @pytest.mark.asyncio + async def test_hyphenated_breed(self, capability): + """Should handle hyphenated breed names.""" + capability.capability_worker.text_to_text_response.return_value = ( + "French Bulldog" + ) + + result = await capability.llm_service.extract_breed_async("French-Bulldog") + + assert result == "French Bulldog" + + +class TestExtractBirthday: + """Tests for _extract_birthday_async.""" + + @pytest.mark.asyncio + async def test_exact_date(self, capability): + """Should extract exact birth dates.""" + capability.capability_worker.text_to_text_response.return_value = "2020-06-15" + + result = await capability.llm_service.extract_birthday_async( + "Born on June 15, 2020" + ) + + assert result == "2020-06-15" + + @pytest.mark.asyncio + async def test_age_calculation(self, capability): + """Should calculate birthday from age.""" + # Mock to return a calculated date + current_year = datetime.now().year + expected_year = current_year - 3 + capability.capability_worker.text_to_text_response.return_value = ( + f"{expected_year}-01-01" + ) + + result = await capability.llm_service.extract_birthday_async("3 years old") + + assert result.startswith(str(expected_year)) + + @pytest.mark.asyncio + async def test_approximate_year(self, capability): + """Should handle approximate year.""" + capability.capability_worker.text_to_text_response.return_value = "2019-01-01" + + result = await capability.llm_service.extract_birthday_async("Born in 2019") + + assert result.startswith("2019") + + @pytest.mark.asyncio + async def test_recent_birth(self, capability): + """Should handle recent births.""" + capability.capability_worker.text_to_text_response.return_value = "2025-12-01" + + result = await capability.llm_service.extract_birthday_async( + "Just got him last month" + ) + + assert "2025" in result + + +class TestExtractWeight: + """Tests for _extract_weight_async.""" + + @pytest.mark.asyncio + async def test_weight_in_pounds(self, capability): + """Should extract weight in pounds.""" + capability.capability_worker.text_to_text_response.return_value = "55" + + result = await capability.llm_service.extract_weight_async("55 pounds") + + assert result == "55" + + @pytest.mark.asyncio + async def test_weight_in_kilos(self, capability): + """Should convert kilos to pounds.""" + capability.capability_worker.text_to_text_response.return_value = ( + "55" # 25 kg ≈ 55 lbs + ) + + result = await capability.llm_service.extract_weight_async("25 kilograms") + + assert result == "55" + + @pytest.mark.asyncio + async def test_weight_with_lbs_abbreviation(self, capability): + """Should handle 'lbs' abbreviation.""" + capability.capability_worker.text_to_text_response.return_value = "70" + + result = await capability.llm_service.extract_weight_async("70 lbs") + + assert result == "70" + + @pytest.mark.asyncio + async def test_decimal_weight(self, capability): + """Should handle decimal weights.""" + capability.capability_worker.text_to_text_response.return_value = "12.5" + + result = await capability.llm_service.extract_weight_async("12.5 pounds") + + assert result == "12.5" + + @pytest.mark.asyncio + async def test_approximate_weight(self, capability): + """Should handle approximate weights.""" + capability.capability_worker.text_to_text_response.return_value = "60" + + result = await capability.llm_service.extract_weight_async("around 60 pounds") + + assert result == "60" + + +class TestExtractAllergies: + """Tests for _extract_allergies_async.""" + + @pytest.mark.asyncio + async def test_single_allergy(self, capability): + """Should extract single allergy as JSON array.""" + capability.capability_worker.text_to_text_response.return_value = '["chicken"]' + + result = await capability.llm_service.extract_allergies_async( + "Allergic to chicken" + ) + + assert result == '["chicken"]' + + @pytest.mark.asyncio + async def test_multiple_allergies(self, capability): + """Should extract multiple allergies.""" + capability.capability_worker.text_to_text_response.return_value = ( + '["chicken", "grain"]' + ) + + result = await capability.llm_service.extract_allergies_async( + "Allergic to chicken and grain" + ) + + assert result == '["chicken", "grain"]' + + @pytest.mark.asyncio + async def test_no_allergies(self, capability): + """Should return empty array for no allergies.""" + capability.capability_worker.text_to_text_response.return_value = "[]" + + result = await capability.llm_service.extract_allergies_async("No allergies") + + assert result == "[]" + + @pytest.mark.asyncio + async def test_allergy_with_description(self, capability): + """Should extract allergy from detailed description.""" + capability.capability_worker.text_to_text_response.return_value = '["beef"]' + + result = await capability.llm_service.extract_allergies_async( + "Gets really itchy when eating beef" + ) + + assert result == '["beef"]' + + +class TestExtractMedications: + """Tests for _extract_medications_async.""" + + @pytest.mark.asyncio + async def test_single_medication(self, capability): + """Should extract single medication with frequency.""" + capability.capability_worker.text_to_text_response.return_value = ( + '[{"name": "Heartgard", "frequency": "monthly"}]' + ) + + result = await capability.llm_service.extract_medications_async( + "Takes Heartgard monthly" + ) + + assert result == '[{"name": "Heartgard", "frequency": "monthly"}]' + + @pytest.mark.asyncio + async def test_multiple_medications(self, capability): + """Should extract multiple medications.""" + capability.capability_worker.text_to_text_response.return_value = ( + '[{"name": "Heartgard", "frequency": "monthly"}, ' + '{"name": "Apoquel", "frequency": "daily"}]' + ) + + result = await capability.llm_service.extract_medications_async( + "Takes Heartgard monthly and Apoquel daily" + ) + + assert "Heartgard" in result + assert "Apoquel" in result + + @pytest.mark.asyncio + async def test_no_medications(self, capability): + """Should return empty array for no medications.""" + capability.capability_worker.text_to_text_response.return_value = "[]" + + result = await capability.llm_service.extract_medications_async( + "Not on any medications" + ) + + assert result == "[]" + + @pytest.mark.asyncio + async def test_medication_without_frequency(self, capability): + """Should handle medication without explicit frequency.""" + capability.capability_worker.text_to_text_response.return_value = ( + '[{"name": "Prednisone", "frequency": "as needed"}]' + ) + + result = await capability.llm_service.extract_medications_async( + "Takes Prednisone sometimes" + ) + + assert "Prednisone" in result + + +class TestExtractVetName: + """Tests for _extract_vet_name_async.""" + + @pytest.mark.asyncio + async def test_vet_with_title(self, capability): + """Should extract vet name with title.""" + capability.capability_worker.text_to_text_response.return_value = "Dr. Smith" + + result = await capability.llm_service.extract_vet_name_async( + "Dr. Smith at Austin Vet" + ) + + assert result == "Dr. Smith" + + @pytest.mark.asyncio + async def test_vet_without_title(self, capability): + """Should extract vet name without title.""" + capability.capability_worker.text_to_text_response.return_value = "John Smith" + + result = await capability.llm_service.extract_vet_name_async("John Smith") + + assert result == "John Smith" + + @pytest.mark.asyncio + async def test_clinic_name(self, capability): + """Should extract clinic name when that's what user provides.""" + capability.capability_worker.text_to_text_response.return_value = ( + "Austin Veterinary Clinic" + ) + + result = await capability.llm_service.extract_vet_name_async( + "Austin Veterinary Clinic" + ) + + assert result == "Austin Veterinary Clinic" + + @pytest.mark.asyncio + async def test_vet_with_clinic(self, capability): + """Should extract vet name from verbose input.""" + capability.capability_worker.text_to_text_response.return_value = "Dr. Johnson" + + result = await capability.llm_service.extract_vet_name_async( + "We go to Dr. Johnson at the North Austin Animal Hospital" + ) + + assert result == "Dr. Johnson" + + +class TestExtractPhoneNumber: + """Tests for _extract_phone_number_async.""" + + @pytest.mark.asyncio + async def test_formatted_phone(self, capability): + """Should extract digits from formatted phone.""" + capability.capability_worker.text_to_text_response.return_value = "5125551234" + + result = await capability.llm_service.extract_phone_number_async( + "(512) 555-1234" + ) + + assert result == "5125551234" + + @pytest.mark.asyncio + async def test_phone_with_spaces(self, capability): + """Should extract digits from phone with spaces.""" + capability.capability_worker.text_to_text_response.return_value = "5125551234" + + result = await capability.llm_service.extract_phone_number_async("512 555 1234") + + assert result == "5125551234" + + @pytest.mark.asyncio + async def test_phone_with_dots(self, capability): + """Should extract digits from phone with dots.""" + capability.capability_worker.text_to_text_response.return_value = "5125551234" + + result = await capability.llm_service.extract_phone_number_async("512.555.1234") + + assert result == "5125551234" + + @pytest.mark.asyncio + async def test_phone_with_country_code(self, capability): + """Should extract phone with country code.""" + capability.capability_worker.text_to_text_response.return_value = "15125551234" + + result = await capability.llm_service.extract_phone_number_async( + "+1 512-555-1234" + ) + + assert result == "15125551234" + + +class TestExtractLocation: + """Tests for _extract_location_async.""" + + @pytest.mark.asyncio + async def test_city_state(self, capability): + """Should extract city and state.""" + capability.capability_worker.text_to_text_response.return_value = ( + "Austin, Texas" + ) + + result = await capability.llm_service.extract_location_async( + "I live in Austin, Texas" + ) + + assert result == "Austin, Texas" + + @pytest.mark.asyncio + async def test_city_only(self, capability): + """Should handle city only input.""" + capability.capability_worker.text_to_text_response.return_value = "Austin, TX" + + result = await capability.llm_service.extract_location_async("Austin") + + assert "Austin" in result + + @pytest.mark.asyncio + async def test_city_country(self, capability): + """Should extract city and country.""" + capability.capability_worker.text_to_text_response.return_value = "London, UK" + + result = await capability.llm_service.extract_location_async( + "London, United Kingdom" + ) + + assert result == "London, UK" + + @pytest.mark.asyncio + async def test_verbose_location(self, capability): + """Should extract location from verbose input.""" + capability.capability_worker.text_to_text_response.return_value = ( + "Seattle, Washington" + ) + + result = await capability.llm_service.extract_location_async( + "We're based out of Seattle in Washington state" + ) + + assert result == "Seattle, Washington" + + +class TestExtractionEdgeCases: + """Tests for edge cases across all extraction methods.""" + + @pytest.mark.asyncio + async def test_none_input_pet_name(self, capability): + """Should handle None input gracefully.""" + capability.capability_worker.text_to_text_response.side_effect = Exception( + "LLM error" + ) + + result = await capability.llm_service.extract_pet_name_async(None) + + assert result == "" + + @pytest.mark.asyncio + async def test_very_long_input(self, capability): + """Should handle very long inputs.""" + capability.capability_worker.text_to_text_response.return_value = "Buddy" + + long_input = "Well, " * 100 + "his name is Buddy" + result = await capability.llm_service.extract_pet_name_async(long_input) + + assert result == "Buddy" + + @pytest.mark.asyncio + async def test_special_characters_in_input(self, capability): + """Should handle special characters.""" + capability.capability_worker.text_to_text_response.return_value = "Mr. Whiskers" + + result = await capability.llm_service.extract_pet_name_async( + "His name is Mr. Whiskers!!!" + ) + + assert result == "Mr. Whiskers" + + @pytest.mark.asyncio + async def test_unicode_in_input(self, capability): + """Should handle unicode characters.""" + capability.capability_worker.text_to_text_response.return_value = "Amélie" + + result = await capability.llm_service.extract_pet_name_async( + "Her name is Amélie 🐱" + ) + + assert result == "Amélie" + + +# Run these tests with: pytest tests/test_llm_extraction.py -v diff --git a/community/pet-care-assistant/tests/test_phone_formatting.py b/community/pet-care-assistant/tests/test_phone_formatting.py new file mode 100644 index 0000000..3e9f923 --- /dev/null +++ b/community/pet-care-assistant/tests/test_phone_formatting.py @@ -0,0 +1,200 @@ +"""Tests for phone number formatting in Pet Care Assistant. + +Tests the enhanced _fmt_phone_for_speech function that handles: +- US 10-digit numbers +- US 11-digit numbers with country code +- International numbers (7-15 digits) +- Edge cases (empty, too short, too long) +""" + +import pytest + + +def test_us_10_digit_basic(): + """Should format 10-digit US numbers with grouping.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("5125551234") + assert result == "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + +def test_us_10_digit_with_formatting(): + """Should handle 10-digit numbers with various formatting.""" + from main import _fmt_phone_for_speech + + # Parentheses and hyphens + assert _fmt_phone_for_speech("(512) 555-1234") == "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + # Dots + assert _fmt_phone_for_speech("512.555.1234") == "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + # Spaces + assert _fmt_phone_for_speech("512 555 1234") == "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + +def test_us_11_digit_with_country_code(): + """Should format 11-digit numbers starting with 1 (US country code).""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("15125551234") + assert result == "1, 5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + +def test_us_11_digit_with_country_code_formatted(): + """Should handle 11-digit with country code and formatting.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("+1 (512) 555-1234") + assert result == "1, 5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + result = _fmt_phone_for_speech("1-512-555-1234") + assert result == "1, 5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + +def test_international_7_digits(): + """Should group 7-digit numbers by 3s.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("5551234") + # Groups: 555, 123, 4 + assert result == "5, 5, 5, 1, 2, 3, 4" + + +def test_international_8_digits(): + """Should group 8-digit numbers by 3s.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("12345678") + # Groups: 123, 456, 78 + assert result == "1, 2, 3, 4, 5, 6, 7, 8" + + +def test_international_12_digits(): + """Should group 12-digit international numbers by 3s.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("441234567890") + # Groups: 441, 234, 567, 890 + assert result == "4, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0" + + +def test_international_with_plus_prefix(): + """Should handle international format with + prefix.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("+44 1234 567890") + # Extracts 441234567890 then groups by 3 + assert "4, 4, 1" in result and "5, 6, 7" in result + + +def test_empty_string(): + """Should handle empty string gracefully.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("") + assert result == "no number provided" + + +def test_none_input(): + """Should handle None input gracefully.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech(None) + assert result == "no number provided" + + +def test_only_non_digits(): + """Should handle input with no digits.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("---") + assert result == "no number provided" + + result = _fmt_phone_for_speech("()") + assert result == "no number provided" + + +def test_too_short_phone_number(): + """Should detect phone numbers that are too short (<7 digits).""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("12345") + assert result == "incomplete phone number" + + result = _fmt_phone_for_speech("123") + assert result == "incomplete phone number" + + +def test_too_long_phone_number(): + """Should detect phone numbers that are too long (>15 digits).""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech("12345678901234567890") + assert result == "phone number too long, please check" + + +def test_11_digits_not_starting_with_1(): + """Should group 11-digit numbers NOT starting with 1 by 3s.""" + from main import _fmt_phone_for_speech + + # Not a US number (doesn't start with 1) + result = _fmt_phone_for_speech("44123456789") + # Should group by 3s, not use US format + assert "4, 4, 1" in result + + +def test_phone_with_extension(): + """Should handle phone numbers with extensions (may be > 15 digits).""" + from main import _fmt_phone_for_speech + + # Phone with extension might exceed 15 digits + result = _fmt_phone_for_speech("512-555-1234 ext 12345") + # Would be 15+ digits, should warn + assert "too long" in result or "," in result + + +def test_voice_transcription_artifacts(): + """Should handle common voice transcription artifacts.""" + from main import _fmt_phone_for_speech + + # "Five one two, five five five, one two three four" + # Might be transcribed with spaces between each digit + result = _fmt_phone_for_speech("5 1 2 5 5 5 1 2 3 4") + assert result == "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + +def test_consistent_formatting(): + """Should format the same number consistently regardless of input format.""" + from main import _fmt_phone_for_speech + + formats = [ + "5125551234", + "(512) 555-1234", + "512-555-1234", + "512.555.1234", + "512 555 1234", + ] + + results = [_fmt_phone_for_speech(fmt) for fmt in formats] + + # All should produce the same output + assert len(set(results)) == 1 + assert results[0] == "5, 1, 2, 5, 5, 5, 1, 2, 3, 4" + + +@pytest.mark.parametrize( + "input_phone,expected_output", + [ + ("5125551234", "5, 1, 2, 5, 5, 5, 1, 2, 3, 4"), + ("15125551234", "1, 5, 1, 2, 5, 5, 5, 1, 2, 3, 4"), + ("", "no number provided"), + ("123", "incomplete phone number"), + ("12345678901234567890", "phone number too long, please check"), + ], +) +def test_phone_formatting_parametrized(input_phone, expected_output): + """Parametrized tests for various phone formats.""" + from main import _fmt_phone_for_speech + + result = _fmt_phone_for_speech(input_phone) + assert result == expected_output diff --git a/community/pet-care-assistant/tests/test_reminder_time.py b/community/pet-care-assistant/tests/test_reminder_time.py new file mode 100644 index 0000000..6860d6f --- /dev/null +++ b/community/pet-care-assistant/tests/test_reminder_time.py @@ -0,0 +1,183 @@ +"""Tests for _parse_reminder_time — natural language time parsing.""" + +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest + + +class TestParseReminderTime: + """Tests for PetCareAssistantCapability._parse_reminder_time.""" + + # ── Returns None for unparseable input ──────────────────────────────── + + def test_none_input(self, capability): + assert capability._parse_reminder_time(None) is None + + def test_empty_string(self, capability): + assert capability._parse_reminder_time("") is None + + def test_garbage_input(self, capability): + assert capability._parse_reminder_time("blah blah blah") is None + + # ── "in X minutes/hours" ────────────────────────────────────────────── + + def test_in_30_minutes(self, capability): + now = datetime.now() + result = capability._parse_reminder_time("in 30 minutes") + assert result is not None + diff = (result - now).total_seconds() + assert 1790 < diff < 1810 # ~30 minutes + + def test_in_2_hours(self, capability): + now = datetime.now() + result = capability._parse_reminder_time("in 2 hours") + assert result is not None + diff = (result - now).total_seconds() + assert 7190 < diff < 7210 # ~2 hours + + # ── "tomorrow at HH:MM" ────────────────────────────────────────────── + + def test_tomorrow_at_10am(self, capability): + now = datetime.now() + result = capability._parse_reminder_time("tomorrow at 10 am") + assert result is not None + assert result.hour == 10 + assert result.minute == 0 + assert result.date() == (now + timedelta(days=1)).date() + + def test_tomorrow_at_3pm(self, capability): + now = datetime.now() + result = capability._parse_reminder_time("tomorrow at 3pm") + assert result is not None + assert result.hour == 15 + assert result.date() == (now + timedelta(days=1)).date() + + # ── "at HH:MM" (today or tomorrow) ─────────────────────────────────── + + def test_at_time_future_today(self, capability): + """'at' a future time today should return today.""" + # Use a time far enough in the future to always be valid + result = capability._parse_reminder_time("at 11:59 pm") + assert result is not None + assert result.hour == 23 + assert result.minute == 59 + + # ── Day-of-week: "next Monday", "on Friday", "this Wednesday" ──────── + + @pytest.mark.parametrize("prefix", ["next", "this", "on"]) + def test_day_of_week_with_prefix(self, capability, prefix): + """'next/this/on ' should resolve to correct future date.""" + # Pick a day that's NOT today to avoid edge cases + now = datetime.now() + # Use a day 3 days from now + target_day = (now + timedelta(days=3)).strftime("%A").lower() + result = capability._parse_reminder_time(f"{prefix} {target_day}") + assert result is not None + expected_date = (now + timedelta(days=3)).date() + assert result.date() == expected_date + # Default time should be 9 AM + assert result.hour == 9 + assert result.minute == 0 + + def test_next_monday_at_5pm(self, capability): + """'next Monday at 5PM' should resolve to next Monday at 17:00.""" + # Mock datetime.now() to a known date (Wednesday 2026-02-18) + fake_now = datetime(2026, 2, 18, 12, 0, 0) # Wednesday + with patch("main.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = capability._parse_reminder_time("next Monday at 5pm") + assert result is not None + # Next Monday from Wednesday Feb 18 = Monday Feb 23 + assert result.month == 2 + assert result.day == 23 + assert result.hour == 17 + assert result.minute == 0 + + def test_next_same_day_means_7_days(self, capability): + """'next ' should mean 7 days from now, not today.""" + fake_now = datetime(2026, 2, 18, 12, 0, 0) # Wednesday + with patch("main.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = capability._parse_reminder_time("next wednesday") + assert result is not None + assert result.date() == datetime(2026, 2, 25).date() + + def test_on_friday_at_3pm(self, capability): + """'on Friday at 3PM' should resolve to next Friday at 15:00.""" + fake_now = datetime(2026, 2, 18, 12, 0, 0) # Wednesday + with patch("main.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = capability._parse_reminder_time("on friday at 3pm") + assert result is not None + assert result.month == 2 + assert result.day == 20 # Friday + assert result.hour == 15 + + def test_bare_day_name_monday(self, capability): + """'Monday at 5PM' without prefix should still work.""" + fake_now = datetime(2026, 2, 18, 12, 0, 0) # Wednesday + with patch("main.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = capability._parse_reminder_time("monday at 5pm") + assert result is not None + assert result.day == 23 # Next Monday + assert result.hour == 17 + + def test_bare_day_name_no_time(self, capability): + """'Friday' without time should default to 9 AM.""" + fake_now = datetime(2026, 2, 18, 12, 0, 0) # Wednesday + with patch("main.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = capability._parse_reminder_time("friday") + assert result is not None + assert result.day == 20 # Friday + assert result.hour == 9 + assert result.minute == 0 + + # ── Case insensitivity ──────────────────────────────────────────────── + + def test_case_insensitive_day(self, capability): + """Day names should be case-insensitive.""" + result = capability._parse_reminder_time("Next MONDAY at 5PM") + assert result is not None + assert result.hour == 17 + + # ── _parse_hm helper ───────────────────────────────────────────────── + + def test_parse_hm_am(self, capability): + import re + + m = re.search(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", "10:30 am") + h, mi = capability._parse_hm(m) + assert h == 10 + assert mi == 30 + + def test_parse_hm_pm(self, capability): + import re + + m = re.search(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", "5pm") + h, mi = capability._parse_hm(m) + assert h == 17 + assert mi == 0 + + def test_parse_hm_12am_is_midnight(self, capability): + import re + + m = re.search(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", "12 am") + h, mi = capability._parse_hm(m) + assert h == 0 + assert mi == 0 + + def test_parse_hm_12pm_is_noon(self, capability): + import re + + m = re.search(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", "12pm") + h, mi = capability._parse_hm(m) + assert h == 12 + assert mi == 0