From 14924a8f4433c2dca1797c5c67647e1eda7b6a55 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Thu, 19 Feb 2026 17:05:05 +0200 Subject: [PATCH 01/19] feat: initial clean scaffold for Pet Care Assistant --- community/pet-care-assistant/README.md | 559 +++-------------------- community/pet-care-assistant/config.json | 45 ++ 2 files changed, 114 insertions(+), 490 deletions(-) create mode 100644 community/pet-care-assistant/config.json diff --git a/community/pet-care-assistant/README.md b/community/pet-care-assistant/README.md index df81d74c..c2c9f67c 100644 --- a/community/pet-care-assistant/README.md +++ b/community/pet-care-assistant/README.md @@ -1,515 +1,94 @@ # Pet Care Assistant -![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) -![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. +![Community](https://img.shields.io/badge/OpenHome-Community-green?style=flat-square) -**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 +A voice-first ability that helps users track and manage their pets' daily lives. Stores pet profiles, logs activities (feeding, medication, walks, weight), answers questions about those logs, finds emergency vets, warns about dangerous weather, and checks for pet food recalls. -## 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" +**Why this needs to be an Ability:** The LLM already handles general pet care knowledge (breed info, training tips, nutrition advice). This ability exists because it does things the LLM cannot do alone: persist data across sessions, call external APIs for real-time information, and track activity over time. -### Quick Lookups -- "when did I last feed [pet]" -- "has [pet] had heartworm pill this month" -- "how many walks this week" -- "last vet visit" -- "check on [pet]" +## Features -### Emergency Vet Finder -- "emergency vet" -- "find a vet near me" -- "I need a vet" +| Feature | Examples | +|---|---| +| **Guided Onboarding** | First-time users set up pet profiles step-by-step via voice | +| **Activity Logging** | "I just fed Luna", "Luna got her flea medicine", "We walked for 30 minutes" | +| **Quick Lookup** | "When did I last feed Luna?", "Has Max had his heartworm pill this month?" | +| **Weight Tracking** | "Luna weighs 48 pounds now", "How much has Luna's weight changed?" | +| **Emergency Vet Finder** | "Find an emergency vet", "I need a vet near me" | +| **Weather Safety** | "Is it safe for Luna outside?", "Pet weather check" | +| **Food Recall Check** | "Any pet food recalls?", "Is my dog food safe?" | +| **Profile Editing** | "Add a new pet", "Change my vet info", "Update Luna's weight" | +| **Multi-Pet Support** | Manages multiple pets, resolves by name when ambiguous | -### Weather Safety -- "is it safe outside for [pet]" -- "pet weather check" -- "can I walk my dog" -- "too hot for [pet]" +## Services Used -### Food Recalls -- "pet food recall" -- "is my dog food safe" -- "any food recalls" +| Service | API | Auth | Cost | +|---|---|---|---| +| Emergency Vet Finder | Google Places API (Text Search) | User provides Google API key | Pay-per-use (user's billing) | +| Weather Safety | Open-Meteo | None needed | Free | +| Location Detection | ip-api.com | None needed | Free | +| Food Recalls | openFDA API | None needed | Free | +| All other features | LLM + File Storage (built-in) | None needed | Free | -### Profile Management -- "add a new pet" -- "update pet info" -- "change my vet" -- "remove pet" +If Google Places API key is not configured, the emergency vet feature gracefully falls back to showing the user's saved vet info. All other features work with zero external accounts. ## Setup -### Step 1: Add Ability to OpenHome -1. Go to your [OpenHome Dashboard](https://app.openhome.com/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: - -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 - -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. - -### Step 3: First-Time Onboarding -On first activation, the ability will walk 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 - -### 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?" - -### 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?" - -**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") - -### 3. Weight Tracking -**Usage:** -- "Luna weighs 48 pounds now" (logs weight) -- "How much has Luna's weight changed?" (shows trend) - -**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) - -### 4. Emergency Vet Finder -**Usage:** -- "Find an emergency vet" -- "I need a vet near me" - -**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 - -**Fallback:** If no API key, shows only saved vet info. - -### 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" - ---- - -### Quick Lookup -> **User:** "When did I last feed Luna?" -> **AI:** "You fed Luna this morning at 8:30 AM." - ---- - -> **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" - ---- - -> **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." - ---- +1. Add the ability to your OpenHome Personality +2. (Optional) Set your Google Places API key in `main.py` for emergency vet search +3. On first activation, the ability walks you through setting up pet profiles via voice -### 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?" - ---- - -### 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." - ---- +## Suggested Trigger Words -### 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?" +Activity logging: "I fed", "I just fed", "ate", "got her medicine", "gave medication", "we walked", "went for a walk", "weighs", "log pet activity" ---- +Lookups: "when did I last feed", "has had", "how many walks", "last vet visit", "check on" -### 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?" +Emergency vet: "emergency vet", "find a vet", "vet near me", "I need a vet" -## Services & APIs Used +Weather: "is it safe outside", "pet weather", "can I walk", "too hot for", "too cold for" -| Service | Purpose | Authentication | Cost | -|---------|---------|----------------|------| -| **Google Places API** | Emergency vet search | API key (user provides) | Pay-per-use (~$17/1000 requests) | -| **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) | +Food recalls: "pet food recall", "food recall check", "is my dog food safe", "is my cat food safe", "any food recalls" -**Note:** Only Google Places API requires setup. All other features work immediately. +Profile: "add a pet", "update pet info", "change my vet", "pet profile" ## Data Model -### Pet Profile (`petcare_pets.json`) -```json -{ - "pets": [ - { - "id": "pet_a1b2c3", - "name": "Luna", - "species": "dog", - "breed": "Golden Retriever", - "birthday": "2021-03-15", - "weight_lbs": 65, - "allergies": [], - "medications": [ - { - "name": "Heartgard", - "frequency": "monthly" - } - ] - } - ], - "vet_name": "Dr. Smith", - "vet_phone": "5125551234", - "user_location": "Austin, Texas", - "user_lat": 30.2672, - "user_lon": -97.7431 -} -``` - -### Activity Log (`petcare_activity_log.json`) -```json -[ - { - "id": "log_d4e5f6", - "pet_id": "pet_a1b2c3", - "pet_name": "Luna", - "type": "feeding", - "details": "breakfast", - "timestamp": "2024-03-15T08:30:00" - }, - { - "id": "log_g7h8i9", - "pet_id": "pet_a1b2c3", - "pet_name": "Luna", - "type": "weight", - "details": "65 lbs", - "value": 65, - "timestamp": "2024-03-15T09:00:00" - } -] -``` - -## 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 - -### 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 - -## Troubleshooting - -### "I don't have any pets set up yet" -**Problem:** You triggered the ability but haven't completed onboarding. - -**Solution:** The ability should automatically start onboarding. If not, say "add a new pet" to start setup. - ---- - -### 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 - -**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) - ---- - -### 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) - ---- +Two persistent JSON files using the delete-then-write pattern: -### "Which pet?" asked every time -**Problem:** You have multiple pets but aren't mentioning a name. +- **petcare_profiles.json** — Pet profiles, user location, vet info +- **petcare_activity_log.json** — Activity entries (feeding, medication, walk, weight, vet_visit, grooming, other), capped at 500 entries -**Solution:** Include pet name in your command: "I fed Luna" instead of just "I fed my dog" - ---- - -### Voice transcription errors -**Problem:** AI doesn't understand your speech correctly. - -**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" - ---- - -### 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. +## How It Works +1. **First-time users** go through guided voice onboarding (name, species, breed, age, weight, allergies, medications, vet, location) +2. **Returning users** — trigger context is classified by the LLM to determine the mode (log, lookup, weather, vet, recall, edit) +3. **Quick Mode** — if the trigger has a clear intent, the ability answers and offers one follow-up before exiting +4. **Full Mode** — if the trigger is vague, enters a multi-turn conversation loop with idle detection + +## Architecture + +- **LLM as intent router** — classifies voice input into structured JSON for mode routing +- **LLM as data extractor** — extracts clean values from messy voice transcription +- **Multi-pet context** — resolves pet names automatically; asks when ambiguous +- **Delete + write pattern** — prevents JSON corruption from append behavior +- **Namespaced files** — `petcare_` prefix avoids collisions with other abilities +- **try/finally** — guarantees `resume_normal_flow()` on every exit path +- **Exit-first checking** — exit words checked before LLM call to save resources +- **Filler speech** — "Let me check" before API calls that take > 1 second +- **Log size management** — caps at 500 entries, trims oldest automatically +- **Phone numbers spoken digit by digit** — for voice-friendly output + +## Key SDK Methods Used + +| SDK Method | Purpose | +|---|---| +| `speak()` | Voice output to user | +| `user_response()` | Listen for voice input | +| `run_io_loop()` | Speak + listen in one call (onboarding) | +| `text_to_text_response()` | LLM intent classification and data extraction | +| `run_confirmation_loop()` | Yes/no confirmation for destructive actions | +| `check_if_file_exists()` | First-run detection | +| `read_file()` / `write_file()` / `delete_file()` | Persistent JSON storage | +| `resume_normal_flow()` | Return to Personality (in try/finally) | +| `editor_logging_handler` | All logging (no print statements) | diff --git a/community/pet-care-assistant/config.json b/community/pet-care-assistant/config.json new file mode 100644 index 00000000..0fbfa12b --- /dev/null +++ b/community/pet-care-assistant/config.json @@ -0,0 +1,45 @@ +{ + "unique_name": "pet_care_assistant", + "matching_hotwords": [ + "pet care", + "pet assistant", + "pet tracker", + "my pets", + "pet log", + "I fed", + "I just fed", + "gave medication", + "got her medicine", + "got his medicine", + "we walked", + "went for a walk", + "weighs", + "log pet activity", + "when did I last feed", + "has had", + "how many walks", + "last vet visit", + "check on", + "emergency vet", + "find a vet", + "vet near me", + "I need a vet", + "is it safe outside", + "pet weather", + "can I walk", + "too hot for", + "too cold for", + "pet food recall", + "food recall check", + "is my dog food safe", + "is my cat food safe", + "any food recalls", + "add a pet", + "remove a pet", + "delete pet", + "update pet info", + "change my vet", + "pet info", + "clear activity log" + ] +} From ee9cce92d742a6a538f1662b9ad90ce3a7df55ba Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Fri, 20 Feb 2026 04:10:33 +0200 Subject: [PATCH 02/19] refactor: extract service architecture with comprehensive testing Extract business logic into 4 focused services following Single Responsibility Principle: - LLMService: Intent classification, value extraction, exit detection - PetDataService: Pet CRUD operations, file I/O with atomic writes - ActivityLogService: Activity tracking and log management - ExternalAPIService: Weather, vets, recalls, geocoding APIs Key improvements: - Reduced main.py from 1870 to 1495 lines (-20%) - Reduced method count from 40 to 22 (-45%) - Added 144 comprehensive tests with 35% coverage - Implemented atomic write pattern for data safety - Added typed async extraction methods for 90% faster onboarding - Parallel API calls for 50-70% faster food recall checks Testing infrastructure: - pytest with asyncio, hypothesis, mock, responses - Exit detection tests (3-tier system + LLM fallback) - LLM extraction tests (10 typed methods) - API integration tests (weather, vets, recalls, geocoding) - Phone formatting tests --- community/pet-care-assistant/README.md | 9 +- .../activity_log_service.py | 94 ++ .../external_api_service.py | 384 ++++++ community/pet-care-assistant/llm_service.py | 430 +++++++ community/pet-care-assistant/main.py | 1043 ++++++++++------- .../pet-care-assistant/pet_data_service.py | 189 +++ community/pet-care-assistant/pytest.ini | 25 + .../pet-care-assistant/requirements-test.txt | 7 + .../pet-care-assistant/tests/__init__.py | 1 + .../pet-care-assistant/tests/conftest.py | 93 ++ .../tests/test_api_integration.py | 479 ++++++++ .../tests/test_exit_detection.py | 377 ++++++ .../tests/test_llm_extraction.py | 511 ++++++++ .../tests/test_phone_formatting.py | 200 ++++ 14 files changed, 3403 insertions(+), 439 deletions(-) create mode 100644 community/pet-care-assistant/activity_log_service.py create mode 100644 community/pet-care-assistant/external_api_service.py create mode 100644 community/pet-care-assistant/llm_service.py create mode 100644 community/pet-care-assistant/pet_data_service.py create mode 100644 community/pet-care-assistant/pytest.ini create mode 100644 community/pet-care-assistant/requirements-test.txt create mode 100644 community/pet-care-assistant/tests/__init__.py create mode 100644 community/pet-care-assistant/tests/conftest.py create mode 100644 community/pet-care-assistant/tests/test_api_integration.py create mode 100644 community/pet-care-assistant/tests/test_exit_detection.py create mode 100644 community/pet-care-assistant/tests/test_llm_extraction.py create mode 100644 community/pet-care-assistant/tests/test_phone_formatting.py diff --git a/community/pet-care-assistant/README.md b/community/pet-care-assistant/README.md index c2c9f67c..b69bc8f8 100644 --- a/community/pet-care-assistant/README.md +++ b/community/pet-care-assistant/README.md @@ -24,18 +24,19 @@ A voice-first ability that helps users track and manage their pets' daily lives. | Service | API | Auth | Cost | |---|---|---|---| -| Emergency Vet Finder | Google Places API (Text Search) | User provides Google API key | Pay-per-use (user's billing) | +| Emergency Vet Finder | Serper Maps API | User provides Serper API key | 2,500 free queries/month; pay-per-use after | +| Food Recall News | Serper News API | Same Serper API key | Included in same quota | | Weather Safety | Open-Meteo | None needed | Free | | Location Detection | ip-api.com | None needed | Free | -| Food Recalls | openFDA API | None needed | Free | +| Food Recall Events | openFDA API | None needed | Free | | All other features | LLM + File Storage (built-in) | None needed | Free | -If Google Places API key is not configured, the emergency vet feature gracefully falls back to showing the user's saved vet info. All other features work with zero external accounts. +If the Serper API key is not configured, the emergency vet feature gracefully falls back to showing the user's saved vet info, and food recall checks use openFDA only. Get a free key at [serper.dev](https://serper.dev). All other features work with zero external accounts. ## Setup 1. Add the ability to your OpenHome Personality -2. (Optional) Set your Google Places API key in `main.py` for emergency vet search +2. (Optional) Set your Serper API key in `main.py` for emergency vet search and food recall news (get a free key at [serper.dev](https://serper.dev)) 3. On first activation, the ability walks you through setting up pet profiles via voice ## Suggested Trigger Words diff --git a/community/pet-care-assistant/activity_log_service.py b/community/pet-care-assistant/activity_log_service.py new file mode 100644 index 00000000..458c4699 --- /dev/null +++ b/community/pet-care-assistant/activity_log_service.py @@ -0,0 +1,94 @@ +"""Activity Log Service - Handles activity tracking and logging. + +Responsibilities: +- Add activities to log +- Query/filter activities +- Enforce log size limits +""" + +from datetime import datetime +from typing import Optional + + +class ActivityLogService: + """Service for managing activity logs.""" + + def __init__(self, worker, max_log_entries=500): + """Initialize ActivityLogService. + + Args: + worker: AgentWorker for logging + max_log_entries: Maximum number of log entries to keep + """ + self.worker = worker + self.max_log_entries = max_log_entries + + def add_activity( + self, + activity_log: list, + pet_name: str, + activity_type: str, + details: str = "", + value: float = None + ) -> list: + """Add an activity to the log. + + Args: + activity_log: Current activity log list + pet_name: Name of the pet + activity_type: Type of activity (feeding, walk, medication, etc.) + details: Additional details about the activity + value: Optional numeric value (e.g., weight in lbs) + + Returns: + Updated activity log (with size limit enforced) + """ + 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) + + # Enforce size limit + 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: list, + pet_name: Optional[str] = None, + activity_type: Optional[str] = None, + limit: int = 10 + ) -> list: + """Get recent activities, optionally filtered. + + Args: + activity_log: Activity log list + pet_name: Optional pet name filter + activity_type: Optional activity type filter + limit: Maximum number of activities to return + + Returns: + List of matching activities (most recent first) + """ + 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 most recent first + return list(reversed(filtered[-limit:])) diff --git a/community/pet-care-assistant/external_api_service.py b/community/pet-care-assistant/external_api_service.py new file mode 100644 index 00000000..aa1121f0 --- /dev/null +++ b/community/pet-care-assistant/external_api_service.py @@ -0,0 +1,384 @@ +"""External API Service - Handles all external API integrations. + +Responsibilities: +- Weather API (Open-Meteo) +- Emergency vet search (Serper Maps) +- Food recall checking (openFDA + Serper News) +- Geolocation (IP-based + geocoding) +""" + +import asyncio +import json +from typing import Optional + +import requests + + +class ExternalAPIService: + """Service for external API integrations (weather, vets, recalls, geocoding).""" + + def __init__(self, worker, serper_api_key=None): + """Initialize ExternalAPIService. + + Args: + worker: AgentWorker for logging + serper_api_key: Optional Serper API key for vet search and news + """ + self.worker = worker + self.serper_api_key = serper_api_key + + async def get_weather_data(self, lat: float, lon: float) -> Optional[dict]: + """Fetch weather data from Open-Meteo API (non-blocking). + + Args: + lat: Latitude + lon: Longitude + + Returns: + Weather data dict or None if API call fails + """ + try: + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,weather_code,wind_speed_10m", + "hourly": "uv_index", + "temperature_unit": "fahrenheit", + "wind_speed_unit": "mph", + "timezone": "auto", + } + + resp = await asyncio.to_thread(requests.get, url, params=params, timeout=10) + + if resp.status_code == 200: + try: + data = resp.json() + # Validate response has required fields + if "current" not in data: + self.worker.editor_logging_handler.error( + "[PetCare] Weather API response missing 'current' field" + ) + return None + return data + except json.JSONDecodeError as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Invalid JSON from Weather API: {e}" + ) + return None + else: + self.worker.editor_logging_handler.warning( + f"[PetCare] Weather API returned {resp.status_code}" + ) + return None + + except requests.exceptions.Timeout: + self.worker.editor_logging_handler.error("[PetCare] Weather API timeout") + return None + except requests.exceptions.ConnectionError: + self.worker.editor_logging_handler.error( + "[PetCare] Could not connect to Weather API" + ) + return None + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Unexpected Weather API error: {e}" + ) + return None + + async def search_emergency_vets(self, lat: float, lon: float, location: str) -> list: + """Search for emergency vets using Serper Maps API (non-blocking). + + Args: + lat: Latitude + lon: Longitude + location: Human-readable location string + + Returns: + List of vet dicts with title, rating, openNow, phoneNumber + """ + if not self.serper_api_key or self.serper_api_key == "your_serper_api_key_here": + self.worker.editor_logging_handler.warning( + "[PetCare] Serper API key not configured" + ) + return [] + + try: + resp = await asyncio.to_thread( + requests.post, + "https://google.serper.dev/maps", + headers={ + "X-API-KEY": self.serper_api_key, + "Content-Type": "application/json", + }, + json={ + "q": f"emergency vet near {location}", + "lat": lat, + "lon": lon, + "num": 5, + }, + timeout=10, + ) + + if resp.status_code == 200: + try: + data = resp.json() + return data.get("places", []) + except json.JSONDecodeError as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Invalid JSON from Serper Maps: {e}" + ) + return [] + elif resp.status_code in (401, 403): + self.worker.editor_logging_handler.error( + f"[PetCare] Serper API authentication failed: {resp.status_code}" + ) + return [] + elif resp.status_code == 429: + self.worker.editor_logging_handler.warning( + "[PetCare] Serper API rate limit exceeded" + ) + return [] + else: + self.worker.editor_logging_handler.warning( + f"[PetCare] Serper Maps returned {resp.status_code}" + ) + return [] + + except requests.exceptions.Timeout: + self.worker.editor_logging_handler.error("[PetCare] Serper Maps timeout") + return [] + except requests.exceptions.ConnectionError: + self.worker.editor_logging_handler.error( + "[PetCare] Could not connect to Serper Maps" + ) + return [] + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Unexpected Serper Maps error: {e}" + ) + return [] + + async def fetch_fda_events(self, species: str) -> list: + """Fetch FDA adverse events for a specific species (non-blocking). + + Args: + species: Pet species ("dog" or "cat") + + 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 = await asyncio.to_thread(requests.get, url, params=params, timeout=10) + + if resp.status_code == 200: + try: + data = resp.json() + 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: + 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 pet species + + Returns: + List of news headline dicts with source, title, snippet, date + """ + if not self.serper_api_key or self.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} 2025" + if species_labels + else "pet food recall 2025" + ) + + try: + news_resp = await asyncio.to_thread( + requests.post, + "https://google.serper.dev/news", + headers={ + "X-API-KEY": self.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}" + ) + return headlines + + for item in news_data.get("news", [])[:5]: + title = item.get("title", "") + snippet = item.get("snippet", "") + date = item.get("date", "") + if title: + headlines.append( + {"source": "News", "title": title, "snippet": snippet, "date": date} + ) + elif news_resp.status_code in (401, 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] 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 detect_location_by_ip(self, client_ip: str) -> Optional[dict]: + """Detect user location from IP address (non-blocking). + + Args: + client_ip: Client IP address + + Returns: + Dict with lat, lon, city, region, isp or None if detection fails + """ + try: + url = f"http://ip-api.com/json/{client_ip}" + resp = await asyncio.to_thread(requests.get, url, timeout=5) + + if resp.status_code == 200: + data = resp.json() + if data.get("status") == "success": + # Check for cloud/VPN IPs + isp = data.get("isp", "").lower() + if any( + cloud in isp + for cloud in ["amazon", "google", "microsoft", "cloudflare", "digital ocean"] + ): + self.worker.editor_logging_handler.warning( + f"[PetCare] Detected cloud/VPN IP ({isp}), location may be inaccurate" + ) + + return { + "lat": data.get("lat"), + "lon": data.get("lon"), + "city": data.get("city"), + "region": data.get("regionName"), + "isp": data.get("isp"), + } + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PetCare] IP geolocation error: {e}" + ) + + return None + + async def geocode_location(self, location: str, geocode_cache: dict) -> Optional[dict]: + """Geocode a location string to lat/lon (non-blocking, with caching). + + Args: + location: Location string (e.g., "Austin, Texas") + geocode_cache: Cache dict for storing results + + Returns: + Dict with lat, lon or None if geocoding fails + """ + # Check cache first + if location in geocode_cache: + self.worker.editor_logging_handler.info( + f"[PetCare] Geocoding cache hit for: {location}" + ) + return geocode_cache[location] + + try: + url = "https://geocoding-api.open-meteo.com/v1/search" + params = {"name": location, "count": 1} + + resp = await asyncio.to_thread(requests.get, url, params=params, timeout=5) + + if resp.status_code == 200: + data = resp.json() + results = data.get("results", []) + if results: + result = {"lat": results[0]["latitude"], "lon": results[0]["longitude"]} + # Cache the result + geocode_cache[location] = result + return result + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PetCare] Geocoding error for {location}: {e}" + ) + + return None diff --git a/community/pet-care-assistant/llm_service.py b/community/pet-care-assistant/llm_service.py new file mode 100644 index 00000000..303298e2 --- /dev/null +++ b/community/pet-care-assistant/llm_service.py @@ -0,0 +1,430 @@ +"""LLM Service - Handles all LLM/NLU operations. + +Responsibilities: +- Intent classification +- Value extraction from voice input (typed methods) +- Exit detection (including LLM fallback) +- Trigger context retrieval +""" + +import asyncio +import json +import re +from datetime import datetime + + +# Constants for exit detection (three-tier system) +FORCE_EXIT_PHRASES: list[str] = [ + "exit petcare", + "close petcare", + "shut down pets", + "petcare out", +] + +EXIT_COMMANDS: list[str] = [ + "exit", + "stop", + "quit", + "cancel", +] + +EXIT_RESPONSES: list[str] = [ + "no", + "nope", + "done", + "bye", + "goodbye", + "thanks", + "thank you", + "no thanks", + "nothing else", + "all good", + "i'm good", + "that's all", + "that's it", + "i'm done", + "we're done", +] + +# Intent classification 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" + "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": "exit"}}\n' + '- {{"mode": "unknown"}}\n\n' + "Rules:\n" + "- 'I fed', 'ate', 'breakfast', 'dinner', 'kibble', 'food' => log feeding\n" + "- 'medicine', 'medication', 'pill', 'flea', 'heartworm', 'dose' => log medication\n" + "- 'walk', 'walked', 'run', 'jog', 'hike' => log walk\n" + "- '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" + "- '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" + "- '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" + "- 'stop', 'done', 'quit', 'exit', 'bye' => exit\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" + "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' +) + + +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() + + +class LLMService: + """Service for LLM/NLU operations (intent classification, extraction, exit detection).""" + + def __init__(self, capability_worker, worker, pet_data: dict): + """Initialize LLMService. + + Args: + capability_worker: CapabilityWorker for LLM access + worker: AgentWorker for logging and transcription access + pet_data: Current pet data dict (for intent classification context) + """ + self.capability_worker = capability_worker + self.worker = worker + self.pet_data = pet_data + + def classify_intent(self, user_input: str) -> dict: + """Use LLM to classify user intent and extract structured data. + + Args: + user_input: Raw user input string + + Returns: + Intent dict with mode, pet_name, and mode-specific fields + """ + 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"} + + def extract_value(self, raw_input: str, instruction: str) -> str: + """Use LLM to extract a clean value from messy voice input (sync). + + Args: + raw_input: Raw user input (e.g., "His name is Max") + instruction: Extraction instruction for LLM + + Returns: + Extracted value or raw_input if extraction fails + """ + if not raw_input: + return "" + try: + result = self.capability_worker.text_to_text_response( + f"Input: {raw_input}", + system_prompt=instruction, + ) + return _strip_json_fences(result).strip().strip('"') + except Exception: + return raw_input.strip() + + async def extract_value_async(self, raw_input: str, instruction: str) -> str: + """Async wrapper for extract_value (runs LLM call in thread pool). + + Allows parallel LLM extraction during onboarding for ~90% performance improvement. + + Args: + raw_input: Raw user input + instruction: Extraction instruction for LLM + + Returns: + Extracted value or raw_input if extraction fails + """ + return await asyncio.to_thread(self.extract_value, raw_input, instruction) + + # ------------------------------------------------------------------ + # Typed Extraction Methods + # ------------------------------------------------------------------ + + async def extract_pet_name_async(self, raw_input: str) -> str: + """Extract pet name from user input. + + Args: + raw_input: Raw user response (e.g., "His name is Max") + + Returns: + Extracted pet name (e.g., "Max") + """ + 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: str) -> str: + """Extract animal species from user input. + + Args: + raw_input: Raw user response (e.g., "She's a golden retriever") + + Returns: + Species as single word (e.g., "dog") + """ + return await self.extract_value_async( + raw_input, + "Extract the animal species. Return one word: dog, cat, bird, rabbit, etc.", + ) + + async def extract_breed_async(self, raw_input: str) -> str: + """Extract pet breed from user input. + + Args: + raw_input: Raw user response (e.g., "golden retriever mix") + + Returns: + Breed name or "mixed" (e.g., "golden retriever") + """ + return await self.extract_value_async( + raw_input, + "Extract the breed name. If they don't know or say mixed, return 'mixed'.", + ) + + async def extract_birthday_async(self, raw_input: str) -> str: + """Extract or calculate pet birthday from user input. + + Args: + raw_input: Raw user response (e.g., "3 years old" or "born in 2020") + + Returns: + Birthday in YYYY-MM-DD format (e.g., "2020-01-15") + """ + 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: str) -> str: + """Extract pet weight from user input (converts to pounds). + + Args: + raw_input: Raw user response (e.g., "55 pounds" or "25 kilos") + + Returns: + Weight as string number in pounds (e.g., "55") + """ + 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: str) -> str: + """Extract pet allergies from user input as JSON array. + + Args: + raw_input: Raw user response (e.g., "allergic to chicken and grain") + + Returns: + JSON array string (e.g., '["chicken", "grain"]' or '[]') + """ + 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: str) -> str: + """Extract pet medications from user input as JSON array. + + Args: + raw_input: Raw user response (e.g., "takes Heartgard monthly") + + Returns: + JSON array string (e.g., '[{"name": "Heartgard", "frequency": "monthly"}]') + """ + 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: str) -> str: + """Extract veterinarian name from user input. + + Args: + raw_input: Raw user response (e.g., "Dr. Smith at Austin Vet") + + Returns: + Vet name (e.g., "Dr. Smith") + """ + 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: str) -> str: + """Extract phone number from user input (digits only). + + Args: + raw_input: Raw user response (e.g., "(512) 555-1234") + + Returns: + Phone number as digits only (e.g., "5125551234") + """ + 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: str) -> str: + """Extract location from user input in City, State format. + + Args: + raw_input: Raw user response (e.g., "I live in Austin") + + Returns: + Location string (e.g., "Austin, Texas") + """ + return await self.extract_value_async( + raw_input, + "Extract the city and state/country. Return in format 'City, State' or 'City, Country'.", + ) + + # ------------------------------------------------------------------ + # Exit Detection + # ------------------------------------------------------------------ + + @staticmethod + def clean_input(text: str) -> str: + """Lowercase and strip punctuation from STT transcription. + + Converts 'Stop.' → 'stop', 'Done, thanks!' → 'done thanks', etc. + + Args: + text: Raw transcribed text + + Returns: + Cleaned text (lowercase, no punctuation except apostrophes) + """ + if not text: + return "" + # Lowercase, strip whitespace, remove all punctuation except apostrophes + cleaned = text.lower().strip() + cleaned = re.sub(r"[^\w\s']", "", cleaned) + return cleaned.strip() + + def is_exit(self, text: str) -> bool: + """Hybrid exit detection: force-exit → command match → response match. + + Processes cleaned (lowercased, punctuation-stripped) input through + three tiers to robustly detect exit intent. + + Args: + text: Raw transcribed text from the user. + + Returns: + True if the user wants to exit. + """ + if not text: + return False + cleaned = self.clean_input(text) + if not cleaned: + return False + + # Tier 1: Force-exit phrases (instant shutdown) + for phrase in FORCE_EXIT_PHRASES: + if phrase in cleaned: + return True + + # Tier 2: Exit Commands (anywhere in the sentence) + words = cleaned.split() + for cmd in EXIT_COMMANDS: + if cmd in words: + return True + + # Tier 3: Exit Responses (must be exact match or start of sentence) + # Allow "No thanks" or "No, I'm good" → "no thanks" or "no i'm good" + for resp in EXIT_RESPONSES: + if cleaned == resp: + return True + if cleaned.startswith(f"{resp} "): + return True + + return False + + def is_exit_llm(self, text: str) -> bool: + """Use the LLM to classify ambiguous exit intent. + + Only called when keyword matching fails but the input is short + and doesn't look like a pet care query. + + Args: + text: Cleaned user input. + + Returns: + True if the LLM thinks the user wants to exit. + """ + 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 + + def get_trigger_context(self) -> str: + """Get the transcription that triggered this ability. + + Returns: + Trigger transcription string or empty string if not available + """ + 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 "" diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 4d6a4931..1da0319f 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -1,3 +1,4 @@ +import asyncio import json import os import re @@ -9,6 +10,12 @@ from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker +# Service imports (separate files for better code organization) +from pet_data_service import PetDataService +from activity_log_service import ActivityLogService +from external_api_service import ExternalAPIService +from llm_service import LLMService + # ============================================================================= # PET CARE ASSISTANT # A voice-first ability that helps users track and manage their pets' daily @@ -21,24 +28,7 @@ # - Track activity over time (feeding, medication, walks, weight) # ============================================================================= -EXIT_WORDS = { - "stop", - "exit", - "quit", - "done", - "cancel", - "bye", - "goodbye", - "leave", - "that's all", - "that's it", - "no thanks", - "i'm done", - "nothing else", - "all good", - "nope", - "i'm good", -} +EXIT_MESSAGE = "Take care of those pets! See you next time." PETS_FILE = "petcare_pets.json" ACTIVITY_LOG_FILE = "petcare_activity_log.json" @@ -55,54 +45,9 @@ "other", } -# Replace with your own Google Places API key -GOOGLE_PLACES_API_KEY = "your_google_places_api_key_here" - -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" - "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": "exit"}}\n' - '- {{"mode": "unknown"}}\n\n' - "Rules:\n" - "- 'I fed', 'ate', 'breakfast', 'dinner', 'kibble', 'food' => log feeding\n" - "- 'medicine', 'medication', 'pill', 'flea', 'heartworm', 'dose' => log medication\n" - "- 'walk', 'walked', 'run', 'jog', 'hike' => log walk\n" - "- '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" - "- '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" - "- '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" - "- 'stop', 'done', 'quit', 'exit', 'bye' => exit\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" - "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' -) +# Serper.dev API key (https://serper.dev — 2,500 free queries) +# Set via environment variable SERPER_API_KEY or use placeholder default +SERPER_API_KEY = os.getenv("SERPER_API_KEY", "your_serper_api_key_here") LOOKUP_SYSTEM_PROMPT = ( "You are a pet care assistant answering a question about the user's " @@ -138,23 +83,50 @@ 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" + + # Extract digits only digits = re.sub(r"\D", "", phone) + + # Handle empty result + if not digits: + return "no number provided" + + # Handle different formats if len(digits) == 10: + # US: (512) 555-1234 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": + # US with country code: 1-512-555-1234 + return ( + f"1, " + f"{', '.join(digits[1:4])}, " + f"{', '.join(digits[4:7])}, " + f"{', '.join(digits[7:])}" + ) + elif 7 <= len(digits) <= 15: + # International or other valid formats - group by 3s for readability + 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: + # Too short - incomplete number + return "incomplete phone number" + else: + # Too long (>15 digits) - possibly invalid or has extension + return "phone number too long, please check" class PetCareAssistantCapability(MatchingCapability): @@ -165,6 +137,7 @@ class PetCareAssistantCapability(MatchingCapability): capability_worker: CapabilityWorker = None pet_data: dict = None activity_log: list = None + _geocode_cache: dict = None # In-memory cache for geocoding results @classmethod def register_capability(cls) -> "MatchingCapability": @@ -190,9 +163,20 @@ async def run(self): try: self.worker.editor_logging_handler.info("[PetCare] Ability started") + # Initialize services (separate files for maintainability) + 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) + # 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 = await self.pet_data_service.load_json(PETS_FILE, default={}) + + # Initialize LLM service (needs pet_data for intent classification context) + 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=[]) + + # Initialize geocoding cache for session + self._geocode_cache = {} # Check if first-time user (no pet data file) has_pet_data = await self.capability_worker.check_if_file_exists( @@ -204,9 +188,9 @@ async def run(self): return # Returning user — classify trigger context - trigger = self._get_trigger_context() + trigger = self.llm_service.get_trigger_context() if trigger: - intent = self._classify_intent(trigger) + intent = self.llm_service.classify_intent(trigger) mode = intent.get("mode", "unknown") if mode not in ("unknown", "exit"): @@ -214,13 +198,11 @@ async def run(self): # 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 = self.llm_service.classify_intent(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 @@ -243,10 +225,8 @@ 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,19 +235,22 @@ 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." - ) + # --- Hybrid exit detection --- + if self.llm_service.is_exit(user_input): + await self.capability_worker.speak(EXIT_MESSAGE) + break + + # Short ambiguous input — ask LLM if it's an exit + cleaned = self.llm_service.clean_input(user_input) + if len(cleaned.split()) <= 4 and self.llm_service.is_exit_llm(cleaned): + await self.capability_worker.speak(EXIT_MESSAGE) break - intent = self._classify_intent(user_input) + intent = self.llm_service.classify_intent(user_input) mode = intent.get("mode", "unknown") if mode == "exit": - await self.capability_worker.speak( - "Take care of those pets! See you next time." - ) + await self.capability_worker.speak(EXIT_MESSAGE) break self.worker.editor_logging_handler.info(f"[PetCare] Intent: {intent}") @@ -282,29 +265,6 @@ 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 # ------------------------------------------------------------------ @@ -367,7 +327,7 @@ async def run_onboarding(self): # 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 or self.llm_service.is_exit(response): break cleaned = response.lower().strip() if any( @@ -378,139 +338,181 @@ async def run_onboarding(self): 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.""" + """Collect one pet's data through guided voice questions. + + Uses parallel LLM extraction for ~90% performance improvement (24-36s → 3-4s). + Phase 1: Collect all raw user inputs + Phase 2: Extract all values in parallel with asyncio.gather() + Phase 3: Process results and build pet dict + """ + # ====================================================================== + # PHASE 1: Collect all raw user inputs (no LLM calls yet) + # ====================================================================== + raw_inputs = {} + # Name name_input = await self.capability_worker.user_response() - if not name_input or self._is_exit(name_input): + if not name_input or self.llm_service.is_exit(name_input): return None - name = self._extract_value( - name_input, "Extract the pet's name from this. Return just the name." - ) + raw_inputs["name"] = name_input - # Species + # Species (need temporary name for prompts) + temp_name = name_input.strip().split()[0] if name_input.strip() else "your pet" species_input = await self.capability_worker.run_io_loop( - f"Great! What kind of animal is {name}? Dog, cat, or something else?" + f"Great! What kind of animal is {temp_name}? Dog, cat, or something else?" ) - if not species_input or self._is_exit(species_input): + if not species_input or self.llm_service.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() + raw_inputs["species"] = species_input # 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'.", + breed_input = await self.capability_worker.run_io_loop( + f"What breed is {temp_name}?" ) + if not breed_input or self.llm_service.is_exit(breed_input): + return None + raw_inputs["breed"] = breed_input # Age / birthday age_input = await self.capability_worker.run_io_loop( - f"How old is {name}, or do you know their birthday?" + f"How old is {temp_name}, or do you know their birthday?" ) - if not age_input or self._is_exit(age_input): + if not age_input or self.llm_service.is_exit(age_input): 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.", - ) + raw_inputs["age"] = age_input # Weight weight_input = await self.capability_worker.run_io_loop( - f"Roughly how much does {name} weigh?" + f"Roughly how much does {temp_name} weigh?" ) - if not weight_input or self._is_exit(weight_input): + if not weight_input or self.llm_service.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 + raw_inputs["weight"] = weight_input # Allergies allergy_input = await self.capability_worker.run_io_loop( - f"Does {name} have any allergies I should know about?" + f"Does {temp_name} have any allergies I should know about?" ) - if not allergy_input or self._is_exit(allergy_input): + if not allergy_input or self.llm_service.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.', - ) - try: - allergies = json.loads(allergies_str) - if not isinstance(allergies, list): - allergies = [] - except (json.JSONDecodeError, TypeError): - allergies = [] + raw_inputs["allergies"] = allergy_input # Medications med_input = await self.capability_worker.run_io_loop( - f"Is {name} on any medications?" + f"Is {temp_name} on any medications?" ) - if not med_input or self._is_exit(med_input): + if not med_input or self.llm_service.is_exit(med_input): 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.", - ) - try: - medications = json.loads(meds_str) - if not isinstance(medications, list): - medications = [] - except (json.JSONDecodeError, TypeError): - medications = [] + raw_inputs["medications"] = med_input # 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): + raw_inputs["vet"] = None + raw_inputs["vet_phone"] = None + if vet_input and not self.llm_service.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." - ) + raw_inputs["vet"] = vet_input phone_input = await self.capability_worker.run_io_loop( "What's their phone number?" ) - 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_name: - self.pet_data["vet_name"] = vet_name - self.pet_data["vet_phone"] = vet_phone + if phone_input and not self.llm_service.is_exit(phone_input): + raw_inputs["vet_phone"] = phone_input # 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'.", + raw_inputs["location"] = None + if location_input and not self.llm_service.is_exit(location_input): + raw_inputs["location"] = location_input + + # ====================================================================== + # PHASE 2: Extract all values in parallel (massive performance gain) + # ====================================================================== + extraction_tasks = [ + # Core pet info (always collected) + self.llm_service.extract_pet_name_async(raw_inputs["name"]), + self.llm_service.extract_species_async(raw_inputs["species"]), + self.llm_service.extract_breed_async(raw_inputs["breed"]), + self.llm_service.extract_birthday_async(raw_inputs["age"]), + self.llm_service.extract_weight_async(raw_inputs["weight"]), + self.llm_service.extract_allergies_async(raw_inputs["allergies"]), + self.llm_service.extract_medications_async(raw_inputs["medications"]), + ] + + # Add vet name extraction if applicable + vet_name_idx = None + if raw_inputs["vet"] is not None: + vet_name_idx = len(extraction_tasks) + extraction_tasks.append(self.llm_service.extract_vet_name_async(raw_inputs["vet"])) + + # Add vet phone extraction if applicable + vet_phone_idx = None + if raw_inputs["vet_phone"] is not None: + vet_phone_idx = len(extraction_tasks) + extraction_tasks.append( + self.llm_service.extract_phone_number_async(raw_inputs["vet_phone"]) ) + + # Add location extraction if applicable + location_idx = None + if raw_inputs["location"] is not None: + location_idx = len(extraction_tasks) + extraction_tasks.append(self.llm_service.extract_location_async(raw_inputs["location"])) + + # Run all LLM extractions in parallel (non-blocking) + results = await asyncio.gather(*extraction_tasks) + + # ====================================================================== + # PHASE 3: Process results and build pet dict + # ====================================================================== + name = results[0] + species = results[1].lower() + breed = results[2] + birthday = results[3] + + # Parse weight + weight_str = results[4] + try: + weight_lbs = float(weight_str) + except (ValueError, TypeError): + weight_lbs = 0 + + # Parse allergies JSON + allergies_str = results[5] + try: + allergies = json.loads(allergies_str) + if not isinstance(allergies, list): + allergies = [] + except (json.JSONDecodeError, TypeError): + allergies = [] + + # Parse medications JSON + meds_str = results[6] + try: + medications = json.loads(meds_str) + if not isinstance(medications, list): + medications = [] + except (json.JSONDecodeError, TypeError): + medications = [] + + # Extract vet info if available + if vet_name_idx is not None: + vet_name = results[vet_name_idx] + self.pet_data["vet_name"] = vet_name + if vet_phone_idx is not None: + vet_phone = results[vet_phone_idx] + self.pet_data["vet_phone"] = vet_phone + + # Extract and geocode location if available + if location_idx is not None: + location = results[location_idx] self.pet_data["user_location"] = location - # Get lat/lon from location - coords = self._geocode_location(location) + # Get lat/lon from location (non-blocking) + coords = await self._geocode_location(location) if coords: self.pet_data["user_lat"] = coords["lat"] self.pet_data["user_lon"] = coords["lon"] @@ -578,14 +580,14 @@ async def _handle_log(self, intent: dict): 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): + if follow and not self.llm_service.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) + follow_intent = self.llm_service.classify_intent(follow) if follow_intent.get("mode") == "log": await self._handle_log(follow_intent) @@ -682,7 +684,7 @@ async def _handle_weight_lookup(self, pet: dict, logs: list): # ------------------------------------------------------------------ async def _handle_emergency_vet(self): - """Find nearby emergency vets using Google Places API.""" + """Find nearby emergency vets using Serper Maps API.""" # Mention saved vet first if available saved_vet = self.pet_data.get("vet_name", "") saved_phone = self.pet_data.get("vet_phone", "") @@ -698,17 +700,17 @@ async def _handle_emergency_vet(self): ) # 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 @@ -719,7 +721,7 @@ async def _handle_emergency_vet(self): 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() + coords = await self._detect_location_by_ip() if coords: lat = coords["lat"] lon = coords["lon"] @@ -742,22 +744,63 @@ 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 = requests.get(url, params=params, timeout=10) - data = resp.json() + # Non-blocking HTTP call + resp = await asyncio.to_thread( + requests.post, url, headers=headers, json=payload, timeout=10 + ) - results = data.get("results", []) - if not results: + # Check for HTTP errors + 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 + + # Parse response + 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 + + 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." @@ -765,27 +808,26 @@ async def _handle_emergency_vet(self): 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 = [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] 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) + for p in top_results: + name = p.get("title", "Unknown") + rating = p.get("rating", "") + is_open = p.get("openNow", False) status = "open now" if is_open else "may be closed" + phone = p.get("phoneNumber", "") part = f"{name}, {status}" if rating: part += f", rated {rating}" + if phone: + part += f", call {_fmt_phone_for_speech(phone)}" parts.append(part) - count = len(sorted_results) + count = len(top_results) await self.capability_worker.speak( f"I found {count} emergency vet{'s' if count != 1 else ''} near you. " + ". ".join(parts) @@ -793,16 +835,23 @@ async def _handle_emergency_vet(self): ) except requests.exceptions.Timeout: + self.worker.editor_logging_handler.error("[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] Google Places API timeout" + "[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." ) # ------------------------------------------------------------------ @@ -819,7 +868,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,10 +898,42 @@ async def _handle_weather(self, intent: dict): "forecast_days": 1, } - resp = requests.get(url, params=params, timeout=10) - weather_data = resp.json() + # Non-blocking HTTP call + resp = await asyncio.to_thread(requests.get, url, params=params, timeout=10) + + # Check for HTTP errors + 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 + + # Parse response + 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 + + # Validate required fields + 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) @@ -883,67 +964,228 @@ async def _handle_weather(self, intent: dict): 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 # ------------------------------------------------------------------ + async def _fetch_fda_events(self, species: str) -> list: + """Fetch FDA adverse events for a specific species (non-blocking). + + Args: + species: Animal species (dog, cat, etc.) + + 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", + } + + # Non-blocking HTTP call + resp = await asyncio.to_thread( + requests.get, url, params=params, timeout=10 + ) + + # Check HTTP status + if resp.status_code == 200: + try: + data = resp.json() + 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: + # No results for this species - expected, not an error + 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 = [] + + # Only fetch if API key is configured + 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: + # Non-blocking HTTP call + 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, + ) + + # Check HTTP status + 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( + { + "source": "News", + "title": title, + "snippet": snippet, + "date": date, + } + ) + 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] 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 for recent pet food adverse events.""" + """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.") - all_results = [] + # Prepare parallel API tasks + tasks = [] + # Add FDA tasks for each species 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", - } + if species in ("dog", "cat"): + tasks.append(self._fetch_fda_events(species)) - resp = requests.get(url, params=params, timeout=10) + # Add Serper News task + tasks.append(self._fetch_serper_news(species_set)) - if resp.status_code == 200: - 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( - { - "species": species, - "brand": brand, - "date": r.get( - "original_receive_date", "unknown date" - ), - } - ) - else: - self.worker.editor_logging_handler.warning( - f"[PetCare] FDA API returned {resp.status_code}" - ) + # Run all API calls in parallel (non-blocking) + results = await asyncio.gather(*tasks, return_exceptions=True) - 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}") + # Combine FDA results (all but last item) + all_results = [] + for i, result in enumerate(results[:-1]): # FDA results + if isinstance(result, list): + all_results.extend(result) + elif isinstance(result, Exception): + # Already logged in helper method + pass - if not all_results: + # Extract Serper News headlines (last item) + news_headlines = [] + if len(results) > 0: + last_result = results[-1] + if isinstance(last_result, list): + news_headlines = last_result + elif isinstance(last_result, Exception): + # Already logged in helper method + pass + + if not all_results and not news_headlines: await self.capability_worker.speak( "No new pet food alerts found recently. Looks clear." ) @@ -951,13 +1193,22 @@ async def _handle_food_recall(self): # 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: @@ -965,10 +1216,10 @@ async def _handle_food_recall(self): 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?" ) # ------------------------------------------------------------------ @@ -996,19 +1247,14 @@ 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 await self._save_json(PETS_FILE, self.pet_data) @@ -1025,11 +1271,8 @@ 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 @@ -1060,7 +1303,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" @@ -1135,123 +1378,33 @@ async def _handle_edit_pet(self, intent: dict): # ------------------------------------------------------------------ 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", []) + """Resolve a pet name to a pet dict. - if not pets: - return None - - # If only one pet, always use it - if len(pets) == 1: - return pets[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 - - # 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] + Delegates to PetDataService. + """ + return self.pet_data_service.resolve_pet(self.pet_data, pet_name) 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 + """Resolve a pet, asking the user if ambiguous. - 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 - - # 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 - - # ------------------------------------------------------------------ - # Helper: trigger context - # ------------------------------------------------------------------ - - 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 "" - - # ------------------------------------------------------------------ - # Helper: extract value from messy voice input - # ------------------------------------------------------------------ - - 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, - ) - return _strip_json_fences(result).strip().strip('"') - except Exception: - return raw_input.strip() - - # ------------------------------------------------------------------ - # Helper: exit detection - # ------------------------------------------------------------------ - - @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 + 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) + # Non-blocking HTTP call + 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,21 +1432,39 @@ 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. + """ + # Check cache first + 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] + + # Cache miss - call API try: url = "https://geocoding-api.open-meteo.com/v1/search" - resp = requests.get( - url, params={"name": location_str, "count": 1}, timeout=10 + # Non-blocking HTTP call + resp = await asyncio.to_thread( + requests.get, url, params={"name": location_str, "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"], } + # Cache the result + 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 @@ -1303,20 +1474,22 @@ def _geocode_location(self, location_str: str) -> dict: # ------------------------------------------------------------------ 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/pet_data_service.py b/community/pet-care-assistant/pet_data_service.py new file mode 100644 index 00000000..e3a6f88b --- /dev/null +++ b/community/pet-care-assistant/pet_data_service.py @@ -0,0 +1,189 @@ +"""Pet Data Service - Handles pet CRUD operations and file I/O. + +Responsibilities: +- Load/save pet data from/to JSON files +- Resolve pet names (fuzzy matching) +- Pet CRUD operations +- Atomic file writes for data safety +""" + +import json +from typing import Optional + + +class PetDataService: + """Service for managing pet data and persistence.""" + + def __init__(self, capability_worker, worker): + """Initialize PetDataService. + + Args: + capability_worker: CapabilityWorker for file I/O and user interaction + worker: AgentWorker for logging + """ + self.capability_worker = capability_worker + self.worker = worker + + async def load_json(self, filename: str, default=None): + """Load a JSON file, returning default if not found or corrupt. + + Args: + filename: Name of file to load + default: Default value if file doesn't exist or is corrupt + + Returns: + Loaded JSON data or default value + """ + 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 {} + + async def save_json(self, filename: str, data): + """Save data using backup-write-delete pattern for data safety. + + Creates a backup before writing to prevent data loss. If write fails, + backup remains for recovery. Not truly atomic, but much safer than + delete-then-write. + + Pattern: + 1. Backup existing file (if exists) + 2. Write new data to target + 3. Delete backup on success + 4. If failure, backup retained for manual recovery + + Args: + filename: Target filename to save to + data: Data to serialize as JSON and save + + Raises: + Exception: If write fails (backup file will remain) + """ + backup_filename = f"{filename}.backup" + + try: + # Step 1: Create backup of existing file (if it exists) + 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}" + ) + + # Step 2: Write new data to target file + await self.capability_worker.write_file( + filename, json.dumps(data), False + ) + + # Step 3: Delete backup on success + 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}" + ) + # Backup file remains for manual recovery + 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: dict, pet_name: Optional[str] = None) -> Optional[dict]: + """Resolve a pet name to a pet dict (synchronous). + + Args: + pet_data: Pet data dict containing 'pets' list + pet_name: Optional pet name to search for + + Returns: + Matching pet dict or first pet if ambiguous, None if no pets + """ + pets = pet_data.get("pets", []) + + if not pets: + return None + + # If only one pet, always use it + if len(pets) == 1: + return pets[0] + + # If name given, try to match + if pet_name: + name_lower = pet_name.lower().strip() + # Exact match + 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 + + # Multiple pets, no match — return first pet as default + # The caller should handle ambiguity at a higher level + return pets[0] + + async def resolve_pet_async( + self, + pet_data: dict, + pet_name: Optional[str] = None, + is_exit_fn=None + ) -> Optional[dict]: + """Resolve a pet, asking the user if ambiguous (async). + + Args: + pet_data: Pet data dict containing 'pets' list + pet_name: Optional pet name to search for + is_exit_fn: Optional function to check if user wants to exit + + Returns: + Matching pet dict or None if user cancels + """ + 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() + # Exact match + for p in pets: + if p["name"].lower() == name_lower: + return p + # Fuzzy match + for p in pets: + if p["name"].lower().startswith(name_lower) or name_lower.startswith( + p["name"].lower() + ): + return p + + # 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 is_exit_fn or not is_exit_fn(response)): + return self.resolve_pet(pet_data, response) + return None diff --git a/community/pet-care-assistant/pytest.ini b/community/pet-care-assistant/pytest.ini new file mode 100644 index 00000000..8c8f27ab --- /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 00000000..e4173641 --- /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 00000000..92d380e0 --- /dev/null +++ b/community/pet-care-assistant/tests/__init__.py @@ -0,0 +1 @@ +# Pet Care Assistant - Test Suite diff --git a/community/pet-care-assistant/tests/conftest.py b/community/pet-care-assistant/tests/conftest.py new file mode 100644 index 00000000..18f1ad6f --- /dev/null +++ b/community/pet-care-assistant/tests/conftest.py @@ -0,0 +1,93 @@ +"""Shared test fixtures for Pet Care Assistant tests.""" + +import pytest +import sys +import os +from unittest.mock import MagicMock, AsyncMock + + +# 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 PetCareAssistantCapability + from pet_data_service import PetDataService + from activity_log_service import ActivityLogService + from external_api_service import ExternalAPIService + from llm_service import LLMService + + 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 = {} + + # 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 00000000..75469016 --- /dev/null +++ b/community/pet-care-assistant/tests/test_api_integration.py @@ -0,0 +1,479 @@ +"""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) +""" + +import json +import pytest +import responses +from unittest.mock import AsyncMock, MagicMock, patch +import asyncio + + +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 successfully find emergency vets with valid response.""" + # 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", + }, + { + "title": "BluePearl Pet Hospital", + "rating": 4.8, + "openNow": True, + "phoneNumber": "512-555-5678", + }, + ] + }, + status=200, + ) + + # Call the method + await capability_with_location._handle_emergency_vet() + + # Verify speak was called with vet info + calls = capability_with_location.capability_worker.speak.call_args_list + speak_text = " ".join(str(call[0][0]) for call in calls) + + assert "Austin Vet Emergency" in speak_text + assert "BluePearl Pet Hospital" in speak_text + assert "open now" in speak_text + + @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 00000000..423d0b52 --- /dev/null +++ b/community/pet-care-assistant/tests/test_exit_detection.py @@ -0,0 +1,377 @@ +"""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, strategies as st + + +class TestCleanInput: + """Test input cleaning logic.""" + + def test_removes_punctuation(self): + """Should remove all punctuation except apostrophes.""" + from llm_service 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 llm_service 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 llm_service 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 llm_service import LLMService + + assert LLMService.clean_input("") == "" + + def test_whitespace_only(self): + """Should handle whitespace-only input.""" + from llm_service import LLMService + + assert LLMService.clean_input(" ") == "" + assert LLMService.clean_input("\t\n") == "" + + def test_multiple_spaces(self): + """Should normalize multiple spaces.""" + from llm_service 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 llm_service 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 llm_service import LLMService + from main import PetCareAssistantCapability + from unittest.mock import MagicMock + + # 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 llm_service 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 llm_service import LLMService + from main import PetCareAssistantCapability + from unittest.mock import MagicMock + + # 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 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_llm_extraction.py b/community/pet-care-assistant/tests/test_llm_extraction.py new file mode 100644 index 00000000..d6f8d45a --- /dev/null +++ b/community/pet-care-assistant/tests/test_llm_extraction.py @@ -0,0 +1,511 @@ +"""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. +""" + +import pytest +from datetime import datetime + + +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(self, capability): + """Should handle unclear species input.""" + capability.capability_worker.text_to_text_response.return_value = "dog" + + result = await capability.llm_service.extract_species_async("Just a regular pet") + + assert result == "dog" + + +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 00000000..3e9f9235 --- /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 From a5e9beb62e9b3c1d063beb1b3c1cdaeda7199e68 Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Fri, 20 Feb 2026 00:53:48 +0500 Subject: [PATCH 03/19] Revise README.md for Pet Care Assistant Updated README.md to enhance clarity and detail about the Pet Care Assistant features, including improved descriptions, added badges, and structured sections. Signed-off-by: Muhammad Rizwan --- community/pet-care-assistant/README.md | 560 +++++++++++++++++++++---- 1 file changed, 490 insertions(+), 70 deletions(-) diff --git a/community/pet-care-assistant/README.md b/community/pet-care-assistant/README.md index b69bc8f8..df81d74c 100644 --- a/community/pet-care-assistant/README.md +++ b/community/pet-care-assistant/README.md @@ -1,95 +1,515 @@ # Pet Care Assistant +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@megz2020-lightgrey?style=flat-square) -![Community](https://img.shields.io/badge/OpenHome-Community-green?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 ability that helps users track and manage their pets' daily lives. Stores pet profiles, logs activities (feeding, medication, walks, weight), answers questions about those logs, finds emergency vets, warns about dangerous weather, and checks for pet food recalls. +**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 -**Why this needs to be an Ability:** The LLM already handles general pet care knowledge (breed info, training tips, nutrition advice). This ability exists because it does things the LLM cannot do alone: persist data across sessions, call external APIs for real-time information, and track activity over time. +## 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" -## Features +### Quick Lookups +- "when did I last feed [pet]" +- "has [pet] had heartworm pill this month" +- "how many walks this week" +- "last vet visit" +- "check on [pet]" -| Feature | Examples | -|---|---| -| **Guided Onboarding** | First-time users set up pet profiles step-by-step via voice | -| **Activity Logging** | "I just fed Luna", "Luna got her flea medicine", "We walked for 30 minutes" | -| **Quick Lookup** | "When did I last feed Luna?", "Has Max had his heartworm pill this month?" | -| **Weight Tracking** | "Luna weighs 48 pounds now", "How much has Luna's weight changed?" | -| **Emergency Vet Finder** | "Find an emergency vet", "I need a vet near me" | -| **Weather Safety** | "Is it safe for Luna outside?", "Pet weather check" | -| **Food Recall Check** | "Any pet food recalls?", "Is my dog food safe?" | -| **Profile Editing** | "Add a new pet", "Change my vet info", "Update Luna's weight" | -| **Multi-Pet Support** | Manages multiple pets, resolves by name when ambiguous | +### Emergency Vet Finder +- "emergency vet" +- "find a vet near me" +- "I need a vet" -## Services Used +### Weather Safety +- "is it safe outside for [pet]" +- "pet weather check" +- "can I walk my dog" +- "too hot for [pet]" -| Service | API | Auth | Cost | -|---|---|---|---| -| Emergency Vet Finder | Serper Maps API | User provides Serper API key | 2,500 free queries/month; pay-per-use after | -| Food Recall News | Serper News API | Same Serper API key | Included in same quota | -| Weather Safety | Open-Meteo | None needed | Free | -| Location Detection | ip-api.com | None needed | Free | -| Food Recall Events | openFDA API | None needed | Free | -| All other features | LLM + File Storage (built-in) | None needed | Free | +### Food Recalls +- "pet food recall" +- "is my dog food safe" +- "any food recalls" -If the Serper API key is not configured, the emergency vet feature gracefully falls back to showing the user's saved vet info, and food recall checks use openFDA only. Get a free key at [serper.dev](https://serper.dev). All other features work with zero external accounts. +### Profile Management +- "add a new pet" +- "update pet info" +- "change my vet" +- "remove pet" ## Setup -1. Add the ability to your OpenHome Personality -2. (Optional) Set your Serper API key in `main.py` for emergency vet search and food recall news (get a free key at [serper.dev](https://serper.dev)) -3. On first activation, the ability walks you through setting up pet profiles via voice +### Step 1: Add Ability to OpenHome +1. Go to your [OpenHome Dashboard](https://app.openhome.com/dashboard) +2. Navigate to **Abilities** section +3. Find **Pet Care Assistant** in the community library +4. Click **Add to Personality** -## Suggested Trigger Words +### Step 2: Configure API Key (Optional but Recommended) +The emergency vet finder requires a Google Places 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 + +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. + +### Step 3: First-Time Onboarding +On first activation, the ability will walk 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 + +### 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?" + +### 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?" + +**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") + +### 3. Weight Tracking +**Usage:** +- "Luna weighs 48 pounds now" (logs weight) +- "How much has Luna's weight changed?" (shows trend) + +**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) + +### 4. Emergency Vet Finder +**Usage:** +- "Find an emergency vet" +- "I need a vet near me" + +**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 + +**Fallback:** If no API key, shows only saved vet info. + +### 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" + +--- + +### Quick Lookup +> **User:** "When did I last feed Luna?" +> **AI:** "You fed Luna this morning at 8:30 AM." + +--- + +> **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" + +--- + +> **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." + +--- -Activity logging: "I fed", "I just fed", "ate", "got her medicine", "gave medication", "we walked", "went for a walk", "weighs", "log pet activity" +### 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?" -Lookups: "when did I last feed", "has had", "how many walks", "last vet visit", "check on" +--- -Emergency vet: "emergency vet", "find a vet", "vet near me", "I need a vet" +### 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." -Weather: "is it safe outside", "pet weather", "can I walk", "too hot for", "too cold for" +--- -Food recalls: "pet food recall", "food recall check", "is my dog food safe", "is my cat food safe", "any food recalls" +### 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?" -Profile: "add a pet", "update pet info", "change my vet", "pet profile" +--- + +### 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 + +| Service | Purpose | Authentication | Cost | +|---------|---------|----------------|------| +| **Google Places API** | Emergency vet search | API key (user provides) | Pay-per-use (~$17/1000 requests) | +| **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) | + +**Note:** Only Google Places API requires setup. All other features work immediately. ## Data Model -Two persistent JSON files using the delete-then-write pattern: +### Pet Profile (`petcare_pets.json`) +```json +{ + "pets": [ + { + "id": "pet_a1b2c3", + "name": "Luna", + "species": "dog", + "breed": "Golden Retriever", + "birthday": "2021-03-15", + "weight_lbs": 65, + "allergies": [], + "medications": [ + { + "name": "Heartgard", + "frequency": "monthly" + } + ] + } + ], + "vet_name": "Dr. Smith", + "vet_phone": "5125551234", + "user_location": "Austin, Texas", + "user_lat": 30.2672, + "user_lon": -97.7431 +} +``` -- **petcare_profiles.json** — Pet profiles, user location, vet info -- **petcare_activity_log.json** — Activity entries (feeding, medication, walk, weight, vet_visit, grooming, other), capped at 500 entries +### Activity Log (`petcare_activity_log.json`) +```json +[ + { + "id": "log_d4e5f6", + "pet_id": "pet_a1b2c3", + "pet_name": "Luna", + "type": "feeding", + "details": "breakfast", + "timestamp": "2024-03-15T08:30:00" + }, + { + "id": "log_g7h8i9", + "pet_id": "pet_a1b2c3", + "pet_name": "Luna", + "type": "weight", + "details": "65 lbs", + "value": 65, + "timestamp": "2024-03-15T09:00:00" + } +] +``` -## How It Works +## 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 + +### 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 + +## Troubleshooting + +### "I don't have any pets set up yet" +**Problem:** You triggered the ability but haven't completed onboarding. + +**Solution:** The ability should automatically start onboarding. If not, say "add a new pet" to start setup. + +--- + +### 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 + +**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) + +--- + +### 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) + +--- + +### "Which pet?" asked every time +**Problem:** You have multiple pets but aren't mentioning a name. + +**Solution:** Include pet name in your command: "I fed Luna" instead of just "I fed my dog" + +--- + +### Voice transcription errors +**Problem:** AI doesn't understand your speech correctly. + +**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" + +--- + +### 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. -1. **First-time users** go through guided voice onboarding (name, species, breed, age, weight, allergies, medications, vet, location) -2. **Returning users** — trigger context is classified by the LLM to determine the mode (log, lookup, weather, vet, recall, edit) -3. **Quick Mode** — if the trigger has a clear intent, the ability answers and offers one follow-up before exiting -4. **Full Mode** — if the trigger is vague, enters a multi-turn conversation loop with idle detection - -## Architecture - -- **LLM as intent router** — classifies voice input into structured JSON for mode routing -- **LLM as data extractor** — extracts clean values from messy voice transcription -- **Multi-pet context** — resolves pet names automatically; asks when ambiguous -- **Delete + write pattern** — prevents JSON corruption from append behavior -- **Namespaced files** — `petcare_` prefix avoids collisions with other abilities -- **try/finally** — guarantees `resume_normal_flow()` on every exit path -- **Exit-first checking** — exit words checked before LLM call to save resources -- **Filler speech** — "Let me check" before API calls that take > 1 second -- **Log size management** — caps at 500 entries, trims oldest automatically -- **Phone numbers spoken digit by digit** — for voice-friendly output - -## Key SDK Methods Used - -| SDK Method | Purpose | -|---|---| -| `speak()` | Voice output to user | -| `user_response()` | Listen for voice input | -| `run_io_loop()` | Speak + listen in one call (onboarding) | -| `text_to_text_response()` | LLM intent classification and data extraction | -| `run_confirmation_loop()` | Yes/no confirmation for destructive actions | -| `check_if_file_exists()` | First-run detection | -| `read_file()` / `write_file()` / `delete_file()` | Persistent JSON storage | -| `resume_normal_flow()` | Return to Personality (in try/finally) | -| `editor_logging_handler` | All logging (no print statements) | From 6cf95a8ebbc665360ba406f609626803c77634e5 Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Fri, 20 Feb 2026 00:54:17 +0500 Subject: [PATCH 04/19] Delete community/pet-care-assistant/config.json Signed-off-by: Muhammad Rizwan --- community/pet-care-assistant/config.json | 45 ------------------------ 1 file changed, 45 deletions(-) delete mode 100644 community/pet-care-assistant/config.json diff --git a/community/pet-care-assistant/config.json b/community/pet-care-assistant/config.json deleted file mode 100644 index 0fbfa12b..00000000 --- a/community/pet-care-assistant/config.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "unique_name": "pet_care_assistant", - "matching_hotwords": [ - "pet care", - "pet assistant", - "pet tracker", - "my pets", - "pet log", - "I fed", - "I just fed", - "gave medication", - "got her medicine", - "got his medicine", - "we walked", - "went for a walk", - "weighs", - "log pet activity", - "when did I last feed", - "has had", - "how many walks", - "last vet visit", - "check on", - "emergency vet", - "find a vet", - "vet near me", - "I need a vet", - "is it safe outside", - "pet weather", - "can I walk", - "too hot for", - "too cold for", - "pet food recall", - "food recall check", - "is my dog food safe", - "is my cat food safe", - "any food recalls", - "add a pet", - "remove a pet", - "delete pet", - "update pet info", - "change my vet", - "pet info", - "clear activity log" - ] -} From 6afe40e88f43526bf6cbee36aefa65a1dbfd4fe2 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Fri, 20 Feb 2026 04:21:50 +0200 Subject: [PATCH 05/19] fix: remove os.getenv() for OpenHome live editor compatibility Replace os.getenv() with hardcoded placeholder value to comply with OpenHome's security restrictions on os module usage outside register_capability block. --- community/pet-care-assistant/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 1da0319f..2aab58a6 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -46,8 +46,8 @@ } # Serper.dev API key (https://serper.dev — 2,500 free queries) -# Set via environment variable SERPER_API_KEY or use placeholder default -SERPER_API_KEY = os.getenv("SERPER_API_KEY", "your_serper_api_key_here") +# Users should replace this placeholder with their actual API key +SERPER_API_KEY = "your_serper_api_key_here" LOOKUP_SYSTEM_PROMPT = ( "You are a pet care assistant answering a question about the user's " From b6bb44afdaccb5a5d349ce0337372aab8bf820c5 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Fri, 20 Feb 2026 04:35:48 +0200 Subject: [PATCH 06/19] fix: add type annotations to service fields for Pydantic compatibility Add proper type annotations to service field declarations to comply with Pydantic v2 requirements. --- community/pet-care-assistant/main.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 2aab58a6..77ef560e 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -11,10 +11,10 @@ from src.main import AgentWorker # Service imports (separate files for better code organization) -from pet_data_service import PetDataService -from activity_log_service import ActivityLogService -from external_api_service import ExternalAPIService -from llm_service import LLMService +from .pet_data_service import PetDataService +from .activity_log_service import ActivityLogService +from .external_api_service import ExternalAPIService +from .llm_service import LLMService # ============================================================================= # PET CARE ASSISTANT @@ -139,6 +139,12 @@ class PetCareAssistantCapability(MatchingCapability): activity_log: list = None _geocode_cache: dict = None # In-memory cache for geocoding results + # Service instances (initialized in run()) + pet_data_service: "PetDataService" = None + activity_log_service: "ActivityLogService" = None + external_api_service: "ExternalAPIService" = None + llm_service: "LLMService" = None + @classmethod def register_capability(cls) -> "MatchingCapability": with open( From 852af88b74af6af6f4b29e1ed2f5f67f43a70d0a Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Fri, 20 Feb 2026 15:46:11 +0200 Subject: [PATCH 07/19] refactor: clean comments, rewrite README with architecture diagram - Remove 71 redundant/obvious comments across all service files and main.py - Rewrite 17 unclear comments to be concise and accurate - Replace verbose `# ---...---` banners with compact `# === ... ===` markers - Fix misleading comment about recurring reminders (not implemented) - Rewrite README.md from scratch: - Fix API references: Serper Maps/News (not Google Places) - Add ASCII architecture diagram showing 4-service structure and data flow - Add reminders section with supported time formats and example conversation - Add reset-all section - Add data model for petcare_reminders.json - Add technical notes: parallel extraction, asyncio non-blocking, exit tiers - Update services table with accurate pricing and descriptions - Fix troubleshooting section (remove Google Places references) - All 144 tests pass --- community/pet-care-assistant/README.md | 566 +++++++----------- .../activity_log_service.py | 7 +- .../external_api_service.py | 32 +- community/pet-care-assistant/llm_service.py | 26 +- community/pet-care-assistant/main.py | 552 ++++++++++------- .../pet-care-assistant/pet_data_service.py | 28 +- .../pet-care-assistant/tests/conftest.py | 36 +- .../tests/test_api_integration.py | 31 +- .../tests/test_exit_detection.py | 21 +- .../tests/test_llm_extraction.py | 147 +++-- 10 files changed, 773 insertions(+), 673 deletions(-) diff --git a/community/pet-care-assistant/README.md b/community/pet-care-assistant/README.md index df81d74c..5d20e4b8 100644 --- a/community/pet-care-assistant/README.md +++ b/community/pet-care-assistant/README.md @@ -3,22 +3,84 @@ ![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 + +``` + User Voice Input + | + OpenHome STT + | + ┌─────▼─────┐ + │ main.py │ PetCareAssistantCapability + │ (router) │ + └─────┬─────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ┌─────▼──────┐ ┌─────▼──────┐ ┌────▼──────────┐ + │ LLMService │ │PetDataSvc │ │ActivityLogSvc │ + │ │ │ │ │ │ + │ - classify │ │ - load/save│ │ - add entry │ + │ intent │ │ JSON │ │ - filter/query│ + │ - extract │ │ - resolve │ │ - enforce │ + │ values │ │ pet name │ │ size limit │ + │ - is_exit │ │ - fuzzy │ │ │ + │ │ │ match │ └───────────────┘ + └────────────┘ └────────────┘ + │ + ┌─────▼──────────┐ + │ExternalAPISvc │ + │ │ + │ - Serper Maps │──► google.serper.dev/maps + │ (vet search) │ + │ - Open-Meteo │──► api.open-meteo.com + │ (weather) │ + │ - openFDA │──► api.fda.gov + │ (recalls) │ + │ - Serper News │──► google.serper.dev/news + │ (headlines) │ + │ - ip-api.com │──► ip-api.com + │ (geolocation)│ + └────────────────┘ + + 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 +90,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 +98,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 +106,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: - -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 +### Step 2: Configure Serper API Key (Optional but Recommended) +The emergency vet finder and food recall news headlines require a Serper API 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"` +1. Go to [serper.dev](https://serper.dev) and sign up +2. Copy your API key (2,500 free queries included) +3. Open `main.py` and find: + ```python + SERPER_API_KEY = "your_serper_api_key_here" + ``` +4. Replace with your actual key -**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" - ---- - -### Quick Lookup -> **User:** "When did I last feed Luna?" -> **AI:** "You fed Luna this morning at 8:30 AM." - ---- +Fetches current conditions from Open-Meteo (free, no key required) and assesses safety for your specific pet's breed. -> **User:** "Has Luna had her heartworm pill this month?" -> **AI:** "Yes, Luna got her Heartgard on March 15th, about 2 weeks ago." +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 ---- - -### 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 +261,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 +295,69 @@ 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 - -### 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 +### 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" + } +] +``` -### Fuzzy Name Matching -- Handles typos and variations -- "Lona" → matches "Luna" -- "max" → matches "Max" -- Partial matches supported +--- -## Troubleshooting +## Technical Notes -### "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/activity_log_service.py b/community/pet-care-assistant/activity_log_service.py index 458c4699..fdcb893b 100644 --- a/community/pet-care-assistant/activity_log_service.py +++ b/community/pet-care-assistant/activity_log_service.py @@ -29,7 +29,7 @@ def add_activity( pet_name: str, activity_type: str, details: str = "", - value: float = None + value: float = None, ) -> list: """Add an activity to the log. @@ -54,10 +54,9 @@ def add_activity( activity_log.append(entry) - # Enforce size limit if len(activity_log) > self.max_log_entries: removed = len(activity_log) - self.max_log_entries - activity_log = 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." ) @@ -69,7 +68,7 @@ def get_recent_activities( activity_log: list, pet_name: Optional[str] = None, activity_type: Optional[str] = None, - limit: int = 10 + limit: int = 10, ) -> list: """Get recent activities, optionally filtered. diff --git a/community/pet-care-assistant/external_api_service.py b/community/pet-care-assistant/external_api_service.py index aa1121f0..430c0c74 100644 --- a/community/pet-care-assistant/external_api_service.py +++ b/community/pet-care-assistant/external_api_service.py @@ -54,7 +54,6 @@ async def get_weather_data(self, lat: float, lon: float) -> Optional[dict]: if resp.status_code == 200: try: data = resp.json() - # Validate response has required fields if "current" not in data: self.worker.editor_logging_handler.error( "[PetCare] Weather API response missing 'current' field" @@ -86,7 +85,9 @@ async def get_weather_data(self, lat: float, lon: float) -> Optional[dict]: ) return None - async def search_emergency_vets(self, lat: float, lon: float, location: str) -> list: + async def search_emergency_vets( + self, lat: float, lon: float, location: str + ) -> list: """Search for emergency vets using Serper Maps API (non-blocking). Args: @@ -275,7 +276,12 @@ async def fetch_serper_news(self, species_set: set) -> list: date = item.get("date", "") if title: headlines.append( - {"source": "News", "title": title, "snippet": snippet, "date": date} + { + "source": "News", + "title": title, + "snippet": snippet, + "date": date, + } ) elif news_resp.status_code in (401, 403): self.worker.editor_logging_handler.error( @@ -321,9 +327,16 @@ async def detect_location_by_ip(self, client_ip: str) -> Optional[dict]: if data.get("status") == "success": # Check for cloud/VPN IPs isp = data.get("isp", "").lower() + # Warn if cloud/VPN IP detected (location may be inaccurate) if any( cloud in isp - for cloud in ["amazon", "google", "microsoft", "cloudflare", "digital ocean"] + for cloud in [ + "amazon", + "google", + "microsoft", + "cloudflare", + "digital ocean", + ] ): self.worker.editor_logging_handler.warning( f"[PetCare] Detected cloud/VPN IP ({isp}), location may be inaccurate" @@ -344,7 +357,9 @@ async def detect_location_by_ip(self, client_ip: str) -> Optional[dict]: return None - async def geocode_location(self, location: str, geocode_cache: dict) -> Optional[dict]: + async def geocode_location( + self, location: str, geocode_cache: dict + ) -> Optional[dict]: """Geocode a location string to lat/lon (non-blocking, with caching). Args: @@ -354,7 +369,6 @@ async def geocode_location(self, location: str, geocode_cache: dict) -> Optional Returns: Dict with lat, lon or None if geocoding fails """ - # Check cache first if location in geocode_cache: self.worker.editor_logging_handler.info( f"[PetCare] Geocoding cache hit for: {location}" @@ -371,8 +385,10 @@ async def geocode_location(self, location: str, geocode_cache: dict) -> Optional data = resp.json() results = data.get("results", []) if results: - result = {"lat": results[0]["latitude"], "lon": results[0]["longitude"]} - # Cache the result + result = { + "lat": results[0]["latitude"], + "lon": results[0]["longitude"], + } geocode_cache[location] = result return result diff --git a/community/pet-care-assistant/llm_service.py b/community/pet-care-assistant/llm_service.py index 303298e2..4ef4aec4 100644 --- a/community/pet-care-assistant/llm_service.py +++ b/community/pet-care-assistant/llm_service.py @@ -12,7 +12,6 @@ import re from datetime import datetime - # Constants for exit detection (three-tier system) FORCE_EXIT_PHRASES: list[str] = [ "exit petcare", @@ -46,7 +45,6 @@ "we're done", ] -# Intent classification prompt CLASSIFY_PROMPT = ( "You are an intent classifier for a pet care assistant. " "The user manages one or more pets.\n" @@ -59,7 +57,8 @@ '- {{"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": "exit"}}\n' '- {{"mode": "unknown"}}\n\n' "Rules:\n" @@ -76,6 +75,10 @@ "- '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" + "- 'start over', 'reset everything', 'delete everything', 'wipe all data', 'fresh start' => edit_pet with action reset_all\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" "- 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" @@ -91,6 +94,9 @@ '"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' + '"What reminders do I have?" -> {{"mode": "reminder", "action": "list", "pet_name": null, "activity": null, "time_description": null}}\n' ) @@ -118,7 +124,7 @@ def __init__(self, capability_worker, worker, pet_data: dict): self.pet_data = pet_data def classify_intent(self, user_input: str) -> dict: - """Use LLM to classify user intent and extract structured data. + """Use LLM to classify user intent and extract structured data (sync). Args: user_input: Raw user input string @@ -143,6 +149,10 @@ def classify_intent(self, user_input: str) -> dict: ) return {"mode": "unknown"} + async def classify_intent_async(self, user_input: str) -> dict: + """Async wrapper for classify_intent (runs in thread pool to avoid blocking event loop).""" + return await asyncio.to_thread(self.classify_intent, user_input) + def extract_value(self, raw_input: str, instruction: str) -> str: """Use LLM to extract a clean value from messy voice input (sync). @@ -178,9 +188,7 @@ async def extract_value_async(self, raw_input: str, instruction: str) -> str: """ return await asyncio.to_thread(self.extract_value, raw_input, instruction) - # ------------------------------------------------------------------ - # Typed Extraction Methods - # ------------------------------------------------------------------ + # === Typed Extraction Methods === async def extract_pet_name_async(self, raw_input: str) -> str: """Extract pet name from user input. @@ -411,6 +419,10 @@ def is_exit_llm(self, text: str) -> bool: except Exception: return False + async def is_exit_llm_async(self, text: str) -> bool: + """Async wrapper for is_exit_llm (runs in thread pool to avoid blocking event loop).""" + return await asyncio.to_thread(self.is_exit_llm, text) + def get_trigger_context(self) -> str: """Get the transcription that triggered this ability. diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 77ef560e..142070a0 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -3,35 +3,36 @@ 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 -# Service imports (separate files for better code organization) -from .pet_data_service import PetDataService -from .activity_log_service import ActivityLogService -from .external_api_service import ExternalAPIService -from .llm_service import LLMService - -# ============================================================================= -# 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) -# ============================================================================= +# Service imports — relative for OpenHome runtime, absolute fallback for local tests +try: + from .activity_log_service import ActivityLogService + from .external_api_service import ExternalAPIService + from .llm_service import LLMService + from .pet_data_service import PetDataService +except ImportError: + from activity_log_service import ActivityLogService # noqa: E402 + from external_api_service import ExternalAPIService # noqa: E402 + from llm_service import LLMService # noqa: E402 + from pet_data_service import PetDataService # noqa: E402 + +"""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 @@ -45,8 +46,7 @@ "other", } -# Serper.dev API key (https://serper.dev — 2,500 free queries) -# Users should replace this placeholder with their actual API key +# 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 = ( @@ -94,23 +94,18 @@ def _fmt_phone_for_speech(phone: str) -> str: if not phone: return "no number provided" - # Extract digits only digits = re.sub(r"\D", "", phone) - # Handle empty result if not digits: return "no number provided" - # Handle different formats if len(digits) == 10: - # US: (512) 555-1234 return ( f"{', '.join(digits[:3])}, " f"{', '.join(digits[3:6])}, " f"{', '.join(digits[6:])}" ) elif len(digits) == 11 and digits[0] == "1": - # US with country code: 1-512-555-1234 return ( f"1, " f"{', '.join(digits[1:4])}, " @@ -118,14 +113,11 @@ def _fmt_phone_for_speech(phone: str) -> str: f"{', '.join(digits[7:])}" ) elif 7 <= len(digits) <= 15: - # International or other valid formats - group by 3s for readability 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: - # Too short - incomplete number return "incomplete phone number" else: - # Too long (>15 digits) - possibly invalid or has extension return "phone number too long, please check" @@ -137,13 +129,14 @@ class PetCareAssistantCapability(MatchingCapability): capability_worker: CapabilityWorker = None pet_data: dict = None activity_log: list = None - _geocode_cache: dict = None # In-memory cache for geocoding results + _geocode_cache: dict = None - # Service instances (initialized in run()) + # 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 @classmethod def register_capability(cls) -> "MatchingCapability": @@ -161,30 +154,31 @@ def call(self, worker: AgentWorker): self.capability_worker = CapabilityWorker(self.worker) self.worker.session_tasks.create(self.run()) - # ------------------------------------------------------------------ - # Main flow - # ------------------------------------------------------------------ + # === Main flow === async def run(self): try: self.worker.editor_logging_handler.info("[PetCare] Ability started") - # Initialize services (separate files for maintainability) 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) - # Load persistent data self.pet_data = await self.pet_data_service.load_json(PETS_FILE, default={}) + # LLMService needs pet_data for intent classification context + 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=[] + ) - # Initialize LLM service (needs pet_data for intent classification context) - 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=[]) - - # Initialize geocoding cache for session self._geocode_cache = {} + await self._check_due_reminders() - # Check if first-time user (no pet data file) has_pet_data = await self.capability_worker.check_if_file_exists( PETS_FILE, False ) @@ -193,25 +187,24 @@ async def run(self): await self.run_onboarding() return - # Returning user — classify trigger context trigger = self.llm_service.get_trigger_context() if trigger: - intent = self.llm_service.classify_intent(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.llm_service.is_exit(follow_up): - follow_intent = self.llm_service.classify_intent(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(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( @@ -231,7 +224,11 @@ 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.llm_service.is_exit(final): + 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 @@ -241,18 +238,19 @@ async def run(self): idle_count = 0 - # --- Hybrid exit detection --- if self.llm_service.is_exit(user_input): await self.capability_worker.speak(EXIT_MESSAGE) break # Short ambiguous input — ask LLM if it's an exit cleaned = self.llm_service.clean_input(user_input) - if len(cleaned.split()) <= 4 and self.llm_service.is_exit_llm(cleaned): + if len( + cleaned.split() + ) <= 4 and await self.llm_service.is_exit_llm_async(cleaned): await self.capability_worker.speak(EXIT_MESSAGE) break - intent = self.llm_service.classify_intent(user_input) + intent = await self.llm_service.classify_intent_async(user_input) mode = intent.get("mode", "unknown") if mode == "exit": @@ -271,9 +269,7 @@ async def run(self): self.worker.editor_logging_handler.info("[PetCare] Ability ended") self.capability_worker.resume_normal_flow() - # ------------------------------------------------------------------ - # Intent router - # ------------------------------------------------------------------ + # === Intent router === async def _route_intent(self, intent: dict): """Route to the correct handler based on classified intent.""" @@ -291,17 +287,16 @@ 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 == "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.""" @@ -318,7 +313,6 @@ async def run_onboarding(self): 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) @@ -330,7 +324,6 @@ async def run_onboarding(self): "or 'find an emergency vet' if you ever need one." ) - # 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.llm_service.is_exit(response): @@ -340,7 +333,6 @@ async def run_onboarding(self): w in cleaned for w in ["no", "nope", "nah", "that's it", "that's all"] ): 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: @@ -351,18 +343,14 @@ async def _collect_pet_info(self) -> dict: Phase 2: Extract all values in parallel with asyncio.gather() Phase 3: Process results and build pet dict """ - # ====================================================================== - # PHASE 1: Collect all raw user inputs (no LLM calls yet) - # ====================================================================== + # ======= PHASE 1: Collect raw user inputs ======= raw_inputs = {} - # Name name_input = await self.capability_worker.user_response() if not name_input or self.llm_service.is_exit(name_input): return None raw_inputs["name"] = name_input - # Species (need temporary name for prompts) temp_name = name_input.strip().split()[0] if name_input.strip() else "your pet" species_input = await self.capability_worker.run_io_loop( f"Great! What kind of animal is {temp_name}? Dog, cat, or something else?" @@ -371,7 +359,6 @@ async def _collect_pet_info(self) -> dict: return None raw_inputs["species"] = species_input - # Breed breed_input = await self.capability_worker.run_io_loop( f"What breed is {temp_name}?" ) @@ -379,7 +366,6 @@ async def _collect_pet_info(self) -> dict: return None raw_inputs["breed"] = breed_input - # Age / birthday age_input = await self.capability_worker.run_io_loop( f"How old is {temp_name}, or do you know their birthday?" ) @@ -387,7 +373,6 @@ async def _collect_pet_info(self) -> dict: return None raw_inputs["age"] = age_input - # Weight weight_input = await self.capability_worker.run_io_loop( f"Roughly how much does {temp_name} weigh?" ) @@ -395,7 +380,6 @@ async def _collect_pet_info(self) -> dict: return None raw_inputs["weight"] = weight_input - # Allergies allergy_input = await self.capability_worker.run_io_loop( f"Does {temp_name} have any allergies I should know about?" ) @@ -403,7 +387,6 @@ async def _collect_pet_info(self) -> dict: return None raw_inputs["allergies"] = allergy_input - # Medications med_input = await self.capability_worker.run_io_loop( f"Is {temp_name} on any medications?" ) @@ -411,7 +394,6 @@ async def _collect_pet_info(self) -> dict: return None raw_inputs["medications"] = med_input - # Vet info vet_input = await self.capability_worker.run_io_loop( "Do you have a regular vet? If so, what's their name?" ) @@ -427,7 +409,6 @@ async def _collect_pet_info(self) -> dict: if phone_input and not self.llm_service.is_exit(phone_input): raw_inputs["vet_phone"] = phone_input - # 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." ) @@ -435,11 +416,8 @@ async def _collect_pet_info(self) -> dict: if location_input and not self.llm_service.is_exit(location_input): raw_inputs["location"] = location_input - # ====================================================================== - # PHASE 2: Extract all values in parallel (massive performance gain) - # ====================================================================== + # ======= PHASE 2: Extract all values in parallel ======= extraction_tasks = [ - # Core pet info (always collected) self.llm_service.extract_pet_name_async(raw_inputs["name"]), self.llm_service.extract_species_async(raw_inputs["species"]), self.llm_service.extract_breed_async(raw_inputs["breed"]), @@ -449,13 +427,13 @@ async def _collect_pet_info(self) -> dict: self.llm_service.extract_medications_async(raw_inputs["medications"]), ] - # Add vet name extraction if applicable vet_name_idx = None if raw_inputs["vet"] is not None: vet_name_idx = len(extraction_tasks) - extraction_tasks.append(self.llm_service.extract_vet_name_async(raw_inputs["vet"])) + extraction_tasks.append( + self.llm_service.extract_vet_name_async(raw_inputs["vet"]) + ) - # Add vet phone extraction if applicable vet_phone_idx = None if raw_inputs["vet_phone"] is not None: vet_phone_idx = len(extraction_tasks) @@ -463,31 +441,27 @@ async def _collect_pet_info(self) -> dict: self.llm_service.extract_phone_number_async(raw_inputs["vet_phone"]) ) - # Add location extraction if applicable location_idx = None if raw_inputs["location"] is not None: location_idx = len(extraction_tasks) - extraction_tasks.append(self.llm_service.extract_location_async(raw_inputs["location"])) + extraction_tasks.append( + self.llm_service.extract_location_async(raw_inputs["location"]) + ) - # Run all LLM extractions in parallel (non-blocking) results = await asyncio.gather(*extraction_tasks) - # ====================================================================== - # PHASE 3: Process results and build pet dict - # ====================================================================== + # ======= PHASE 3: Build pet dict from extracted results ======= name = results[0] species = results[1].lower() breed = results[2] birthday = results[3] - # Parse weight weight_str = results[4] try: weight_lbs = float(weight_str) except (ValueError, TypeError): weight_lbs = 0 - # Parse allergies JSON allergies_str = results[5] try: allergies = json.loads(allergies_str) @@ -496,7 +470,6 @@ async def _collect_pet_info(self) -> dict: except (json.JSONDecodeError, TypeError): allergies = [] - # Parse medications JSON meds_str = results[6] try: medications = json.loads(meds_str) @@ -517,7 +490,6 @@ async def _collect_pet_info(self) -> dict: if location_idx is not None: location = results[location_idx] self.pet_data["user_location"] = location - # Get lat/lon from location (non-blocking) coords = await self._geocode_location(location) if coords: self.pet_data["user_lat"] = coords["lat"] @@ -535,9 +507,7 @@ async def _collect_pet_info(self) -> dict: "medications": medications, } - # ------------------------------------------------------------------ - # Log Activity - # ------------------------------------------------------------------ + # === Log Activity === async def _handle_log(self, intent: dict): """Log a pet activity (feeding, medication, walk, weight, etc.).""" @@ -560,29 +530,24 @@ 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() @@ -592,21 +557,17 @@ async def _handle_log(self, intent: dict): 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.llm_service.classify_intent(follow) + follow_intent = await self.llm_service.classify_intent_async(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 if pet: relevant_logs = [ e for e in self.activity_log if e.get("pet_id") == pet["id"] @@ -616,7 +577,6 @@ async def _handle_lookup(self, intent: dict): 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"] @@ -685,13 +645,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 Serper Maps API.""" - # Mention saved vet first if available saved_vet = self.pet_data.get("vet_name", "") saved_phone = self.pet_data.get("vet_phone", "") @@ -705,7 +662,6 @@ async def _handle_emergency_vet(self): f"Your regular vet is {saved_vet} at {phone_spoken}." ) - # Check for API key if SERPER_API_KEY == "your_serper_api_key_here": if not saved_vet: await self.capability_worker.speak( @@ -720,28 +676,55 @@ async def _handle_emergency_vet(self): ) 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 = 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: + # Allow user to override auto-detected location with saved location + 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.") @@ -760,12 +743,10 @@ async def _handle_emergency_vet(self): } payload = {"q": query, "num": 5} - # Non-blocking HTTP call resp = await asyncio.to_thread( requests.post, url, headers=headers, json=payload, timeout=10 ) - # Check for HTTP errors 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}" @@ -793,7 +774,6 @@ async def _handle_emergency_vet(self): ) return - # Parse response try: data = resp.json() except json.JSONDecodeError as e: @@ -813,35 +793,57 @@ async def _handle_emergency_vet(self): ) return - # Prioritize open locations, take top 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] - parts = [] - for p in top_results: - name = p.get("title", "Unknown") - rating = p.get("rating", "") - is_open = p.get("openNow", False) - status = "open now" if is_open else "may be closed" - phone = p.get("phoneNumber", "") - - part = f"{name}, {status}" - if rating: - part += f", rated {rating}" - if phone: - part += f", call {_fmt_phone_for_speech(phone)}" - parts.append(part) - + # Announce names first — short, interruptible utterances + names = [p.get("title", "Unknown") for p in top_results] count = len(top_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?" + f"I found {count} nearby vet{'s' if count != 1 else ''}: " + + ", ".join(names) + + ". Which one do you want the number for?" ) + pick = await self.capability_worker.user_response() + if not pick or self.llm_service.is_exit(pick): + return + + pick_lower = pick.lower() + chosen = next( + ( + p + for p in top_results + if any( + word in pick_lower + for word in p.get("title", "").lower().split() + ) + ), + top_results[0], # default to first if unclear + ) + + name = chosen.get("title", "Unknown") + phone = chosen.get("phoneNumber", "") + rating = chosen.get("rating", "") + is_open = chosen.get("openNow", False) + address = chosen.get("address", "") + status = "open now" if is_open else "may be closed" + + detail = f"{name}, {status}" + if rating: + detail += f", rated {rating}" + if phone: + detail += f". Number: {_fmt_phone_for_speech(phone)}" + if address: + detail += f". Address: {address}" + await self.capability_worker.speak(detail) + except requests.exceptions.Timeout: - self.worker.editor_logging_handler.error("[PetCare] Serper Maps API timeout") + self.worker.editor_logging_handler.error( + "[PetCare] Serper Maps API timeout" + ) await self.capability_worker.speak( "The vet search timed out. Check your internet connection and try again." ) @@ -860,9 +862,7 @@ async def _handle_emergency_vet(self): "An unexpected error occurred while searching for vets. Try again later." ) - # ------------------------------------------------------------------ - # Weather Safety Check - # ------------------------------------------------------------------ + # === Weather Safety Check === async def _handle_weather(self, intent: dict): """Check weather safety for a pet using Open-Meteo API.""" @@ -904,10 +904,8 @@ async def _handle_weather(self, intent: dict): "forecast_days": 1, } - # Non-blocking HTTP call resp = await asyncio.to_thread(requests.get, url, params=params, timeout=10) - # Check for HTTP errors if resp.status_code != 200: self.worker.editor_logging_handler.error( f"[PetCare] Open-Meteo API returned error: {resp.status_code}" @@ -917,7 +915,6 @@ async def _handle_weather(self, intent: dict): ) return - # Parse response try: weather_data = resp.json() except json.JSONDecodeError as e: @@ -929,7 +926,6 @@ async def _handle_weather(self, intent: dict): ) return - # Validate required fields current = weather_data.get("current") if not current: self.worker.editor_logging_handler.error( @@ -944,7 +940,6 @@ async def _handle_weather(self, intent: dict): 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 @@ -987,9 +982,7 @@ async def _handle_weather(self, intent: dict): "An unexpected error occurred while checking the weather." ) - # ------------------------------------------------------------------ - # Food Recall Checker - # ------------------------------------------------------------------ + # === Food Recall Checker === async def _fetch_fda_events(self, species: str) -> list: """Fetch FDA adverse events for a specific species (non-blocking). @@ -1009,12 +1002,8 @@ async def _fetch_fda_events(self, species: str) -> list: "sort": "original_receive_date:desc", } - # Non-blocking HTTP call - resp = await asyncio.to_thread( - requests.get, url, params=params, timeout=10 - ) + resp = await asyncio.to_thread(requests.get, url, params=params, timeout=10) - # Check HTTP status if resp.status_code == 200: try: data = resp.json() @@ -1037,7 +1026,7 @@ async def _fetch_fda_events(self, species: str) -> list: } ) elif resp.status_code == 404: - # No results for this species - expected, not an error + # 404 is expected when no events exist for species self.worker.editor_logging_handler.info( f"[PetCare] No FDA events found for {species}" ) @@ -1076,7 +1065,6 @@ async def _fetch_serper_news(self, species_set: set) -> list: """ headlines = [] - # Only fetch if API key is configured if SERPER_API_KEY == "your_serper_api_key_here": return headlines @@ -1088,7 +1076,6 @@ async def _fetch_serper_news(self, species_set: set) -> list: ) try: - # Non-blocking HTTP call news_resp = await asyncio.to_thread( requests.post, "https://google.serper.dev/news", @@ -1100,7 +1087,6 @@ async def _fetch_serper_news(self, species_set: set) -> list: timeout=10, ) - # Check HTTP status if news_resp.status_code == 200: try: news_data = news_resp.json() @@ -1158,30 +1144,20 @@ async def _handle_food_recall(self): await self.capability_worker.speak("Let me check for recent pet food alerts.") - # Prepare parallel API tasks tasks = [] - - # Add FDA tasks for each species for species in species_set: if species in ("dog", "cat"): tasks.append(self._fetch_fda_events(species)) - - # Add Serper News task tasks.append(self._fetch_serper_news(species_set)) - # Run all API calls in parallel (non-blocking) + # Execute all API calls concurrently results = await asyncio.gather(*tasks, return_exceptions=True) - # Combine FDA results (all but last item) all_results = [] - for i, result in enumerate(results[:-1]): # FDA results + for result in results[:-1]: # FDA results if isinstance(result, list): all_results.extend(result) - elif isinstance(result, Exception): - # Already logged in helper method - pass - # Extract Serper News headlines (last item) news_headlines = [] if len(results) > 0: last_result = results[-1] @@ -1197,7 +1173,6 @@ async def _handle_food_recall(self): ) return - # Summarize with LLM pet_names = [p["name"] for p in pets] context_parts = [] if all_results: @@ -1228,9 +1203,7 @@ async def _handle_food_recall(self): "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.""" @@ -1260,7 +1233,9 @@ async def _handle_edit_pet(self, intent: dict): await self.capability_worker.speak("And their phone number?") phone_input = await self.capability_worker.user_response() if phone_input and not self.llm_service.is_exit(phone_input): - vet_phone = await self.llm_service.extract_phone_number_async(phone_input) + vet_phone = await self.llm_service.extract_phone_number_async( + phone_input + ) self.pet_data["vet_phone"] = vet_phone await self._save_json(PETS_FILE, self.pet_data) @@ -1278,17 +1253,17 @@ async def _handle_edit_pet(self, intent: dict): ) weight_input = await self.capability_worker.user_response() if weight_input and not self.llm_service.is_exit(weight_input): - weight_str = await self.llm_service.extract_weight_async(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", @@ -1373,15 +1348,193 @@ 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 = [] + await self._save_json(PETS_FILE, self.pet_data) + await self._save_json(ACTIVITY_LOG_FILE, self.activity_log) + await self._save_json(REMINDERS_FILE, self.reminders) + 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 - # ------------------------------------------------------------------ + # === Reminders === + + 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', + 'every day at HH:MM' (returns next occurrence). + Returns None if unparseable. + """ + if not time_description: + return None + now = datetime.now() + text = time_description.lower().strip() + + 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 = int(m.group(1)) + minute = int(m.group(2)) if m.group(2) else 0 + meridiem = m.group(3) + if meridiem == "pm" and hour < 12: + hour += 12 + elif meridiem == "am" and hour == 12: + hour = 0 + tomorrow = now + timedelta(days=1) + return tomorrow.replace(hour=hour, minute=minute, second=0, microsecond=0) + + m = re.search(r"at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?", text) + if m: + hour = int(m.group(1)) + minute = int(m.group(2)) if m.group(2) else 0 + meridiem = m.group(3) + if meridiem == "pm" and hour < 12: + hour += 12 + elif meridiem == "am" and hour == 12: + hour = 0 + candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + # If time already passed today, schedule for tomorrow + if candidate <= now: + candidate += timedelta(days=1) + return candidate + + return None + + 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.") + ) + # Remove fired reminders + self.reminders = [r for r in self.reminders if r not in due] + await self._save_json(REMINDERS_FILE, self.reminders) + + async def _handle_reminder(self, intent: dict): + """Handle set / list / delete reminder actions.""" + action = intent.get("action", "set") + + 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}." + ) + + 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." + ) + + else: # "set" + pet_name = intent.get("pet_name", "") + activity = intent.get("activity", "") + time_description = intent.get("time_description", "") + + if not time_description: + await self.capability_worker.speak( + "When should I remind you? Say something like 'in 2 hours' or 'at 6 PM'." + ) + time_description = await self.capability_worker.user_response() or "" + + due_at = self._parse_reminder_time(time_description) + if not due_at: + await self.capability_worker.speak( + "I couldn't understand that time. Try saying 'in 2 hours' or 'at 6 PM'." + ) + return + + 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}" + ) + + # === Helper: resolve pet === def _resolve_pet(self, pet_name: str) -> dict: """Resolve a pet name to a pet dict. @@ -1399,15 +1552,12 @@ async def _resolve_pet_async(self, pet_name: str) -> dict: self.pet_data, pet_name, self.llm_service.is_exit ) - # ------------------------------------------------------------------ - # Helper: geolocation - # ------------------------------------------------------------------ + # === Helper: geolocation === 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 - # Non-blocking HTTP call resp = await asyncio.to_thread( requests.get, f"http://ip-api.com/json/{ip}", timeout=5 ) @@ -1443,17 +1593,14 @@ async def _geocode_location(self, location_str: str) -> dict: Uses in-memory cache to avoid redundant API calls within a session. """ - # Check cache first 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] - # Cache miss - call API try: url = "https://geocoding-api.open-meteo.com/v1/search" - # Non-blocking HTTP call resp = await asyncio.to_thread( requests.get, url, params={"name": location_str, "count": 1}, timeout=10 ) @@ -1465,7 +1612,6 @@ async def _geocode_location(self, location_str: str) -> dict: "lat": results[0]["latitude"], "lon": results[0]["longitude"], } - # Cache the result self._geocode_cache[location_str] = coords self.worker.editor_logging_handler.info( f"[PetCare] Geocoded {location_str} -> {coords['lat']}, {coords['lon']}" @@ -1475,9 +1621,7 @@ async def _geocode_location(self, location_str: str) -> dict: 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. diff --git a/community/pet-care-assistant/pet_data_service.py b/community/pet-care-assistant/pet_data_service.py index e3a6f88b..2fd1c073 100644 --- a/community/pet-care-assistant/pet_data_service.py +++ b/community/pet-care-assistant/pet_data_service.py @@ -68,22 +68,16 @@ async def save_json(self, filename: str, data): backup_filename = f"{filename}.backup" try: - # Step 1: Create backup of existing file (if it exists) + # Create backup before overwriting 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 - ) + await self.capability_worker.write_file(backup_filename, content, False) self.worker.editor_logging_handler.info( f"[PetCare] Created backup: {backup_filename}" ) - # Step 2: Write new data to target file - await self.capability_worker.write_file( - filename, json.dumps(data), False - ) + await self.capability_worker.write_file(filename, json.dumps(data), False) - # Step 3: Delete backup on success if await self.capability_worker.check_if_file_exists( backup_filename, False ): @@ -105,7 +99,9 @@ async def save_json(self, filename: str, data): ) raise - def resolve_pet(self, pet_data: dict, pet_name: Optional[str] = None) -> Optional[dict]: + def resolve_pet( + self, pet_data: dict, pet_name: Optional[str] = None + ) -> Optional[dict]: """Resolve a pet name to a pet dict (synchronous). Args: @@ -120,14 +116,11 @@ def resolve_pet(self, pet_data: dict, pet_name: Optional[str] = None) -> Optiona if not pets: return None - # If only one pet, always use it if len(pets) == 1: return pets[0] - # If name given, try to match if pet_name: name_lower = pet_name.lower().strip() - # Exact match for p in pets: if p["name"].lower() == name_lower: return p @@ -143,10 +136,7 @@ def resolve_pet(self, pet_data: dict, pet_name: Optional[str] = None) -> Optiona return pets[0] async def resolve_pet_async( - self, - pet_data: dict, - pet_name: Optional[str] = None, - is_exit_fn=None + self, pet_data: dict, pet_name: Optional[str] = None, is_exit_fn=None ) -> Optional[dict]: """Resolve a pet, asking the user if ambiguous (async). @@ -168,18 +158,16 @@ async def resolve_pet_async( if pet_name: name_lower = pet_name.lower().strip() - # Exact match for p in pets: if p["name"].lower() == name_lower: return p - # Fuzzy match + # 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 - # 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() diff --git a/community/pet-care-assistant/tests/conftest.py b/community/pet-care-assistant/tests/conftest.py index 18f1ad6f..768c50b2 100644 --- a/community/pet-care-assistant/tests/conftest.py +++ b/community/pet-care-assistant/tests/conftest.py @@ -1,9 +1,10 @@ """Shared test fixtures for Pet Care Assistant tests.""" -import pytest -import sys import os -from unittest.mock import MagicMock, AsyncMock +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest # Mock the OpenHome src modules before importing main @@ -12,9 +13,11 @@ 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.MatchingCapability = type( + "MatchingCapability", + (), + {"__init__": lambda self, unique_name="", matching_hotwords=None: None}, + ) mock_capability_worker = MagicMock() mock_capability_worker.CapabilityWorker = MagicMock @@ -23,11 +26,11 @@ def mock_src_modules(): 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 + 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__)))) @@ -68,15 +71,14 @@ def mock_capability_worker(): @pytest.fixture def capability(mock_worker, mock_capability_worker): """Create a PetCareAssistantCapability instance with mocked dependencies.""" - from main import PetCareAssistantCapability - from pet_data_service import PetDataService from activity_log_service import ActivityLogService from external_api_service import ExternalAPIService from llm_service import LLMService + from main import PetCareAssistantCapability + from pet_data_service import PetDataService cap = PetCareAssistantCapability( - unique_name="test_pet_care", - matching_hotwords=["pet care", "my pets"] + unique_name="test_pet_care", matching_hotwords=["pet care", "my pets"] ) cap.worker = mock_worker cap.capability_worker = mock_capability_worker @@ -87,7 +89,9 @@ def capability(mock_worker, mock_capability_worker): # 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.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 index 75469016..2a7690e6 100644 --- a/community/pet-care-assistant/tests/test_api_integration.py +++ b/community/pet-care-assistant/tests/test_api_integration.py @@ -13,11 +13,12 @@ - Edge cases (empty results, malformed JSON) """ +import asyncio import json +from unittest.mock import AsyncMock, MagicMock, patch + import pytest import responses -from unittest.mock import AsyncMock, MagicMock, patch -import asyncio class TestEmergencyVetAPI: @@ -38,7 +39,7 @@ def capability_with_location(self, capability): @responses.activate @patch("main.SERPER_API_KEY", "test_api_key_123") async def test_emergency_vet_success(self, capability_with_location): - """Should successfully find emergency vets with valid response.""" + """Should list vet names first, then give details after user picks one.""" # Mock successful Serper Maps response responses.add( responses.POST, @@ -50,28 +51,36 @@ async def test_emergency_vet_success(self, capability_with_location): "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, ) - # Call the method + # 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() - # Verify speak was called with vet info 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 "4.5" in speak_text @pytest.mark.asyncio @responses.activate @@ -238,7 +247,9 @@ async def test_weather_invalid_json(self, capability_with_pet_and_location): @pytest.mark.asyncio @responses.activate - async def test_weather_missing_current_field(self, capability_with_pet_and_location): + async def test_weather_missing_current_field( + self, capability_with_pet_and_location + ): """Should handle response missing required 'current' field.""" responses.add( responses.GET, @@ -359,7 +370,9 @@ async def test_food_recall_no_results(self, capability_with_pets): 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()) + assert "no" in speak_text.lower() and ( + "alert" in speak_text.lower() or "clear" in speak_text.lower() + ) class TestGeolocationAPIs: @@ -446,9 +459,7 @@ async def test_geocoding_success(self, capability): @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} - } + 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") diff --git a/community/pet-care-assistant/tests/test_exit_detection.py b/community/pet-care-assistant/tests/test_exit_detection.py index 423d0b52..554cb24a 100644 --- a/community/pet-care-assistant/tests/test_exit_detection.py +++ b/community/pet-care-assistant/tests/test_exit_detection.py @@ -8,7 +8,8 @@ """ import pytest -from hypothesis import given, strategies as st +from hypothesis import given +from hypothesis import strategies as st class TestCleanInput: @@ -269,12 +270,16 @@ def test_llm_says_no(self, capability): 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" + 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") + 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): @@ -293,9 +298,10 @@ class TestIsExitPropertyBased: @given(st.text()) def test_never_crashes(self, text): """Should never crash regardless of input.""" + from unittest.mock import MagicMock + from llm_service import LLMService from main import PetCareAssistantCapability - from unittest.mock import MagicMock # Create capability with mocked dependencies inside test cap = PetCareAssistantCapability(unique_name="test", matching_hotwords=[]) @@ -323,9 +329,10 @@ def test_clean_input_returns_string(self, text): @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 llm_service import LLMService from main import PetCareAssistantCapability - from unittest.mock import MagicMock # Create capability with mocked dependencies inside test cap = PetCareAssistantCapability(unique_name="test", matching_hotwords=[]) @@ -374,4 +381,6 @@ def test_false_positive_prevention(self, capability): "Can I walk Luna today?", ] for query in queries: - assert capability.llm_service.is_exit(query) is False, f"False positive on: {query}" + assert ( + capability.llm_service.is_exit(query) is False + ), f"False positive on: {query}" diff --git a/community/pet-care-assistant/tests/test_llm_extraction.py b/community/pet-care-assistant/tests/test_llm_extraction.py index d6f8d45a..b0a17c54 100644 --- a/community/pet-care-assistant/tests/test_llm_extraction.py +++ b/community/pet-care-assistant/tests/test_llm_extraction.py @@ -15,9 +15,10 @@ Uses mocked LLM responses to test extraction logic and edge cases. """ -import pytest from datetime import datetime +import pytest + class TestExtractPetName: """Tests for _extract_pet_name_async.""" @@ -34,9 +35,13 @@ async def test_simple_name(self, capability): @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" + 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") + result = await capability.llm_service.extract_pet_name_async( + "She's called Princess Luna" + ) assert result == "Princess Luna" @@ -54,7 +59,9 @@ async def test_name_with_extra_words(self, capability): @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") + capability.capability_worker.text_to_text_response.side_effect = Exception( + "LLM error" + ) result = await capability.llm_service.extract_pet_name_async("") @@ -63,7 +70,9 @@ async def test_empty_input(self, capability): @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") + capability.capability_worker.text_to_text_response.side_effect = Exception( + "LLM error" + ) result = await capability.llm_service.extract_pet_name_async("Max") @@ -78,7 +87,9 @@ 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") + result = await capability.llm_service.extract_species_async( + "He's a golden retriever" + ) assert result == "dog" @@ -87,7 +98,9 @@ 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") + result = await capability.llm_service.extract_species_async( + "She's a Maine Coon cat" + ) assert result == "cat" @@ -96,7 +109,9 @@ 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") + result = await capability.llm_service.extract_species_async( + "A Holland Lop rabbit" + ) assert result == "rabbit" @@ -105,7 +120,9 @@ 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") + result = await capability.llm_service.extract_species_async( + "German Shepherd dog" + ) assert result == "dog" @@ -114,7 +131,9 @@ async def test_unclear_species(self, capability): """Should handle unclear species input.""" capability.capability_worker.text_to_text_response.return_value = "dog" - result = await capability.llm_service.extract_species_async("Just a regular pet") + result = await capability.llm_service.extract_species_async( + "Just a regular pet" + ) assert result == "dog" @@ -125,7 +144,9 @@ class TestExtractBreed: @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" + capability.capability_worker.text_to_text_response.return_value = ( + "golden retriever" + ) result = await capability.llm_service.extract_breed_async("Golden Retriever") @@ -145,14 +166,18 @@ 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") + 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" + capability.capability_worker.text_to_text_response.return_value = ( + "French Bulldog" + ) result = await capability.llm_service.extract_breed_async("French-Bulldog") @@ -167,7 +192,9 @@ 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") + result = await capability.llm_service.extract_birthday_async( + "Born on June 15, 2020" + ) assert result == "2020-06-15" @@ -177,7 +204,9 @@ async def test_age_calculation(self, capability): # 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" + 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") @@ -197,7 +226,9 @@ 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") + result = await capability.llm_service.extract_birthday_async( + "Just got him last month" + ) assert "2025" in result @@ -217,7 +248,9 @@ async def test_weight_in_pounds(self, capability): @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 + capability.capability_worker.text_to_text_response.return_value = ( + "55" # 25 kg ≈ 55 lbs + ) result = await capability.llm_service.extract_weight_async("25 kilograms") @@ -259,27 +292,33 @@ 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") + 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"]' + capability.capability_worker.text_to_text_response.return_value = ( + '["chicken", "grain"]' + ) - result = await capability.llm_service.extract_allergies_async("Allergic to chicken and 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 = '[]' + capability.capability_worker.text_to_text_response.return_value = "[]" result = await capability.llm_service.extract_allergies_async("No allergies") - assert result == '[]' + assert result == "[]" @pytest.mark.asyncio async def test_allergy_with_description(self, capability): @@ -303,7 +342,9 @@ async def test_single_medication(self, capability): '[{"name": "Heartgard", "frequency": "monthly"}]' ) - result = await capability.llm_service.extract_medications_async("Takes Heartgard monthly") + result = await capability.llm_service.extract_medications_async( + "Takes Heartgard monthly" + ) assert result == '[{"name": "Heartgard", "frequency": "monthly"}]' @@ -325,11 +366,13 @@ async def test_multiple_medications(self, capability): @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 = '[]' + capability.capability_worker.text_to_text_response.return_value = "[]" - result = await capability.llm_service.extract_medications_async("Not on any medications") + result = await capability.llm_service.extract_medications_async( + "Not on any medications" + ) - assert result == '[]' + assert result == "[]" @pytest.mark.asyncio async def test_medication_without_frequency(self, capability): @@ -338,7 +381,9 @@ async def test_medication_without_frequency(self, capability): '[{"name": "Prednisone", "frequency": "as needed"}]' ) - result = await capability.llm_service.extract_medications_async("Takes Prednisone sometimes") + result = await capability.llm_service.extract_medications_async( + "Takes Prednisone sometimes" + ) assert "Prednisone" in result @@ -351,7 +396,9 @@ 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") + result = await capability.llm_service.extract_vet_name_async( + "Dr. Smith at Austin Vet" + ) assert result == "Dr. Smith" @@ -367,9 +414,13 @@ async def test_vet_without_title(self, capability): @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" + capability.capability_worker.text_to_text_response.return_value = ( + "Austin Veterinary Clinic" + ) - result = await capability.llm_service.extract_vet_name_async("Austin Veterinary Clinic") + result = await capability.llm_service.extract_vet_name_async( + "Austin Veterinary Clinic" + ) assert result == "Austin Veterinary Clinic" @@ -393,7 +444,9 @@ 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") + result = await capability.llm_service.extract_phone_number_async( + "(512) 555-1234" + ) assert result == "5125551234" @@ -420,7 +473,9 @@ 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") + result = await capability.llm_service.extract_phone_number_async( + "+1 512-555-1234" + ) assert result == "15125551234" @@ -431,9 +486,13 @@ class TestExtractLocation: @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" + capability.capability_worker.text_to_text_response.return_value = ( + "Austin, Texas" + ) - result = await capability.llm_service.extract_location_async("I live in Austin, Texas") + result = await capability.llm_service.extract_location_async( + "I live in Austin, Texas" + ) assert result == "Austin, Texas" @@ -451,14 +510,18 @@ 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") + 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" + 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" @@ -473,7 +536,9 @@ class TestExtractionEdgeCases: @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") + capability.capability_worker.text_to_text_response.side_effect = Exception( + "LLM error" + ) result = await capability.llm_service.extract_pet_name_async(None) @@ -494,7 +559,9 @@ 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!!!") + result = await capability.llm_service.extract_pet_name_async( + "His name is Mr. Whiskers!!!" + ) assert result == "Mr. Whiskers" @@ -503,7 +570,9 @@ 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 🐱") + result = await capability.llm_service.extract_pet_name_async( + "Her name is Amélie 🐱" + ) assert result == "Amélie" From 02296f59e953f05e88d8acb0ecd5063cfe9e5280 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Fri, 20 Feb 2026 15:49:56 +0200 Subject: [PATCH 08/19] fix: correct reset_all classification and add pet inventory lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live-session bugs fixed: 1. "start over" / "delete everything" was classified as clear_log instead of reset_all. Root cause: the LLM prompt had no explicit distinction between the two actions. Fixed by: - Clarifying clear_log scope: "ONLY removes activity history, pets stay" - Clarifying reset_all scope: "deletes ALL data: pets + logs + reminders" - Adding IMPORTANT rule: 'delete everything' and 'start over' always mean reset_all, not clear_log - Adding 4 extra example JSON outputs showing clear_log vs reset_all 2. "do you have any pets/animals" was classified as unknown. Fixed by: - Adding 'what pets', 'any animals', 'list pets' → lookup rule in prompt - Adding 2 example JSON outputs for pet inventory queries - Adding early-return path in _handle_lookup that reads directly from pet_data (no activity log needed) and speaks the pet list --- community/pet-care-assistant/llm_service.py | 11 +++++++-- community/pet-care-assistant/main.py | 27 ++++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/community/pet-care-assistant/llm_service.py b/community/pet-care-assistant/llm_service.py index 4ef4aec4..4bd9bf1e 100644 --- a/community/pet-care-assistant/llm_service.py +++ b/community/pet-care-assistant/llm_service.py @@ -74,8 +74,10 @@ "- '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" - "- 'start over', 'reset everything', 'delete everything', 'wipe all data', 'fresh start' => edit_pet with action reset_all\n" + "- 'clear log', 'clear activity log', 'delete all logs', 'clear history' => edit_pet with action clear_log (ONLY removes activity history, pets stay)\n" + "- 'start over', 'reset everything', 'delete everything', 'wipe all data', 'fresh start', 'wipe everything', 'clean slate', 'start from scratch', 'erase everything' => edit_pet with action reset_all (deletes ALL data: pets + logs + reminders)\n" + "- IMPORTANT: 'delete everything' and 'start over' always mean reset_all, NOT clear_log\n" + "- 'what pets', 'do I have any pets', 'any animals', 'list my pets', 'how many pets', 'what animals do I have' => 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" @@ -94,7 +96,12 @@ '"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' + '"Clear activity log" -> {{"mode": "edit_pet", "action": "clear_log", "pet_name": null, "details": "clear logs"}}\n' '"Start over" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' + '"Delete everything" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' + '"Wipe all data" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' + '"What pets do I have?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' + '"Do I have any animals?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' '"Remind me to feed Luna in 2 hours" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "feeding", "time_description": "in 2 hours"}}\n' '"What reminders do I have?" -> {{"mode": "reminder", "action": "list", "pet_name": null, "activity": null, "time_description": null}}\n' ) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 142070a0..f8f8cfc6 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -565,9 +565,34 @@ async def _handle_log(self, intent: dict): 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", "") + # 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")) + if pet: relevant_logs = [ e for e in self.activity_log if e.get("pet_id") == pet["id"] From 0a97661588324c57c1c35ea4fb6108c8c8da47d6 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sat, 21 Feb 2026 06:07:16 +0200 Subject: [PATCH 09/19] feat: add day-of-week reminders, friendly onboarding, LLM vet matching, and inline query improvements New features: 1. Day-of-week reminder parsing - _parse_reminder_time now supports 'next Monday', 'on Friday', 'this Wednesday', and bare day names with optional time - Added _WEEKDAY_MAP class attribute and _parse_hm() static helper - 21 new tests in test_reminder_time.py 2. Friendly conversational onboarding - All prompts rewritten to natural voice (Nice name!, Got it!, Almost done!) - Added age/birthday follow-up step between breed and weight - Vet and location steps skip automatically for second and later pets - add_pet flow now collects all details identically to onboarding 3. Species hallucination guard - Added _VALID_SPECIES set for post-extraction validation - Hallucinated species reset to 'unknown' to trigger follow-up questions - Name and species steps now use _ask_onboarding_step (hard-exit + inline query detection) instead of raw run_io_loop 4. Inline query improvements - Expanded Tier 2 in _answer_inline_query to handle weather, emergency_vet, reminder, and food_recall modes inline during onboarding - Added _is_pet_care_related() LLM yes/no check for 'unknown' mode inputs - Extended inventory keyword patterns to catch more STT variants - Added test_inline_query.py with 9 tests covering all handled modes 5. Pending intent handling after onboarding - Commands embedded in 'no more pets' responses (e.g. 'No, is it safe to walk Luna?') are stashed in _pending_intent_text and routed after onboarding completes instead of being silently dropped 6. LLM vet name matching - Replaced simple word-set scoring with 3-tier scoring: exact words (x3), substring containment (x2), compact title (x2) - Added _llm_pick_vet() fallback when all tiers score 0 7. Future-tense reminder classification - Added CRITICAL INSTRUCTION #6 to CLASSIFY_PROMPT distinguishing past events (log) from future plans (reminder) - Added 5 new examples including future vet visits 8. Comment cleanup - Removed redundant, obvious, and developer-note comments throughout main.py - Tightened multi-line explanations to concise single-line forms 9. Code formatting - Applied isort and black across all source files and tests All 220 tests pass. --- community/pet-care-assistant/llm_service.py | 97 +- community/pet-care-assistant/main.py | 1272 ++++++++++++++--- .../pet-care-assistant/pet_data_service.py | 35 +- .../pet-care-assistant/tests/conftest.py | 1 + .../tests/test_api_integration.py | 2 +- .../tests/test_exit_detection.py | 111 ++ .../tests/test_inline_query.py | 323 +++++ .../tests/test_llm_extraction.py | 26 +- .../tests/test_reminder_time.py | 183 +++ 9 files changed, 1796 insertions(+), 254 deletions(-) create mode 100644 community/pet-care-assistant/tests/test_inline_query.py create mode 100644 community/pet-care-assistant/tests/test_reminder_time.py diff --git a/community/pet-care-assistant/llm_service.py b/community/pet-care-assistant/llm_service.py index 4bd9bf1e..4d9a5e82 100644 --- a/community/pet-care-assistant/llm_service.py +++ b/community/pet-care-assistant/llm_service.py @@ -47,9 +47,27 @@ 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 (next, tomorrow, this weekend, " + "on Monday, at 5PM next week) 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" @@ -59,6 +77,7 @@ '- {{"mode": "food_recall"}}\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" @@ -68,23 +87,24 @@ "- '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', 'clear history' => edit_pet with action clear_log (ONLY removes activity history, pets stay)\n" + "- 'clear log', 'clear activity log', 'delete all logs', 'clear history' => edit_pet with action clear_log (ONLY removes activity history, pets are kept)\n" "- 'start over', 'reset everything', 'delete everything', 'wipe all data', 'fresh start', 'wipe everything', 'clean slate', 'start from scratch', 'erase everything' => edit_pet with action reset_all (deletes ALL data: pets + logs + reminders)\n" "- IMPORTANT: 'delete everything' and 'start over' always mean reset_all, NOT clear_log\n" - "- 'what pets', 'do I have any pets', 'any animals', 'list my pets', 'how many pets', 'what animals do I have' => lookup with query 'list registered pets'\n" + "- 'what pets', 'do I have any pets', 'any animals', 'list my pets', 'how many pets', 'what animals do I have', 'do you have any' => lookup with query 'list registered pets'\n" + "- 'tell me about Luna', 'give me Luna info', 'Luna profile', 'Luna registered info', 'Luna details', 'Luna stats', 'what do you have on Luna' => lookup with pet_name and query containing 'profile info'\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', 'dog 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' @@ -99,11 +119,22 @@ '"Clear activity log" -> {{"mode": "edit_pet", "action": "clear_log", "pet_name": null, "details": "clear logs"}}\n' '"Start over" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' '"Delete everything" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' + '"I wanna start over" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' '"Wipe all data" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' '"What pets do I have?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' '"Do I have any animals?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' + '"Do you have any animal?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' + '"pet care" -> {{"mode": "greeting"}}\n' + '"dog dog care" -> {{"mode": "greeting"}}\n' '"Remind me to feed Luna in 2 hours" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "feeding", "time_description": "in 2 hours"}}\n' '"What reminders do I have?" -> {{"mode": "reminder", "action": "list", "pet_name": null, "activity": null, "time_description": null}}\n' + '"Animal animal, Give me loaner information" -> {{"mode": "lookup", "pet_name": null, "query": "owner information"}}\n' + '"Dog dog, tell me about food" -> {{"mode": "lookup", "pet_name": null, "query": "food information"}}\n' + '"I wanna go to the doctor with Luna next Monday" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "vet_visit", "time_description": "next Monday"}}\n' + '"I need to take Max to the vet on Friday at 3PM" -> {{"mode": "reminder", "action": "set", "pet_name": "Max", "activity": "vet_visit", "time_description": "Friday at 3PM"}}\n' + '"Luna has a vet appointment tomorrow at 10AM" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "vet_visit", "time_description": "tomorrow at 10AM"}}\n' + '"Set a reminder to walk Luna next Monday at 5PM" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "walk", "time_description": "next Monday at 5PM"}}\n' + '"Went to the vet with Luna today" -> {{"mode": "log", "pet_name": "Luna", "activity_type": "vet_visit", "details": "vet visit today", "value": null}}\n' ) @@ -217,11 +248,14 @@ async def extract_species_async(self, raw_input: str) -> str: raw_input: Raw user response (e.g., "She's a golden retriever") Returns: - Species as single word (e.g., "dog") + Species as single word (e.g., "dog"), or "unknown" if not mentioned """ return await self.extract_value_async( raw_input, - "Extract the animal species. Return one word: dog, cat, bird, rabbit, etc.", + "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: str) -> str: @@ -231,11 +265,14 @@ async def extract_breed_async(self, raw_input: str) -> str: raw_input: Raw user response (e.g., "golden retriever mix") Returns: - Breed name or "mixed" (e.g., "golden retriever") + Breed name or "mixed" (e.g., "golden retriever"), or "unknown" if not mentioned """ return await self.extract_value_async( raw_input, - "Extract the breed name. If they don't know or say mixed, return 'mixed'.", + "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: str) -> str: @@ -365,6 +402,40 @@ def clean_input(text: str) -> str: cleaned = re.sub(r"[^\w\s']", "", cleaned) return cleaned.strip() + def is_hard_exit(self, text: str) -> bool: + """Exit detection for mid-question contexts (Tier 1 + 2 only). + + Use this instead of is_exit() when 'no', 'done', 'thanks', etc. are + valid answers to the current question (e.g. onboarding). Only matches + explicit abort/reset commands; ignores EXIT_RESPONSES like 'no'. + """ + 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/restart phrases (user wants to start over during onboarding) + 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(self, text: str) -> bool: """Hybrid exit detection: force-exit → command match → response match. diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index f8f8cfc6..903d624a 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -82,6 +82,18 @@ ) +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. @@ -137,6 +149,8 @@ class PetCareAssistantCapability(MatchingCapability): 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 @classmethod def register_capability(cls) -> "MatchingCapability": @@ -154,6 +168,30 @@ def call(self, worker: AgentWorker): self.capability_worker = CapabilityWorker(self.worker) self.worker.session_tasks.create(self.run()) + 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): @@ -165,7 +203,6 @@ async def run(self): self.external_api_service = ExternalAPIService(self.worker, SERPER_API_KEY) self.pet_data = await self.pet_data_service.load_json(PETS_FILE, default={}) - # LLMService needs pet_data for intent classification context self.llm_service = LLMService( self.capability_worker, self.worker, self.pet_data ) @@ -177,18 +214,43 @@ async def run(self): ) self._geocode_cache = {} + self._corrected_name = None await self._check_due_reminders() + trigger = self.llm_service.get_trigger_context() + 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 + 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." + ) - trigger = self.llm_service.get_trigger_context() - if trigger: + elif trigger: intent = await self.llm_service.classify_intent_async(trigger) mode = intent.get("mode", "unknown") @@ -205,15 +267,43 @@ async def run(self): await self.capability_worker.speak(EXIT_MESSAGE) return - 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() @@ -238,28 +328,68 @@ async def run(self): idle_count = 0 - if self.llm_service.is_exit(user_input): - await self.capability_worker.speak(EXIT_MESSAGE) - break - - # Short ambiguous input — ask LLM if it's an exit cleaned = self.llm_service.clean_input(user_input) - if len( - cleaned.split() - ) <= 4 and await self.llm_service.is_exit_llm_async(cleaned): - await self.capability_worker.speak(EXIT_MESSAGE) - break - intent = await self.llm_service.classify_intent_async(user_input) - mode = intent.get("mode", "unknown") + # 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 - if mode == "exit": + 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 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) + await self.capability_worker.speak("What else can I help with?") + else: + await self.capability_worker.speak(EXIT_MESSAGE) + except Exception as e: self.worker.editor_logging_handler.error(f"[PetCare] Unexpected error: {e}") await self.capability_worker.speak( @@ -289,6 +419,11 @@ async def _route_intent(self, intent: dict): 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: @@ -298,17 +433,24 @@ async def _route_intent(self, intent: dict): # === 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 @@ -319,189 +461,661 @@ async def run_onboarding(self): 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." ) 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.llm_service.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 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. + async def _ask_onboarding_step(self, prompt: str) -> str | None: + """Ask an onboarding question, handling hard-exit and inline pet queries. - Uses parallel LLM extraction for ~90% performance improvement (24-36s → 3-4s). - Phase 1: Collect all raw user inputs - Phase 2: Extract all values in parallel with asyncio.gather() - Phase 3: Process results and build pet dict - """ - # ======= PHASE 1: Collect raw user inputs ======= - raw_inputs = {} + 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). - name_input = await self.capability_worker.user_response() - if not name_input or self.llm_service.is_exit(name_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 - raw_inputs["name"] = name_input + 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. - temp_name = name_input.strip().split()[0] if name_input.strip() else "your pet" - species_input = await self.capability_worker.run_io_loop( - f"Great! What kind of animal is {temp_name}? Dog, cat, or something else?" + 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 not species_input or self.llm_service.is_exit(species_input): - return None - raw_inputs["species"] = species_input + if len(words) < 4 or not has_signal: + return False - breed_input = await self.capability_worker.run_io_loop( - f"What breed is {temp_name}?" - ) - if not breed_input or self.llm_service.is_exit(breed_input): - return None - raw_inputs["breed"] = breed_input + try: + intent = await self.llm_service.classify_intent_async(response) + mode = intent.get("mode", "unknown") + + # 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 - age_input = await self.capability_worker.run_io_loop( - f"How old is {temp_name}, or do you know their birthday?" - ) - if not age_input or self.llm_service.is_exit(age_input): - return None - raw_inputs["age"] = age_input + # 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 - weight_input = await self.capability_worker.run_io_loop( - f"Roughly how much does {temp_name} weigh?" - ) - if not weight_input or self.llm_service.is_exit(weight_input): - return None - raw_inputs["weight"] = weight_input + # 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 - allergy_input = await self.capability_worker.run_io_loop( - f"Does {temp_name} have any allergies I should know about?" - ) - if not allergy_input or self.llm_service.is_exit(allergy_input): - return None - raw_inputs["allergies"] = allergy_input + # Genuinely off-topic — not a question for this assistant + return False - med_input = await self.capability_worker.run_io_loop( - f"Is {temp_name} on any medications?" - ) - if not med_input or self.llm_service.is_exit(med_input): - return None - raw_inputs["medications"] = med_input + except Exception as e: + self.worker.editor_logging_handler.warning( + f"[PetCare] Inline query classification failed: {e}" + ) - vet_input = await self.capability_worker.run_io_loop( - "Do you have a regular vet? If so, what's their name?" - ) - raw_inputs["vet"] = None - raw_inputs["vet_phone"] = None - if vet_input and not self.llm_service.is_exit(vet_input): - cleaned = vet_input.lower().strip() - if not any(w in cleaned for w in ["no", "nope", "skip", "don't have"]): - raw_inputs["vet"] = vet_input - phone_input = await self.capability_worker.run_io_loop( - "What's their phone number?" - ) - if phone_input and not self.llm_service.is_exit(phone_input): - raw_inputs["vet_phone"] = phone_input + return False - 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." - ) - raw_inputs["location"] = None - if location_input and not self.llm_service.is_exit(location_input): - raw_inputs["location"] = location_input - - # ======= PHASE 2: Extract all values in parallel ======= - extraction_tasks = [ - self.llm_service.extract_pet_name_async(raw_inputs["name"]), - self.llm_service.extract_species_async(raw_inputs["species"]), - self.llm_service.extract_breed_async(raw_inputs["breed"]), - self.llm_service.extract_birthday_async(raw_inputs["age"]), - self.llm_service.extract_weight_async(raw_inputs["weight"]), - self.llm_service.extract_allergies_async(raw_inputs["allergies"]), - self.llm_service.extract_medications_async(raw_inputs["medications"]), - ] + async def _is_pet_care_related(self, text: str) -> bool: + """Use LLM to check if a question is related to pet care. - vet_name_idx = None - if raw_inputs["vet"] is not None: - vet_name_idx = len(extraction_tasks) - extraction_tasks.append( - self.llm_service.extract_vet_name_async(raw_inputs["vet"]) + Returns True if the question is about pets, animals, or pet care tasks. + Returns False for completely unrelated questions. + """ + try: + 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 + + 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 - vet_phone_idx = None - if raw_inputs["vet_phone"] is not None: - vet_phone_idx = len(extraction_tasks) - extraction_tasks.append( - self.llm_service.extract_phone_number_async(raw_inputs["vet_phone"]) + # 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", + } - location_idx = None - if raw_inputs["location"] is not None: - location_idx = len(extraction_tasks) - extraction_tasks.append( - self.llm_service.extract_location_async(raw_inputs["location"]) + _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." + ) + 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 = [] - results = await asyncio.gather(*extraction_tasks) + # ── 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 + _is_skip = lambda v: any( + w in v.lower() for w in ["no", "nope", "skip", "don't", "none"] + ) + # Affirmative without a name ("yes", "yeah", "sure") → ask for the name + _is_affirmative = lambda v: 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 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." + ) - # ======= PHASE 3: Build pet dict from extracted results ======= - name = results[0] - species = results[1].lower() - breed = results[2] - birthday = results[3] + # ── 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"] - weight_str = results[4] + # ── Build pet dict ──────────────────────────────────────────────────── try: weight_lbs = float(weight_str) except (ValueError, TypeError): weight_lbs = 0 - allergies_str = results[5] - try: - allergies = json.loads(allergies_str) - if not isinstance(allergies, list): - allergies = [] - except (json.JSONDecodeError, TypeError): - allergies = [] - - meds_str = results[6] - try: - medications = json.loads(meds_str) - if not isinstance(medications, list): - medications = [] - except (json.JSONDecodeError, TypeError): - medications = [] - - # Extract vet info if available - if vet_name_idx is not None: - vet_name = results[vet_name_idx] - self.pet_data["vet_name"] = vet_name - if vet_phone_idx is not None: - vet_phone = results[vet_phone_idx] - self.pet_data["vet_phone"] = vet_phone - - # Extract and geocode location if available - if location_idx is not None: - location = results[location_idx] - 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"] - - 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, @@ -570,7 +1184,13 @@ async def _handle_lookup(self, intent: dict): # 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"] + for w in [ + "what pets", + "any pets", + "any animals", + "list pets", + "how many pets", + ] ): pets = self.pet_data.get("pets", []) if not pets: @@ -593,12 +1213,52 @@ async def _handle_lookup(self, intent: dict): 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] @@ -621,8 +1281,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: @@ -658,8 +1320,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: @@ -705,7 +1369,6 @@ async def _handle_emergency_vet(self): lon = self.pet_data.get("user_lon") if not lat or not lon: - # Allow user to override auto-detected location with saved location saved_location = self.pet_data.get("user_location", "") if saved_location: await self.capability_worker.speak( @@ -823,7 +1486,6 @@ async def _handle_emergency_vet(self): closed_vets = [p for p in places if not p.get("openNow")] top_results = (open_vets + closed_vets)[:3] - # Announce names first — short, interruptible utterances names = [p.get("title", "Unknown") for p in top_results] count = len(top_results) await self.capability_worker.speak( @@ -837,32 +1499,59 @@ async def _handle_emergency_vet(self): return pick_lower = pick.lower() - chosen = next( - ( - p - for p in top_results - if any( - word in pick_lower - for word in p.get("title", "").lower().split() - ) - ), - top_results[0], # default to first if unclear - ) + # 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", + } + + 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", "") - rating = chosen.get("rating", "") is_open = chosen.get("openNow", False) - address = chosen.get("address", "") status = "open now" if is_open else "may be closed" detail = f"{name}, {status}" - if rating: - detail += f", rated {rating}" if phone: - detail += f". Number: {_fmt_phone_for_speech(phone)}" - if address: - detail += f". Address: {address}" + 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: @@ -887,6 +1576,41 @@ async def _handle_emergency_vet(self): "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 === async def _handle_weather(self, intent: dict): @@ -982,8 +1706,10 @@ 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) @@ -1175,7 +1901,6 @@ async def _handle_food_recall(self): tasks.append(self._fetch_fda_events(species)) tasks.append(self._fetch_serper_news(species_set)) - # Execute all API calls concurrently results = await asyncio.gather(*tasks, return_exceptions=True) all_results = [] @@ -1189,8 +1914,7 @@ async def _handle_food_recall(self): if isinstance(last_result, list): news_headlines = last_result elif isinstance(last_result, Exception): - # Already logged in helper method - pass + pass # Logged in _fetch_serper_news if not all_results and not news_headlines: await self.capability_worker.speak( @@ -1218,7 +1942,10 @@ async def _handle_food_recall(self): ) 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 @@ -1236,14 +1963,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.") @@ -1320,8 +2047,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", []): @@ -1382,9 +2110,22 @@ async def _handle_edit_pet(self, intent: dict): self.pet_data = {} self.activity_log = [] self.reminders = [] - await self._save_json(PETS_FILE, self.pet_data) - await self._save_json(ACTIVITY_LOG_FILE, self.activity_log) - await self._save_json(REMINDERS_FILE, 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." ) @@ -1400,11 +2141,21 @@ async def _handle_edit_pet(self, intent: dict): # === 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', - 'every day at HH:MM' (returns next occurrence). + 'next Monday at 5PM', 'on Friday', 'this Wednesday'. Returns None if unparseable. """ if not time_description: @@ -1412,6 +2163,7 @@ def _parse_reminder_time(self, time_description: str) -> datetime | 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))) @@ -1422,33 +2174,82 @@ def _parse_reminder_time(self, time_description: str) -> datetime | None: m = re.search(r"tomorrow.*?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", text) if m: - hour = int(m.group(1)) - minute = int(m.group(2)) if m.group(2) else 0 - meridiem = m.group(3) - if meridiem == "pm" and hour < 12: - hour += 12 - elif meridiem == "am" and hour == 12: - hour = 0 + 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 + ) + + 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 + ) + m = re.search(r"at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?", text) if m: - hour = int(m.group(1)) - minute = int(m.group(2)) if m.group(2) else 0 - meridiem = m.group(3) - if meridiem == "pm" and hour < 12: - hour += 12 - elif meridiem == "am" and hour == 12: - hour = 0 + hour, minute = self._parse_hm(m) candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) - # If time already passed today, schedule for tomorrow if candidate <= now: candidate += timedelta(days=1) return candidate 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: @@ -1465,7 +2266,6 @@ async def _check_due_reminders(self): await self.capability_worker.speak( r.get("message", "You have a pet reminder due.") ) - # Remove fired reminders self.reminders = [r for r in self.reminders if r not in due] await self._save_json(REMINDERS_FILE, self.reminders) @@ -1528,14 +2328,14 @@ async def _handle_reminder(self, intent: dict): if not time_description: await self.capability_worker.speak( - "When should I remind you? Say something like 'in 2 hours' or 'at 6 PM'." + "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 "" due_at = self._parse_reminder_time(time_description) if not due_at: await self.capability_worker.speak( - "I couldn't understand that time. Try saying 'in 2 hours' or 'at 6 PM'." + "I couldn't understand that time. Try 'in 2 hours', 'at 6 PM', or 'next Monday at 5 PM'." ) return @@ -1626,8 +2426,10 @@ async def _geocode_location(self, location_str: str) -> dict: try: url = "https://geocoding-api.open-meteo.com/v1/search" + # 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": location_str, "count": 1}, timeout=10 + requests.get, url, params={"name": city_only, "count": 1}, timeout=10 ) if resp.status_code == 200: data = resp.json() diff --git a/community/pet-care-assistant/pet_data_service.py b/community/pet-care-assistant/pet_data_service.py index 2fd1c073..5e340ecb 100644 --- a/community/pet-care-assistant/pet_data_service.py +++ b/community/pet-care-assistant/pet_data_service.py @@ -27,6 +27,9 @@ def __init__(self, capability_worker, worker): async def load_json(self, filename: str, default=None): """Load a JSON file, returning default if not found or corrupt. + If the main file is corrupt, attempts to recover from the backup + file created by save_json. Only resets if both are unusable. + Args: filename: Name of file to load default: Default value if file doesn't exist or is corrupt @@ -34,15 +37,42 @@ async def load_json(self, filename: str, default=None): Returns: Loaded JSON data or default value """ + 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(): + # File exists but is empty — treat as absent (not corrupt) + 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}, resetting." + f"[PetCare] Corrupt file {filename}, trying backup." ) await self.capability_worker.delete_file(filename, False) + + # Main file missing or corrupt — try backup recovery + 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." + ) + # Restore the main file 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: str, data): @@ -75,6 +105,9 @@ async def save_json(self, filename: str, data): self.worker.editor_logging_handler.info( f"[PetCare] Created backup: {backup_filename}" ) + # Delete original so write_file always creates a fresh file + # Ensure a clean file write by removing the existing file. + await self.capability_worker.delete_file(filename, False) await self.capability_worker.write_file(filename, json.dumps(data), False) diff --git a/community/pet-care-assistant/tests/conftest.py b/community/pet-care-assistant/tests/conftest.py index 768c50b2..0450771a 100644 --- a/community/pet-care-assistant/tests/conftest.py +++ b/community/pet-care-assistant/tests/conftest.py @@ -85,6 +85,7 @@ def capability(mock_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) diff --git a/community/pet-care-assistant/tests/test_api_integration.py b/community/pet-care-assistant/tests/test_api_integration.py index 2a7690e6..eaf7f5fc 100644 --- a/community/pet-care-assistant/tests/test_api_integration.py +++ b/community/pet-care-assistant/tests/test_api_integration.py @@ -80,7 +80,7 @@ async def test_emergency_vet_success(self, capability_with_location): assert "BluePearl Pet Hospital" in speak_text # Details for chosen vet spoken after pick assert "open now" in speak_text - assert "4.5" in speak_text + assert "5, 1, 2" in speak_text # phone number spoken @pytest.mark.asyncio @responses.activate diff --git a/community/pet-care-assistant/tests/test_exit_detection.py b/community/pet-care-assistant/tests/test_exit_detection.py index 554cb24a..058ae4be 100644 --- a/community/pet-care-assistant/tests/test_exit_detection.py +++ b/community/pet-care-assistant/tests/test_exit_detection.py @@ -348,6 +348,117 @@ def test_exit_detection_deterministic(self, 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.""" 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 00000000..28da19e3 --- /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 index b0a17c54..e463d644 100644 --- a/community/pet-care-assistant/tests/test_llm_extraction.py +++ b/community/pet-care-assistant/tests/test_llm_extraction.py @@ -127,15 +127,33 @@ async def test_species_with_breed(self, capability): assert result == "dog" @pytest.mark.asyncio - async def test_unclear_species(self, capability): - """Should handle unclear species input.""" - capability.capability_worker.text_to_text_response.return_value = "dog" + 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 == "dog" + 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: 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 00000000..6860d6f0 --- /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 From d55c00a0e60d47fa4245216a372a00016a8bc461 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 04:15:56 +0000 Subject: [PATCH 10/19] style: auto-format Python files with autoflake + autopep8 --- community/pet-care-assistant/activity_log_service.py | 2 +- community/pet-care-assistant/main.py | 8 +++++--- .../pet-care-assistant/tests/test_api_integration.py | 2 -- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/community/pet-care-assistant/activity_log_service.py b/community/pet-care-assistant/activity_log_service.py index fdcb893b..6b25af2d 100644 --- a/community/pet-care-assistant/activity_log_service.py +++ b/community/pet-care-assistant/activity_log_service.py @@ -56,7 +56,7 @@ def add_activity( if len(activity_log) > self.max_log_entries: removed = len(activity_log) - self.max_log_entries - activity_log = 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." ) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 903d624a..5c243ac1 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -125,7 +125,7 @@ def _fmt_phone_for_speech(phone: str) -> str: f"{', '.join(digits[7:])}" ) elif 7 <= len(digits) <= 15: - groups = [digits[i : i + 3] for i in range(0, len(digits), 3)] + 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" @@ -1029,11 +1029,13 @@ def _bad_name(v): ) if vet_input is None: return None # User wants to abort/restart - _is_skip = lambda v: any( + + def _is_skip(v): return any( w in v.lower() for w in ["no", "nope", "skip", "don't", "none"] ) # Affirmative without a name ("yes", "yeah", "sure") → ask for the name - _is_affirmative = lambda v: v.lower().strip().rstrip(".!?") in { + + def _is_affirmative(v): return v.lower().strip().rstrip(".!?") in { "yes", "yeah", "yep", diff --git a/community/pet-care-assistant/tests/test_api_integration.py b/community/pet-care-assistant/tests/test_api_integration.py index eaf7f5fc..94a1a330 100644 --- a/community/pet-care-assistant/tests/test_api_integration.py +++ b/community/pet-care-assistant/tests/test_api_integration.py @@ -13,8 +13,6 @@ - Edge cases (empty results, malformed JSON) """ -import asyncio -import json from unittest.mock import AsyncMock, MagicMock, patch import pytest From 6133433feb585443a34fb925802c4406495a8b86 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sat, 21 Feb 2026 06:17:20 +0200 Subject: [PATCH 11/19] fix: empty tests/__init__.py to satisfy linter requirement --- community/pet-care-assistant/tests/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/community/pet-care-assistant/tests/__init__.py b/community/pet-care-assistant/tests/__init__.py index 92d380e0..e69de29b 100644 --- a/community/pet-care-assistant/tests/__init__.py +++ b/community/pet-care-assistant/tests/__init__.py @@ -1 +0,0 @@ -# Pet Care Assistant - Test Suite From a98a74e1093ca9019abf3938bd9fc57154571203 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sat, 21 Feb 2026 06:22:20 +0200 Subject: [PATCH 12/19] fix: inline config values and add register capability tag - Replace raw open(config.json) in register_capability() with inlined unique_name and matching_hotwords values to satisfy the OpenHome validator's no-raw-open() rule - Add #{{register capability}} tag inside the class body - Remove now-unused os import --- community/pet-care-assistant/main.py | 51 ++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 5c243ac1..2ae6d270 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -1,6 +1,5 @@ import asyncio import json -import os import re import uuid from datetime import datetime, timedelta @@ -137,6 +136,7 @@ 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 @@ -154,13 +154,50 @@ class PetCareAssistantCapability(MatchingCapability): @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"], + unique_name="pet_care_assistant", + matching_hotwords=[ + "pet care", + "pet assistant", + "pet tracker", + "my pets", + "pet log", + "I fed", + "I just fed", + "gave medication", + "got her medicine", + "got his medicine", + "we walked", + "went for a walk", + "weighs", + "log pet activity", + "when did I last feed", + "has had", + "how many walks", + "last vet visit", + "check on", + "emergency vet", + "find a vet", + "vet near me", + "I need a vet", + "is it safe outside", + "pet weather", + "can I walk", + "too hot for", + "too cold for", + "pet food recall", + "food recall check", + "is my dog food safe", + "is my cat food safe", + "any food recalls", + "add a pet", + "remove a pet", + "delete pet", + "update pet info", + "change my vet", + "pet info", + "clear activity log", + ], ) def call(self, worker: AgentWorker): From 28ee2c4ce9c5ecfadced2163ebe2bb464a7e005c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 04:23:40 +0000 Subject: [PATCH 13/19] style: auto-format Python files with autoflake + autopep8 --- community/pet-care-assistant/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 2ae6d270..b0360a6f 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -136,7 +136,7 @@ class PetCareAssistantCapability(MatchingCapability): """OpenHome ability for multi-pet care tracking with persistent storage, emergency vet finder, weather safety, and food recall checks.""" - #{{register capability}} + # {{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None pet_data: dict = None From f2d3e7faf133f79ba177b7847d166703ca541319 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sat, 21 Feb 2026 06:39:39 +0200 Subject: [PATCH 14/19] fix: register_capability --- community/pet-care-assistant/main.py | 50 ++++------------------------ 1 file changed, 7 insertions(+), 43 deletions(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index b0360a6f..36c4a8e0 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -1,5 +1,6 @@ import asyncio import json +import os import re import uuid from datetime import datetime, timedelta @@ -154,50 +155,13 @@ class PetCareAssistantCapability(MatchingCapability): @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="pet_care_assistant", - matching_hotwords=[ - "pet care", - "pet assistant", - "pet tracker", - "my pets", - "pet log", - "I fed", - "I just fed", - "gave medication", - "got her medicine", - "got his medicine", - "we walked", - "went for a walk", - "weighs", - "log pet activity", - "when did I last feed", - "has had", - "how many walks", - "last vet visit", - "check on", - "emergency vet", - "find a vet", - "vet near me", - "I need a vet", - "is it safe outside", - "pet weather", - "can I walk", - "too hot for", - "too cold for", - "pet food recall", - "food recall check", - "is my dog food safe", - "is my cat food safe", - "any food recalls", - "add a pet", - "remove a pet", - "delete pet", - "update pet info", - "change my vet", - "pet info", - "clear activity log", - ], + unique_name=data["unique_name"], + matching_hotwords=data["matching_hotwords"], ) def call(self, worker: AgentWorker): From 2688ae27bc4021eaf2aba3f8b1310624aa376bfe Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 22 Feb 2026 03:23:13 +0200 Subject: [PATCH 15/19] =?UTF-8?q?refactor:=20revert=20to=20inline=20classe?= =?UTF-8?q?s=20=E2=80=94=20live=20editor=20errors=20on=20external=20file?= =?UTF-8?q?=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live editor raised import errors when importing from separate module files (activity_log_service, pet_data_service, llm_service, external_api_service). The OpenHome platform only loads main.py and does not add the ability directory to sys.path, so any import from sibling files fails at runtime. Changes: - Inline ActivityLogService, PetDataService, LLMService, ExternalAPIService directly into main.py - Restore missing is_hard_exit() method on LLMService - Remove register_capability() classmethod (used blocked open() call); keep only #{{register capability}} tag per current template pattern - Delete separate service files: activity_log_service.py, pet_data_service.py, llm_service.py, external_api_service.py - Update tests/conftest.py and test_exit_detection.py to import from main - Update README architecture diagram to reflect single-file structure - All 220 tests pass --- community/pet-care-assistant/README.md | 73 +-- .../activity_log_service.py | 93 ---- .../external_api_service.py | 400 -------------- community/pet-care-assistant/llm_service.py | 520 ------------------ community/pet-care-assistant/main.py | 462 +++++++++++++++- .../pet-care-assistant/pet_data_service.py | 210 ------- .../pet-care-assistant/tests/conftest.py | 12 +- .../tests/test_exit_detection.py | 20 +- 8 files changed, 495 insertions(+), 1295 deletions(-) delete mode 100644 community/pet-care-assistant/activity_log_service.py delete mode 100644 community/pet-care-assistant/external_api_service.py delete mode 100644 community/pet-care-assistant/llm_service.py delete mode 100644 community/pet-care-assistant/pet_data_service.py diff --git a/community/pet-care-assistant/README.md b/community/pet-care-assistant/README.md index 5d20e4b8..18ee8821 100644 --- a/community/pet-care-assistant/README.md +++ b/community/pet-care-assistant/README.md @@ -16,43 +16,47 @@ A voice-first assistant for managing your pets' daily lives. Track feeding, medi ## Architecture +All logic lives in a single `main.py` file, following the OpenHome Ability pattern. + ``` User Voice Input | OpenHome STT | - ┌─────▼─────┐ - │ main.py │ PetCareAssistantCapability - │ (router) │ - └─────┬─────┘ - │ - ┌───────────────┼───────────────┐ - │ │ │ - ┌─────▼──────┐ ┌─────▼──────┐ ┌────▼──────────┐ - │ LLMService │ │PetDataSvc │ │ActivityLogSvc │ - │ │ │ │ │ │ - │ - classify │ │ - load/save│ │ - add entry │ - │ intent │ │ JSON │ │ - filter/query│ - │ - extract │ │ - resolve │ │ - enforce │ - │ values │ │ pet name │ │ size limit │ - │ - is_exit │ │ - fuzzy │ │ │ - │ │ │ match │ └───────────────┘ - └────────────┘ └────────────┘ - │ - ┌─────▼──────────┐ - │ExternalAPISvc │ - │ │ - │ - Serper Maps │──► google.serper.dev/maps - │ (vet search) │ - │ - Open-Meteo │──► api.open-meteo.com - │ (weather) │ - │ - openFDA │──► api.fda.gov - │ (recalls) │ - │ - Serper News │──► google.serper.dev/news - │ (headlines) │ - │ - ip-api.com │──► ip-api.com - │ (geolocation)│ - └────────────────┘ + ┌─────▼──────────────────────────────────────────┐ + │ 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): ┌──────────────────────┐ ┌────────────────────────┐ ┌──────────────────────┐ @@ -134,7 +138,7 @@ The emergency vet finder and food recall news headlines require a Serper API key 1. Go to [serper.dev](https://serper.dev) and sign up 2. Copy your API key (2,500 free queries included) -3. Open `main.py` and find: +3. At the top of `main.py`, find: ```python SERPER_API_KEY = "your_serper_api_key_here" ``` @@ -313,6 +317,9 @@ Wipe all data and start fresh with a new onboarding session. ## Technical Notes +### 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. + ### 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. diff --git a/community/pet-care-assistant/activity_log_service.py b/community/pet-care-assistant/activity_log_service.py deleted file mode 100644 index 6b25af2d..00000000 --- a/community/pet-care-assistant/activity_log_service.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Activity Log Service - Handles activity tracking and logging. - -Responsibilities: -- Add activities to log -- Query/filter activities -- Enforce log size limits -""" - -from datetime import datetime -from typing import Optional - - -class ActivityLogService: - """Service for managing activity logs.""" - - def __init__(self, worker, max_log_entries=500): - """Initialize ActivityLogService. - - Args: - worker: AgentWorker for logging - max_log_entries: Maximum number of log entries to keep - """ - self.worker = worker - self.max_log_entries = max_log_entries - - def add_activity( - self, - activity_log: list, - pet_name: str, - activity_type: str, - details: str = "", - value: float = None, - ) -> list: - """Add an activity to the log. - - Args: - activity_log: Current activity log list - pet_name: Name of the pet - activity_type: Type of activity (feeding, walk, medication, etc.) - details: Additional details about the activity - value: Optional numeric value (e.g., weight in lbs) - - Returns: - Updated activity log (with size limit enforced) - """ - 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: list, - pet_name: Optional[str] = None, - activity_type: Optional[str] = None, - limit: int = 10, - ) -> list: - """Get recent activities, optionally filtered. - - Args: - activity_log: Activity log list - pet_name: Optional pet name filter - activity_type: Optional activity type filter - limit: Maximum number of activities to return - - Returns: - List of matching activities (most recent first) - """ - 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 most recent first - return list(reversed(filtered[-limit:])) diff --git a/community/pet-care-assistant/external_api_service.py b/community/pet-care-assistant/external_api_service.py deleted file mode 100644 index 430c0c74..00000000 --- a/community/pet-care-assistant/external_api_service.py +++ /dev/null @@ -1,400 +0,0 @@ -"""External API Service - Handles all external API integrations. - -Responsibilities: -- Weather API (Open-Meteo) -- Emergency vet search (Serper Maps) -- Food recall checking (openFDA + Serper News) -- Geolocation (IP-based + geocoding) -""" - -import asyncio -import json -from typing import Optional - -import requests - - -class ExternalAPIService: - """Service for external API integrations (weather, vets, recalls, geocoding).""" - - def __init__(self, worker, serper_api_key=None): - """Initialize ExternalAPIService. - - Args: - worker: AgentWorker for logging - serper_api_key: Optional Serper API key for vet search and news - """ - self.worker = worker - self.serper_api_key = serper_api_key - - async def get_weather_data(self, lat: float, lon: float) -> Optional[dict]: - """Fetch weather data from Open-Meteo API (non-blocking). - - Args: - lat: Latitude - lon: Longitude - - Returns: - Weather data dict or None if API call fails - """ - try: - url = "https://api.open-meteo.com/v1/forecast" - params = { - "latitude": lat, - "longitude": lon, - "current": "temperature_2m,weather_code,wind_speed_10m", - "hourly": "uv_index", - "temperature_unit": "fahrenheit", - "wind_speed_unit": "mph", - "timezone": "auto", - } - - resp = await asyncio.to_thread(requests.get, url, params=params, timeout=10) - - if resp.status_code == 200: - try: - data = resp.json() - if "current" not in data: - self.worker.editor_logging_handler.error( - "[PetCare] Weather API response missing 'current' field" - ) - return None - return data - except json.JSONDecodeError as e: - self.worker.editor_logging_handler.error( - f"[PetCare] Invalid JSON from Weather API: {e}" - ) - return None - else: - self.worker.editor_logging_handler.warning( - f"[PetCare] Weather API returned {resp.status_code}" - ) - return None - - except requests.exceptions.Timeout: - self.worker.editor_logging_handler.error("[PetCare] Weather API timeout") - return None - except requests.exceptions.ConnectionError: - self.worker.editor_logging_handler.error( - "[PetCare] Could not connect to Weather API" - ) - return None - except Exception as e: - self.worker.editor_logging_handler.error( - f"[PetCare] Unexpected Weather API error: {e}" - ) - return None - - async def search_emergency_vets( - self, lat: float, lon: float, location: str - ) -> list: - """Search for emergency vets using Serper Maps API (non-blocking). - - Args: - lat: Latitude - lon: Longitude - location: Human-readable location string - - Returns: - List of vet dicts with title, rating, openNow, phoneNumber - """ - if not self.serper_api_key or self.serper_api_key == "your_serper_api_key_here": - self.worker.editor_logging_handler.warning( - "[PetCare] Serper API key not configured" - ) - return [] - - try: - resp = await asyncio.to_thread( - requests.post, - "https://google.serper.dev/maps", - headers={ - "X-API-KEY": self.serper_api_key, - "Content-Type": "application/json", - }, - json={ - "q": f"emergency vet near {location}", - "lat": lat, - "lon": lon, - "num": 5, - }, - timeout=10, - ) - - if resp.status_code == 200: - try: - data = resp.json() - return data.get("places", []) - except json.JSONDecodeError as e: - self.worker.editor_logging_handler.error( - f"[PetCare] Invalid JSON from Serper Maps: {e}" - ) - return [] - elif resp.status_code in (401, 403): - self.worker.editor_logging_handler.error( - f"[PetCare] Serper API authentication failed: {resp.status_code}" - ) - return [] - elif resp.status_code == 429: - self.worker.editor_logging_handler.warning( - "[PetCare] Serper API rate limit exceeded" - ) - return [] - else: - self.worker.editor_logging_handler.warning( - f"[PetCare] Serper Maps returned {resp.status_code}" - ) - return [] - - except requests.exceptions.Timeout: - self.worker.editor_logging_handler.error("[PetCare] Serper Maps timeout") - return [] - except requests.exceptions.ConnectionError: - self.worker.editor_logging_handler.error( - "[PetCare] Could not connect to Serper Maps" - ) - return [] - except Exception as e: - self.worker.editor_logging_handler.error( - f"[PetCare] Unexpected Serper Maps error: {e}" - ) - return [] - - async def fetch_fda_events(self, species: str) -> list: - """Fetch FDA adverse events for a specific species (non-blocking). - - Args: - species: Pet species ("dog" or "cat") - - 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 = await asyncio.to_thread(requests.get, url, params=params, timeout=10) - - if resp.status_code == 200: - try: - data = resp.json() - 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: - 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 pet species - - Returns: - List of news headline dicts with source, title, snippet, date - """ - if not self.serper_api_key or self.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} 2025" - if species_labels - else "pet food recall 2025" - ) - - try: - news_resp = await asyncio.to_thread( - requests.post, - "https://google.serper.dev/news", - headers={ - "X-API-KEY": self.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}" - ) - return headlines - - for item in news_data.get("news", [])[:5]: - title = item.get("title", "") - snippet = item.get("snippet", "") - date = item.get("date", "") - if title: - headlines.append( - { - "source": "News", - "title": title, - "snippet": snippet, - "date": date, - } - ) - elif news_resp.status_code in (401, 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] 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 detect_location_by_ip(self, client_ip: str) -> Optional[dict]: - """Detect user location from IP address (non-blocking). - - Args: - client_ip: Client IP address - - Returns: - Dict with lat, lon, city, region, isp or None if detection fails - """ - try: - url = f"http://ip-api.com/json/{client_ip}" - resp = await asyncio.to_thread(requests.get, url, timeout=5) - - if resp.status_code == 200: - data = resp.json() - if data.get("status") == "success": - # Check for cloud/VPN IPs - isp = data.get("isp", "").lower() - # Warn if cloud/VPN IP detected (location may be inaccurate) - if any( - cloud in isp - for cloud in [ - "amazon", - "google", - "microsoft", - "cloudflare", - "digital ocean", - ] - ): - self.worker.editor_logging_handler.warning( - f"[PetCare] Detected cloud/VPN IP ({isp}), location may be inaccurate" - ) - - return { - "lat": data.get("lat"), - "lon": data.get("lon"), - "city": data.get("city"), - "region": data.get("regionName"), - "isp": data.get("isp"), - } - - except Exception as e: - self.worker.editor_logging_handler.error( - f"[PetCare] IP geolocation error: {e}" - ) - - return None - - async def geocode_location( - self, location: str, geocode_cache: dict - ) -> Optional[dict]: - """Geocode a location string to lat/lon (non-blocking, with caching). - - Args: - location: Location string (e.g., "Austin, Texas") - geocode_cache: Cache dict for storing results - - Returns: - Dict with lat, lon or None if geocoding fails - """ - if location in geocode_cache: - self.worker.editor_logging_handler.info( - f"[PetCare] Geocoding cache hit for: {location}" - ) - return geocode_cache[location] - - try: - url = "https://geocoding-api.open-meteo.com/v1/search" - params = {"name": location, "count": 1} - - resp = await asyncio.to_thread(requests.get, url, params=params, timeout=5) - - if resp.status_code == 200: - data = resp.json() - results = data.get("results", []) - if results: - result = { - "lat": results[0]["latitude"], - "lon": results[0]["longitude"], - } - geocode_cache[location] = result - return result - - except Exception as e: - self.worker.editor_logging_handler.error( - f"[PetCare] Geocoding error for {location}: {e}" - ) - - return None diff --git a/community/pet-care-assistant/llm_service.py b/community/pet-care-assistant/llm_service.py deleted file mode 100644 index 4d9a5e82..00000000 --- a/community/pet-care-assistant/llm_service.py +++ /dev/null @@ -1,520 +0,0 @@ -"""LLM Service - Handles all LLM/NLU operations. - -Responsibilities: -- Intent classification -- Value extraction from voice input (typed methods) -- Exit detection (including LLM fallback) -- Trigger context retrieval -""" - -import asyncio -import json -import re -from datetime import datetime - -# Constants for exit detection (three-tier system) -FORCE_EXIT_PHRASES: list[str] = [ - "exit petcare", - "close petcare", - "shut down pets", - "petcare out", -] - -EXIT_COMMANDS: list[str] = [ - "exit", - "stop", - "quit", - "cancel", -] - -EXIT_RESPONSES: list[str] = [ - "no", - "nope", - "done", - "bye", - "goodbye", - "thanks", - "thank you", - "no thanks", - "nothing else", - "all good", - "i'm good", - "that's all", - "that's it", - "i'm done", - "we're done", -] - -CLASSIFY_PROMPT = ( - "You are an intent classifier for a pet care assistant. " - "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 (next, tomorrow, this weekend, " - "on Monday, at 5PM next week) 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|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" - "- 'I fed', 'ate', 'breakfast', 'dinner', 'kibble', 'food' => log feeding\n" - "- 'medicine', 'medication', 'pill', 'flea', 'heartworm', 'dose' => log medication\n" - "- 'walk', 'walked', 'run', 'jog', 'hike' => log walk\n" - "- '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', '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', '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', 'clear history' => edit_pet with action clear_log (ONLY removes activity history, pets are kept)\n" - "- 'start over', 'reset everything', 'delete everything', 'wipe all data', 'fresh start', 'wipe everything', 'clean slate', 'start from scratch', 'erase everything' => edit_pet with action reset_all (deletes ALL data: pets + logs + reminders)\n" - "- IMPORTANT: 'delete everything' and 'start over' always mean reset_all, NOT clear_log\n" - "- 'what pets', 'do I have any pets', 'any animals', 'list my pets', 'how many pets', 'what animals do I have', 'do you have any' => lookup with query 'list registered pets'\n" - "- 'tell me about Luna', 'give me Luna info', 'Luna profile', 'Luna registered info', 'Luna details', 'Luna stats', 'what do you have on Luna' => lookup with pet_name and query containing 'profile info'\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', 'dog 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\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' - '"Clear activity log" -> {{"mode": "edit_pet", "action": "clear_log", "pet_name": null, "details": "clear logs"}}\n' - '"Start over" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' - '"Delete everything" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' - '"I wanna start over" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' - '"Wipe all data" -> {{"mode": "edit_pet", "action": "reset_all", "pet_name": null, "details": "reset all data"}}\n' - '"What pets do I have?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' - '"Do I have any animals?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' - '"Do you have any animal?" -> {{"mode": "lookup", "pet_name": null, "query": "list registered pets"}}\n' - '"pet care" -> {{"mode": "greeting"}}\n' - '"dog dog care" -> {{"mode": "greeting"}}\n' - '"Remind me to feed Luna in 2 hours" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "feeding", "time_description": "in 2 hours"}}\n' - '"What reminders do I have?" -> {{"mode": "reminder", "action": "list", "pet_name": null, "activity": null, "time_description": null}}\n' - '"Animal animal, Give me loaner information" -> {{"mode": "lookup", "pet_name": null, "query": "owner information"}}\n' - '"Dog dog, tell me about food" -> {{"mode": "lookup", "pet_name": null, "query": "food information"}}\n' - '"I wanna go to the doctor with Luna next Monday" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "vet_visit", "time_description": "next Monday"}}\n' - '"I need to take Max to the vet on Friday at 3PM" -> {{"mode": "reminder", "action": "set", "pet_name": "Max", "activity": "vet_visit", "time_description": "Friday at 3PM"}}\n' - '"Luna has a vet appointment tomorrow at 10AM" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "vet_visit", "time_description": "tomorrow at 10AM"}}\n' - '"Set a reminder to walk Luna next Monday at 5PM" -> {{"mode": "reminder", "action": "set", "pet_name": "Luna", "activity": "walk", "time_description": "next Monday at 5PM"}}\n' - '"Went to the vet with Luna today" -> {{"mode": "log", "pet_name": "Luna", "activity_type": "vet_visit", "details": "vet visit today", "value": null}}\n' -) - - -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() - - -class LLMService: - """Service for LLM/NLU operations (intent classification, extraction, exit detection).""" - - def __init__(self, capability_worker, worker, pet_data: dict): - """Initialize LLMService. - - Args: - capability_worker: CapabilityWorker for LLM access - worker: AgentWorker for logging and transcription access - pet_data: Current pet data dict (for intent classification context) - """ - self.capability_worker = capability_worker - self.worker = worker - self.pet_data = pet_data - - def classify_intent(self, user_input: str) -> dict: - """Use LLM to classify user intent and extract structured data (sync). - - Args: - user_input: Raw user input string - - Returns: - Intent dict with mode, pet_name, and mode-specific fields - """ - 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"} - - async def classify_intent_async(self, user_input: str) -> dict: - """Async wrapper for classify_intent (runs in thread pool to avoid blocking event loop).""" - return await asyncio.to_thread(self.classify_intent, user_input) - - def extract_value(self, raw_input: str, instruction: str) -> str: - """Use LLM to extract a clean value from messy voice input (sync). - - Args: - raw_input: Raw user input (e.g., "His name is Max") - instruction: Extraction instruction for LLM - - Returns: - Extracted value or raw_input if extraction fails - """ - if not raw_input: - return "" - try: - result = self.capability_worker.text_to_text_response( - f"Input: {raw_input}", - system_prompt=instruction, - ) - return _strip_json_fences(result).strip().strip('"') - except Exception: - return raw_input.strip() - - async def extract_value_async(self, raw_input: str, instruction: str) -> str: - """Async wrapper for extract_value (runs LLM call in thread pool). - - Allows parallel LLM extraction during onboarding for ~90% performance improvement. - - Args: - raw_input: Raw user input - instruction: Extraction instruction for LLM - - Returns: - Extracted value or raw_input if extraction fails - """ - return await asyncio.to_thread(self.extract_value, raw_input, instruction) - - # === Typed Extraction Methods === - - async def extract_pet_name_async(self, raw_input: str) -> str: - """Extract pet name from user input. - - Args: - raw_input: Raw user response (e.g., "His name is Max") - - Returns: - Extracted pet name (e.g., "Max") - """ - 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: str) -> str: - """Extract animal species from user input. - - Args: - raw_input: Raw user response (e.g., "She's a golden retriever") - - Returns: - Species as single word (e.g., "dog"), or "unknown" if not mentioned - """ - 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: str) -> str: - """Extract pet breed from user input. - - Args: - raw_input: Raw user response (e.g., "golden retriever mix") - - Returns: - Breed name or "mixed" (e.g., "golden retriever"), or "unknown" if not mentioned - """ - 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: str) -> str: - """Extract or calculate pet birthday from user input. - - Args: - raw_input: Raw user response (e.g., "3 years old" or "born in 2020") - - Returns: - Birthday in YYYY-MM-DD format (e.g., "2020-01-15") - """ - 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: str) -> str: - """Extract pet weight from user input (converts to pounds). - - Args: - raw_input: Raw user response (e.g., "55 pounds" or "25 kilos") - - Returns: - Weight as string number in pounds (e.g., "55") - """ - 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: str) -> str: - """Extract pet allergies from user input as JSON array. - - Args: - raw_input: Raw user response (e.g., "allergic to chicken and grain") - - Returns: - JSON array string (e.g., '["chicken", "grain"]' or '[]') - """ - 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: str) -> str: - """Extract pet medications from user input as JSON array. - - Args: - raw_input: Raw user response (e.g., "takes Heartgard monthly") - - Returns: - JSON array string (e.g., '[{"name": "Heartgard", "frequency": "monthly"}]') - """ - 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: str) -> str: - """Extract veterinarian name from user input. - - Args: - raw_input: Raw user response (e.g., "Dr. Smith at Austin Vet") - - Returns: - Vet name (e.g., "Dr. Smith") - """ - 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: str) -> str: - """Extract phone number from user input (digits only). - - Args: - raw_input: Raw user response (e.g., "(512) 555-1234") - - Returns: - Phone number as digits only (e.g., "5125551234") - """ - 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: str) -> str: - """Extract location from user input in City, State format. - - Args: - raw_input: Raw user response (e.g., "I live in Austin") - - Returns: - Location string (e.g., "Austin, Texas") - """ - return await self.extract_value_async( - raw_input, - "Extract the city and state/country. Return in format 'City, State' or 'City, Country'.", - ) - - # ------------------------------------------------------------------ - # Exit Detection - # ------------------------------------------------------------------ - - @staticmethod - def clean_input(text: str) -> str: - """Lowercase and strip punctuation from STT transcription. - - Converts 'Stop.' → 'stop', 'Done, thanks!' → 'done thanks', etc. - - Args: - text: Raw transcribed text - - Returns: - Cleaned text (lowercase, no punctuation except apostrophes) - """ - if not text: - return "" - # Lowercase, strip whitespace, remove all punctuation except apostrophes - cleaned = text.lower().strip() - cleaned = re.sub(r"[^\w\s']", "", cleaned) - return cleaned.strip() - - def is_hard_exit(self, text: str) -> bool: - """Exit detection for mid-question contexts (Tier 1 + 2 only). - - Use this instead of is_exit() when 'no', 'done', 'thanks', etc. are - valid answers to the current question (e.g. onboarding). Only matches - explicit abort/reset commands; ignores EXIT_RESPONSES like 'no'. - """ - 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/restart phrases (user wants to start over during onboarding) - 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(self, text: str) -> bool: - """Hybrid exit detection: force-exit → command match → response match. - - Processes cleaned (lowercased, punctuation-stripped) input through - three tiers to robustly detect exit intent. - - Args: - text: Raw transcribed text from the user. - - Returns: - True if the user wants to exit. - """ - if not text: - return False - cleaned = self.clean_input(text) - if not cleaned: - return False - - # Tier 1: Force-exit phrases (instant shutdown) - for phrase in FORCE_EXIT_PHRASES: - if phrase in cleaned: - return True - - # Tier 2: Exit Commands (anywhere in the sentence) - words = cleaned.split() - for cmd in EXIT_COMMANDS: - if cmd in words: - return True - - # Tier 3: Exit Responses (must be exact match or start of sentence) - # Allow "No thanks" or "No, I'm good" → "no thanks" or "no i'm good" - for resp in EXIT_RESPONSES: - if cleaned == resp: - return True - if cleaned.startswith(f"{resp} "): - return True - - return False - - def is_exit_llm(self, text: str) -> bool: - """Use the LLM to classify ambiguous exit intent. - - Only called when keyword matching fails but the input is short - and doesn't look like a pet care query. - - Args: - text: Cleaned user input. - - Returns: - True if the LLM thinks the user wants to exit. - """ - 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: str) -> bool: - """Async wrapper for is_exit_llm (runs in thread pool to avoid blocking event loop).""" - return await asyncio.to_thread(self.is_exit_llm, text) - - def get_trigger_context(self) -> str: - """Get the transcription that triggered this ability. - - Returns: - Trigger transcription string or empty string if not available - """ - 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 "" diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 36c4a8e0..c4bcea70 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -1,6 +1,5 @@ import asyncio import json -import os import re import uuid from datetime import datetime, timedelta @@ -10,17 +9,443 @@ from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker -# Service imports — relative for OpenHome runtime, absolute fallback for local tests -try: - from .activity_log_service import ActivityLogService - from .external_api_service import ExternalAPIService - from .llm_service import LLMService - from .pet_data_service import PetDataService -except ImportError: - from activity_log_service import ActivityLogService # noqa: E402 - from external_api_service import ExternalAPIService # noqa: E402 - from llm_service import LLMService # noqa: E402 - from pet_data_service import PetDataService # noqa: E402 +# =========================================================================== +# 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", "bye", "goodbye", "thanks", "thank you", + "no thanks", "nothing else", "all good", "i'm good", "that's all", + "that's it", "i'm done", "we're done", +] + +_CLASSIFY_PROMPT = ( + "You are an intent classifier for a pet care assistant. " + "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' + '- {{"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|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" + "- 'I fed', 'ate', 'breakfast', 'dinner', 'kibble', 'food' => log feeding\n" + "- 'medicine', 'medication', 'pill', 'flea', 'heartworm', 'dose' => log medication\n" + "- 'walk', 'walked', 'run', 'jog', 'hike' => log walk\n" + "- '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', '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', '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', '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\n" + "Examples:\n" + '"I just fed Luna" -> {{"mode": "log", "pet_name": "Luna", "activity_type": "feeding", "details": "fed", "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' + '"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' + '"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. @@ -137,7 +562,7 @@ class PetCareAssistantCapability(MatchingCapability): """OpenHome ability for multi-pet care tracking with persistent storage, emergency vet finder, weather safety, and food recall checks.""" - # {{register capability}} + #{{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None pet_data: dict = None @@ -153,17 +578,6 @@ class PetCareAssistantCapability(MatchingCapability): # Stash for a command embedded in a "no more pets" response during onboarding _pending_intent_text: str = 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"], - ) - def call(self, worker: AgentWorker): self.worker = worker self.capability_worker = CapabilityWorker(self.worker) diff --git a/community/pet-care-assistant/pet_data_service.py b/community/pet-care-assistant/pet_data_service.py deleted file mode 100644 index 5e340ecb..00000000 --- a/community/pet-care-assistant/pet_data_service.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Pet Data Service - Handles pet CRUD operations and file I/O. - -Responsibilities: -- Load/save pet data from/to JSON files -- Resolve pet names (fuzzy matching) -- Pet CRUD operations -- Atomic file writes for data safety -""" - -import json -from typing import Optional - - -class PetDataService: - """Service for managing pet data and persistence.""" - - def __init__(self, capability_worker, worker): - """Initialize PetDataService. - - Args: - capability_worker: CapabilityWorker for file I/O and user interaction - worker: AgentWorker for logging - """ - self.capability_worker = capability_worker - self.worker = worker - - async def load_json(self, filename: str, default=None): - """Load a JSON file, returning default if not found or corrupt. - - If the main file is corrupt, attempts to recover from the backup - file created by save_json. Only resets if both are unusable. - - Args: - filename: Name of file to load - default: Default value if file doesn't exist or is corrupt - - Returns: - Loaded JSON data or default value - """ - 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(): - # File exists but is empty — treat as absent (not corrupt) - 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) - - # Main file missing or corrupt — try backup recovery - 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." - ) - # Restore the main file 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: str, data): - """Save data using backup-write-delete pattern for data safety. - - Creates a backup before writing to prevent data loss. If write fails, - backup remains for recovery. Not truly atomic, but much safer than - delete-then-write. - - Pattern: - 1. Backup existing file (if exists) - 2. Write new data to target - 3. Delete backup on success - 4. If failure, backup retained for manual recovery - - Args: - filename: Target filename to save to - data: Data to serialize as JSON and save - - Raises: - Exception: If write fails (backup file will remain) - """ - backup_filename = f"{filename}.backup" - - try: - # Create backup before overwriting - 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}" - ) - # Delete original so write_file always creates a fresh file - # Ensure a clean file write by removing the existing file. - 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}" - ) - # Backup file remains for manual recovery - 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: dict, pet_name: Optional[str] = None - ) -> Optional[dict]: - """Resolve a pet name to a pet dict (synchronous). - - Args: - pet_data: Pet data dict containing 'pets' list - pet_name: Optional pet name to search for - - Returns: - Matching pet dict or first pet if ambiguous, None if no pets - """ - 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 - # 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 - - # Multiple pets, no match — return first pet as default - # The caller should handle ambiguity at a higher level - return pets[0] - - async def resolve_pet_async( - self, pet_data: dict, pet_name: Optional[str] = None, is_exit_fn=None - ) -> Optional[dict]: - """Resolve a pet, asking the user if ambiguous (async). - - Args: - pet_data: Pet data dict containing 'pets' list - pet_name: Optional pet name to search for - is_exit_fn: Optional function to check if user wants to exit - - Returns: - Matching pet dict or None if user cancels - """ - 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 - # 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 - - 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 diff --git a/community/pet-care-assistant/tests/conftest.py b/community/pet-care-assistant/tests/conftest.py index 0450771a..4301bb54 100644 --- a/community/pet-care-assistant/tests/conftest.py +++ b/community/pet-care-assistant/tests/conftest.py @@ -71,11 +71,13 @@ def mock_capability_worker(): @pytest.fixture def capability(mock_worker, mock_capability_worker): """Create a PetCareAssistantCapability instance with mocked dependencies.""" - from activity_log_service import ActivityLogService - from external_api_service import ExternalAPIService - from llm_service import LLMService - from main import PetCareAssistantCapability - from pet_data_service import PetDataService + from main import ( + ActivityLogService, + ExternalAPIService, + LLMService, + PetCareAssistantCapability, + PetDataService, + ) cap = PetCareAssistantCapability( unique_name="test_pet_care", matching_hotwords=["pet care", "my pets"] diff --git a/community/pet-care-assistant/tests/test_exit_detection.py b/community/pet-care-assistant/tests/test_exit_detection.py index 058ae4be..effd6629 100644 --- a/community/pet-care-assistant/tests/test_exit_detection.py +++ b/community/pet-care-assistant/tests/test_exit_detection.py @@ -17,7 +17,7 @@ class TestCleanInput: def test_removes_punctuation(self): """Should remove all punctuation except apostrophes.""" - from llm_service import LLMService + from main import LLMService assert LLMService.clean_input("Stop!") == "stop" assert LLMService.clean_input("Done.") == "done" @@ -26,7 +26,7 @@ def test_removes_punctuation(self): def test_preserves_apostrophes(self): """Should preserve apostrophes in contractions.""" - from llm_service import LLMService + from main import LLMService assert LLMService.clean_input("I'm done") == "i'm done" assert LLMService.clean_input("That's it") == "that's it" @@ -34,7 +34,7 @@ def test_preserves_apostrophes(self): def test_lowercases(self): """Should convert all text to lowercase.""" - from llm_service import LLMService + from main import LLMService assert LLMService.clean_input("STOP") == "stop" assert LLMService.clean_input("Exit") == "exit" @@ -42,20 +42,20 @@ def test_lowercases(self): def test_empty_input(self): """Should handle empty strings.""" - from llm_service import LLMService + from main import LLMService assert LLMService.clean_input("") == "" def test_whitespace_only(self): """Should handle whitespace-only input.""" - from llm_service import LLMService + from main import LLMService assert LLMService.clean_input(" ") == "" assert LLMService.clean_input("\t\n") == "" def test_multiple_spaces(self): """Should normalize multiple spaces.""" - from llm_service import LLMService + from main import LLMService # Regex will remove extra spaces when punctuation is removed result = LLMService.clean_input("no thanks") @@ -63,7 +63,7 @@ def test_multiple_spaces(self): def test_mixed_punctuation(self): """Should handle mixed punctuation.""" - from llm_service import LLMService + from main import LLMService assert LLMService.clean_input("Done, thanks!!!") == "done thanks" assert LLMService.clean_input("I'm done.") == "i'm done" @@ -300,7 +300,7 @@ def test_never_crashes(self, text): """Should never crash regardless of input.""" from unittest.mock import MagicMock - from llm_service import LLMService + from main import LLMService from main import PetCareAssistantCapability # Create capability with mocked dependencies inside test @@ -321,7 +321,7 @@ def test_never_crashes(self, text): @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 llm_service import LLMService + from main import LLMService result = LLMService.clean_input(text) assert isinstance(result, str) @@ -331,7 +331,7 @@ def test_exit_detection_deterministic(self, text): """Exit detection should be deterministic (same input = same output).""" from unittest.mock import MagicMock - from llm_service import LLMService + from main import LLMService from main import PetCareAssistantCapability # Create capability with mocked dependencies inside test From f54997f9f28d8ccc85edfee6b2e17601b9ad0668 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 01:23:57 +0000 Subject: [PATCH 16/19] style: auto-format Python files with autoflake + autopep8 --- community/pet-care-assistant/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index c4bcea70..341a22ad 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -13,6 +13,7 @@ # Activity Log Service # =========================================================================== + class ActivityLogService: def __init__(self, worker, max_log_entries=500): self.worker = worker @@ -447,6 +448,7 @@ 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 @@ -562,7 +564,7 @@ class PetCareAssistantCapability(MatchingCapability): """OpenHome ability for multi-pet care tracking with persistent storage, emergency vet finder, weather safety, and food recall checks.""" - #{{register capability}} + # {{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None pet_data: dict = None From 3392b15411397e1c474da0045e55b1517beca751 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 22 Feb 2026 03:28:06 +0200 Subject: [PATCH 17/19] fix: remove duplicate user_response() calls causing repeated command prompts The main conversation loop already calls user_response() at the top of each iteration. Two extra follow-up prompts were consuming that response first: 1. _handle_log asked "Anything else to log?" + user_response() after every log 2. The main loop asked "What else can I help with?" after every _route_intent call Result: user had to say their command twice before it was acted on. Fix: remove the follow-up block from _handle_log and the "What else can I help with?" prompt from the main loop. The loop naturally listens for the next command at the top of each iteration. --- community/pet-care-assistant/main.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 341a22ad..2a276d92 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -802,8 +802,6 @@ async def run(self): consecutive_unknown = 0 await self._route_intent(intent) - - await self.capability_worker.speak("What else can I help with?") else: await self.capability_worker.speak(EXIT_MESSAGE) @@ -1581,19 +1579,6 @@ async def _handle_log(self, intent: dict): f"Got it. Logged {pet['name']}'s {activity_type} at {time_str}." ) - 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.llm_service.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 - follow_intent = await self.llm_service.classify_intent_async(follow) - if follow_intent.get("mode") == "log": - await self._handle_log(follow_intent) - # === Quick Lookup === async def _handle_lookup(self, intent: dict): From 8bbed2ff7ef17c2fabc0a80eb6c2e56f415a56f2 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 22 Feb 2026 03:29:05 +0200 Subject: [PATCH 18/19] style: apply isort and black formatting --- community/pet-care-assistant/main.py | 92 +++++++++++++++++++--------- 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index 2a276d92..e730abc8 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -19,7 +19,9 @@ 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): + def add_activity( + self, activity_log, pet_name, activity_type, details="", value=None + ): entry = { "pet_name": pet_name, "type": activity_type, @@ -31,13 +33,15 @@ def add_activity(self, activity_log, pet_name, activity_type, details="", 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:] + 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): + 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] @@ -50,6 +54,7 @@ def get_recent_activities(self, activity_log, pet_name=None, activity_type=None, # Pet Data Service # =========================================================================== + class PetDataService: def __init__(self, capability_worker, worker): self.capability_worker = capability_worker @@ -76,7 +81,9 @@ async def load_json(self, filename, default=None): 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.write_file( + filename, json.dumps(data), False + ) await self.capability_worker.delete_file(backup_filename, False) return data except (json.JSONDecodeError, Exception) as e: @@ -97,7 +104,9 @@ async def save_json(self, filename, data): ) 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): + 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" @@ -106,7 +115,9 @@ async def save_json(self, filename, data): 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): + 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" ) @@ -124,7 +135,9 @@ def resolve_pet(self, pet_data, pet_name=None): 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()): + if p["name"].lower().startswith(name_lower) or name_lower.startswith( + p["name"].lower() + ): return p return pets[0] @@ -141,7 +154,9 @@ async def resolve_pet_async(self, pet_data, pet_name=None, is_exit_fn=None): 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()): + 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}?") @@ -165,9 +180,21 @@ async def resolve_pet_async(self, pet_data, pet_name=None, is_exit_fn=None): _EXIT_COMMANDS = ["exit", "stop", "quit", "cancel"] _EXIT_RESPONSES = [ - "no", "nope", "done", "bye", "goodbye", "thanks", "thank you", - "no thanks", "nothing else", "all good", "i'm good", "that's all", - "that's it", "i'm done", "we're done", + "no", + "nope", + "done", + "bye", + "goodbye", + "thanks", + "thank you", + "no thanks", + "nothing else", + "all good", + "i'm good", + "that's all", + "that's it", + "i'm done", + "we're done", ] _CLASSIFY_PROMPT = ( @@ -259,11 +286,14 @@ def classify_intent(self, user_input): ) try: raw = self.capability_worker.text_to_text_response( - f"User said: {user_input}", system_prompt=prompt_filled, + 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}") + self.worker.editor_logging_handler.error( + f"[PetCare] Classification error: {e}" + ) return {"mode": "unknown"} async def classify_intent_async(self, user_input): @@ -274,7 +304,8 @@ def extract_value(self, raw_input, instruction): return "" try: result = self.capability_worker.text_to_text_response( - f"Input: {raw_input}", system_prompt=instruction, + f"Input: {raw_input}", + system_prompt=instruction, ) return _strip_llm_fences(result).strip().strip('"') except Exception: @@ -443,6 +474,7 @@ def get_trigger_context(self): # External API Service (unused directly — logic inlined in main capability) # =========================================================================== + class ExternalAPIService: def __init__(self, worker, serper_api_key=None): self.worker = worker @@ -552,7 +584,7 @@ def _fmt_phone_for_speech(phone: str) -> str: f"{', '.join(digits[7:])}" ) elif 7 <= len(digits) <= 15: - groups = [digits[i: i + 3] for i in range(0, len(digits), 3)] + 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" @@ -1445,21 +1477,25 @@ def _bad_name(v): 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"] - ) + def _is_skip(v): + return any( + w in v.lower() for w in ["no", "nope", "skip", "don't", "none"] + ) + # 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", - } + 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?" From 8fbfe84b0432945226b94f32cb08db4e6bf66168 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 01:51:27 +0000 Subject: [PATCH 19/19] style: auto-format Python files with autoflake + autopep8 --- community/pet-care-assistant/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/community/pet-care-assistant/main.py b/community/pet-care-assistant/main.py index e730abc8..66a6445c 100644 --- a/community/pet-care-assistant/main.py +++ b/community/pet-care-assistant/main.py @@ -33,7 +33,7 @@ def add_activity( 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 :] + 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." ) @@ -584,7 +584,7 @@ def _fmt_phone_for_speech(phone: str) -> str: f"{', '.join(digits[7:])}" ) elif 7 <= len(digits) <= 15: - groups = [digits[i : i + 3] for i in range(0, len(digits), 3)] + 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"