From ca25860b3eeadbebbfdd99fff8d2e8015c0d0b5a Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 03:38:49 +0200 Subject: [PATCH 01/12] Add health-supplement-search ability Voice-driven semantic search over 100 curated supplement products. Supports Weaviate (built-in Snowflake Arctic embeddings) and Qdrant (Jina AI embeddings) via a config flag. Falls back to Serper web search when a supplement is not found in the local DB. --- community/health-supplement-search/README.md | 92 +++ .../health-supplement-search/__init__.py | 0 community/health-supplement-search/main.py | 534 ++++++++++++++++++ 3 files changed, 626 insertions(+) create mode 100644 community/health-supplement-search/README.md create mode 100644 community/health-supplement-search/__init__.py create mode 100644 community/health-supplement-search/main.py diff --git a/community/health-supplement-search/README.md b/community/health-supplement-search/README.md new file mode 100644 index 00000000..04073247 --- /dev/null +++ b/community/health-supplement-search/README.md @@ -0,0 +1,92 @@ +# Health Supplement Search + +Voice-driven semantic search over 100 curated health supplement products. Ask about a health concern and get personalized supplement recommendations backed by real product data and user reviews. + +## What It Does + +- Searches a curated database of 100 real supplement products from iHerb using semantic vector similarity +- Falls back to live web search (Serper API) when a supplement isn't in the local database +- Supports multi-turn conversation: ask for details, re-rank by rating, or search for something new +- Works with **Qdrant Cloud** or **Weaviate** as the vector database (switchable via config flag) + +## Suggested Trigger Words + +- "search for supplements" +- "supplement search" +- "find me a supplement" +- "health supplement advisor" +- "what supplements help with" + +## Example Conversation + +> **User:** "Find me supplements for joint pain" +> **AI:** "I found 3 great options. First, Glucosamine Sulfate by Doctor's Best, rated 4.6 out of 5, known for cartilage support and joint mobility. Second, Boswellia Extract by Now Foods, rated 4.4, with strong anti-inflammatory effects. Would you like more details on any of these?" + +> **User:** "Tell me more about the first one" +> **AI:** "Doctor's Best Glucosamine Sulfate contains 750mg of pharmaceutical-grade glucosamine per capsule..." + +> **User:** "What about something for sleep?" +> **AI:** "Let me search for that..." + +## Required Setup (One-Time) + +This ability needs a vector database pre-loaded with supplement data. + +**Setup scripts and full instructions are in the companion branch:** +[`feat/health-supplement-search-setup`](https://github.com/megz2020/abilities/tree/feat/health-supplement-search-setup/community/health-supplement-search/setup) + +**Quick summary:** +1. Get a free [Weaviate Cloud](https://console.weaviate.cloud) cluster (built-in embeddings — no extra embedding API needed) + — or a free [Qdrant Cloud](https://cloud.qdrant.io) cluster + [Jina AI](https://jina.ai/embeddings/) key +2. Clone the setup branch and run `setup_vectordb.py --provider weaviate` (or `qdrant`) to load the 100 products +3. Save your API keys in `health_supplement_config.json` +4. Optional: get a free [Serper](https://serper.dev) API key for web fallback (2,500 free searches/month) + +## Configuration + +Save as `health_supplement_config.json` in your OpenHome file storage: + +```json +{ + "vector_db_provider": "qdrant", + "jina_api_key": "jina_xxxx", + "qdrant_url": "https://xxx.qdrant.io:6333", + "qdrant_api_key": "xxxx", + "qdrant_collection": "supplements", + "weaviate_url": "", + "weaviate_api_key": "", + "weaviate_class": "Supplement", + "serper_api_key": "", + "distance_threshold": 0.7 +} +``` + +Set `vector_db_provider` to `"qdrant"` or `"weaviate"` to switch backends. + +## How It Works + +1. User speaks a health concern +2. Query is embedded into a 1536-dimensional vector via Jina AI +3. The vector DB is searched for the most similar supplement products (cosine similarity) +4. If no good match is found (distance ≥ 0.7), falls back to Serper web search +5. Results are summarized by the OpenHome LLM into a natural voice response + +## Vector DB Options + +| Option | Free Tier | Best For | +|--------|-----------|----------| +| **Qdrant Cloud** | 1GB forever, auto-suspends after 1 week idle | Production / long-term use | +| **Weaviate Sandbox** | 14-day trial, then deleted | Quick testing only | + +## Key SDK Methods Used + +- `speak()` — deliver responses to the user +- `user_response()` — listen for user input +- `text_to_text_response()` — LLM summarization and intent detection +- `check_if_file_exists()` / `read_file()` / `write_file()` / `delete_file()` — config persistence + +## Data Source + +Supplement data from the [Weaviate HealthSearch Demo](https://github.com/weaviate/healthsearch-demo) — 100 real products from iHerb with names, brands, ratings, reviews, ingredients, and health effects. + +> **Disclaimer**: This ability is for informational purposes only and does not constitute medical advice. Always consult a qualified healthcare provider before starting any supplement. diff --git a/community/health-supplement-search/__init__.py b/community/health-supplement-search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py new file mode 100644 index 00000000..fd5dbe6b --- /dev/null +++ b/community/health-supplement-search/main.py @@ -0,0 +1,534 @@ +import asyncio +import json +import re + +import httpx + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# ============================================================================= +# HEALTH SUPPLEMENT SEARCH +# Voice-driven semantic search over 100 curated health supplement products. +# Weaviate: uses built-in Snowflake Arctic embeddings (no Jina needed). +# Qdrant: uses Jina AI embeddings (free tier). +# Falls back to Serper web search when the supplement is not in the local DB. +# +# One-time setup required: run setup/setup_vectordb.py before first use. +# ============================================================================= + +CONFIG_FILE = "health_supplement_config.json" + +# Top-level constants for keys (empty = read from config at runtime) +JINA_API_KEY = "" +QDRANT_URL = "" +QDRANT_API_KEY = "" +WEAVIATE_URL = "" +WEAVIATE_API_KEY = "" +SERPER_API_KEY = "" + +JINA_EMBED_URL = "https://api.jina.ai/v1/embeddings" +JINA_MODEL = "jina-embeddings-v3" +JINA_DIMENSIONS = 1536 + +SERPER_SEARCH_URL = "https://google.serper.dev/search" + +# Distance threshold: results with distance >= this value are considered "no match" +# Cosine distance in Qdrant: 0.0 = identical, 2.0 = opposite +# Weaviate certainty: 1.0 = identical, 0.0 = opposite (converted to distance = 1 - certainty) +DEFAULT_DISTANCE_THRESHOLD = 0.70 + +MAX_RESULTS = 5 +MAX_TURNS = 20 +IDLE_REPROMPT = 2 +IDLE_EXIT = 3 + +EXIT_WORDS = {"stop", "exit", "quit", "done", "bye", "goodbye", "no thanks", "cancel"} + +DEFAULT_CONFIG = { + "vector_db_provider": "qdrant", + "jina_api_key": JINA_API_KEY, + "qdrant_url": QDRANT_URL, + "qdrant_api_key": QDRANT_API_KEY, + "qdrant_collection": "supplements", + "weaviate_url": WEAVIATE_URL, + "weaviate_api_key": WEAVIATE_API_KEY, + "weaviate_class": "Supplement", + "serper_api_key": SERPER_API_KEY, + "distance_threshold": DEFAULT_DISTANCE_THRESHOLD, +} + + +def _strip_llm_fences(text: str) -> str: + text = text.strip() + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + return text.strip() + + +class HealthSupplementSearchCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Runtime config (populated by _load_config) + _cfg: dict = None + _last_results: list = None # last vector DB results for follow-up "tell me more" + _last_source: str = "" # "curated" | "web" + + # {{register capability}} + + # ------------------------------------------------------------------------- + # Config + # ------------------------------------------------------------------------- + + async def _load_config(self) -> bool: + """Load config from OpenHome file storage. Returns True if ready to use.""" + try: + exists = await self.capability_worker.check_if_file_exists(CONFIG_FILE, False) + if exists: + raw = await self.capability_worker.read_file(CONFIG_FILE, False) + loaded = json.loads(raw) + self._cfg = {**DEFAULT_CONFIG, **loaded} + else: + self._cfg = dict(DEFAULT_CONFIG) + except Exception as exc: + self._log(f"Config load error: {exc}") + self._cfg = dict(DEFAULT_CONFIG) + + # Merge top-level constants (let env/hardcoded keys override empty config) + for key, const in [ + ("jina_api_key", JINA_API_KEY), + ("qdrant_api_key", QDRANT_API_KEY), + ("qdrant_url", QDRANT_URL), + ("weaviate_api_key", WEAVIATE_API_KEY), + ("weaviate_url", WEAVIATE_URL), + ("serper_api_key", SERPER_API_KEY), + ]: + if const and not self._cfg.get(key): + self._cfg[key] = const + + provider = self._cfg.get("vector_db_provider", "qdrant") + jina_ok = bool(self._cfg.get("jina_api_key", "").strip()) + qdrant_ok = bool(self._cfg.get("qdrant_url", "").strip() and self._cfg.get("qdrant_api_key", "").strip()) + weaviate_ok = bool(self._cfg.get("weaviate_url", "").strip() and self._cfg.get("weaviate_api_key", "").strip()) + + # Jina is only required for Qdrant — Weaviate embeds internally + if provider == "qdrant" and not jina_ok: + return False + if provider == "qdrant" and not qdrant_ok: + return False + if provider == "weaviate" and not weaviate_ok: + return False + return True + + async def _save_config(self): + if await self.capability_worker.check_if_file_exists(CONFIG_FILE, False): + await self.capability_worker.delete_file(CONFIG_FILE, False) + await self.capability_worker.write_file(CONFIG_FILE, json.dumps(self._cfg), False) + + # ------------------------------------------------------------------------- + # Logging + # ------------------------------------------------------------------------- + + def _log(self, msg: str): + self.worker.editor_logging_handler.info(f"[HealthSupSearch] {msg}") + + def _err(self, msg: str): + self.worker.editor_logging_handler.error(f"[HealthSupSearch] {msg}") + + # ------------------------------------------------------------------------- + # Embedding + # ------------------------------------------------------------------------- + + async def _embed_query(self, text: str) -> list: + """Embed a query string via Jina AI. Returns 1536-dim vector or [].""" + key = self._cfg.get("jina_api_key", "").strip() + if not key: + self._err("Jina API key missing") + return [] + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + JINA_EMBED_URL, + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + json={"model": JINA_MODEL, "input": [text], "dimensions": JINA_DIMENSIONS}, + ) + resp.raise_for_status() + return resp.json()["data"][0]["embedding"] + except Exception as exc: + self._err(f"Jina embed error: {exc}") + return [] + + # ------------------------------------------------------------------------- + # Vector DB — abstract interface + # ------------------------------------------------------------------------- + + async def _search_supplements(self, user_query: str, limit: int = MAX_RESULTS) -> list: + """ + Unified search entry point. Handles embedding per provider: + - Weaviate: passes raw text via nearText (Weaviate embeds internally) + - Qdrant: embeds with Jina first, then passes vector + Returns list of {payload, score, distance}. + """ + provider = self._cfg.get("vector_db_provider", "qdrant") + if provider == "weaviate": + return await self._weaviate_search(user_query, limit) + vector = await self._embed_query(user_query) + if not vector: + return [] + return await self._qdrant_search(vector, limit) + + # --- Qdrant --- + + async def _qdrant_search(self, query_vector: list, limit: int) -> list: + url = self._cfg.get("qdrant_url", "").rstrip("/") + collection = self._cfg.get("qdrant_collection", "supplements") + key = self._cfg.get("qdrant_api_key", "") + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + f"{url}/collections/{collection}/points/search", + headers={"api-key": key, "Content-Type": "application/json"}, + json={"vector": query_vector, "limit": limit, "with_payload": True}, + ) + resp.raise_for_status() + hits = resp.json().get("result", []) + return [ + { + "payload": h.get("payload", {}), + "score": h.get("score", 0.0), + "distance": 1.0 - h.get("score", 0.0), # Qdrant cosine score → distance + } + for h in hits + ] + except Exception as exc: + self._err(f"Qdrant search error: {exc}") + return [] + + # --- Weaviate (nearText — embedding handled by Weaviate internally) --- + + async def _weaviate_search(self, query_text: str, limit: int) -> list: + url = self._cfg.get("weaviate_url", "").rstrip("/") + key = self._cfg.get("weaviate_api_key", "") + cls = self._cfg.get("weaviate_class", "Supplement") + # Escape any quotes in the query to avoid breaking the GraphQL string + safe_query = query_text.replace('"', "'") + gql = ( + f'{{ Get {{ {cls}(' + f'nearText: {{concepts: ["{safe_query}"]}}, ' + f'limit: {limit}' + f') {{ name brand rating description ingredients summary effects image reviews ' + f'_additional {{ certainty distance }} }} }} }}' + ) + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + f"{url}/v1/graphql", + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "X-Weaviate-Cluster-Url": url, + }, + json={"query": gql}, + ) + resp.raise_for_status() + data = resp.json() + errors = data.get("errors") + if errors: + self._err(f"Weaviate GraphQL errors: {errors}") + return [] + hits = data.get("data", {}).get("Get", {}).get(cls, []) or [] + results = [] + for h in hits: + additional = h.get("_additional", {}) or {} + certainty = float(additional.get("certainty") or 0.0) + distance = float(additional.get("distance") or (1.0 - certainty)) + payload = {k: v for k, v in h.items() if k != "_additional"} + results.append({"payload": payload, "score": certainty, "distance": distance}) + return results + except Exception as exc: + self._err(f"Weaviate search error: {exc}") + return [] + + # ------------------------------------------------------------------------- + # Serper fallback + # ------------------------------------------------------------------------- + + async def _serper_search(self, query: str) -> list: + """Search Serper for supplement info. Returns [{title, snippet, link}].""" + key = self._cfg.get("serper_api_key", "").strip() + if not key: + return [] + search_q = f"{query} supplement benefits reviews site:examine.com OR site:iherb.com OR site:webmd.com" + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + SERPER_SEARCH_URL, + headers={"X-API-KEY": key, "Content-Type": "application/json"}, + json={"q": search_q, "num": 5}, + ) + resp.raise_for_status() + organic = resp.json().get("organic", []) + return [ + {"title": r.get("title", ""), "snippet": r.get("snippet", ""), "link": r.get("link", "")} + for r in organic + ] + except Exception as exc: + self._err(f"Serper search error: {exc}") + return [] + + # ------------------------------------------------------------------------- + # Search with fallback logic + # ------------------------------------------------------------------------- + + async def _search_with_fallback(self, user_query: str) -> tuple: + """ + Returns (results, source) where source is 'curated' or 'web'. + 1. Try vector DB — if best distance < threshold, return curated results. + 2. If no good match and serper key is set, fall back to web search. + 3. Otherwise return ([], 'none'). + Weaviate embeds the query internally; Qdrant uses Jina AI. + """ + threshold = float(self._cfg.get("distance_threshold", DEFAULT_DISTANCE_THRESHOLD)) + + db_results = await self._search_supplements(user_query) + if db_results and db_results[0]["distance"] < threshold: + self._log(f"Curated match (best distance: {db_results[0]['distance']:.3f})") + return db_results, "curated" + + best = f"{db_results[0]['distance']:.3f}" if db_results else "N/A" + self._log(f"No curated match (best distance: {best}), trying Serper") + web_results = await self._serper_search(user_query) + if web_results: + return web_results, "web" + + return [], "none" + + # ------------------------------------------------------------------------- + # LLM summarization + # ------------------------------------------------------------------------- + + def _summarize_curated(self, user_query: str, results: list) -> str: + """Build voice-friendly summary of curated DB results.""" + products_text = "" + for i, r in enumerate(results[:3], 1): + p = r["payload"] + effects_raw = p.get("effects", "") + positives = [] + for part in str(effects_raw).split(","): + part = part.strip().strip("[]'\"") + if part.startswith("POSITIVE on "): + positives.append(part.replace("POSITIVE on ", "").replace("_", " ")) + effects_str = ", ".join(positives[:3]) if positives else "general wellness" + products_text += ( + f"{i}. {p.get('name', 'Unknown')} by {p.get('brand', 'Unknown')} " + f"(rating: {p.get('rating', 0)}/5). " + f"Key benefits: {effects_str}. " + f"Summary: {p.get('summary', '')[:150]}\n" + ) + + prompt = ( + f"The user asked: \"{user_query}\"\n\n" + f"Here are the top matching supplements from a curated database:\n{products_text}\n" + "Provide a friendly, conversational voice response recommending the most relevant products. " + "Mention product names, ratings, and key benefits. Keep it to 3-4 sentences maximum. " + "End with asking if they want more details on any product. " + "Do not include markdown. This is not medical advice." + ) + return self.capability_worker.text_to_text_response(prompt) + + def _summarize_web(self, user_query: str, web_results: list) -> str: + """Build voice-friendly summary of web search results.""" + snippets = "" + for r in web_results[:4]: + snippets += f"- {r.get('title', '')}: {r.get('snippet', '')}\n" + + prompt = ( + f"The user asked about: \"{user_query}\"\n\n" + f"I didn't find this in my curated supplement database, but here is what I found online:\n{snippets}\n" + "Provide a brief, helpful voice response based on these web results. " + "Keep it to 2-3 sentences. Be clear that this comes from web results and not a curated product database. " + "Always remind the user to consult a healthcare provider before taking any supplement. " + "Do not include markdown or URLs." + ) + return self.capability_worker.text_to_text_response(prompt) + + def _detail_response(self, product_payload: dict) -> str: + """Generate a detailed voice response for a single product.""" + p = product_payload + reviews = p.get("reviews", []) + review_sample = reviews[0][:120] if reviews else "No reviews available." + prompt = ( + f"Give a detailed voice summary of this supplement:\n" + f"Name: {p.get('name', '')}\n" + f"Brand: {p.get('brand', '')}\n" + f"Rating: {p.get('rating', 0)}/5\n" + f"Description: {p.get('description', '')[:300]}\n" + f"Ingredients: {p.get('ingredients', '')[:200]}\n" + f"Effects: {p.get('effects', '')[:200]}\n" + f"Sample review: {review_sample}\n" + "Keep it to 4 sentences. Friendly and informative. No markdown. " + "Remind the user this is not medical advice." + ) + return self.capability_worker.text_to_text_response(prompt) + + # ------------------------------------------------------------------------- + # Intent detection + # ------------------------------------------------------------------------- + + def _wants_exit(self, user_input: str) -> bool: + lowered = user_input.lower().strip() + if any(word in lowered for word in EXIT_WORDS): + return True + prompt = ( + f"Does this voice input mean the user wants to stop/exit the supplement search?\n" + f"Input: \"{user_input}\"\n" + "Reply with only YES or NO." + ) + result = self.capability_worker.text_to_text_response(prompt).strip().upper() + return result.startswith("YES") + + def _wants_detail(self, user_input: str, last_results: list) -> dict: + """Return the matching product payload if user wants details on a specific product, else {}.""" + if not last_results: + return {} + detail_triggers = ("more", "detail", "tell me about", "ingredients", "reviews", "what's in") + lowered = user_input.lower() + if not any(t in lowered for t in detail_triggers): + return {} + + product_names = [r["payload"].get("name", "") for r in last_results if "payload" in r] + names_str = "\n".join(f"{i+1}. {n}" for i, n in enumerate(product_names)) + prompt = ( + f"The user said: \"{user_input}\"\n" + f"Which of these products are they asking about? Reply with only the number (1-{len(product_names)}) " + f"or 0 if unclear.\n{names_str}" + ) + raw = self.capability_worker.text_to_text_response(prompt).strip() + try: + idx = int(raw) - 1 + if 0 <= idx < len(last_results): + return last_results[idx].get("payload", {}) + except ValueError: + pass + return {} + + def _wants_rerank(self, user_input: str) -> str: + """Return 'rating_high', 'rating_low', or '' if no rerank intent detected.""" + lowered = user_input.lower() + if any(w in lowered for w in ("best rated", "highest rated", "top rated", "best rating")): + return "rating_high" + if any(w in lowered for w in ("lowest rated", "worst rated", "cheapest")): + return "rating_low" + return "" + + # ------------------------------------------------------------------------- + # Main loop + # ------------------------------------------------------------------------- + + async def run(self): + try: + config_ok = await self._load_config() + if not config_ok: + await self.capability_worker.speak( + "Health Supplement Search isn't configured yet. " + "Please run the setup script and add your API keys to the config file. " + "Check the README for instructions." + ) + self.capability_worker.resume_normal_flow() + return + + provider = self._cfg.get("vector_db_provider", "qdrant") + self._log(f"Starting. Provider: {provider}") + + await self.capability_worker.speak( + "Welcome to Health Supplement Search. " + "I can help you find supplements for specific health concerns using a curated database " + "of 100 reviewed products. Note: this is for informational purposes only and not medical advice. " + "What health concern can I help you with today?" + ) + + self._last_results = [] + self._last_source = "" + idle_count = 0 + turn = 0 + + while turn < MAX_TURNS: + turn += 1 + user_input = await self.capability_worker.user_response() + + if not user_input or not user_input.strip(): + idle_count += 1 + if idle_count >= IDLE_EXIT: + await self.capability_worker.speak("No response detected. Goodbye!") + break + if idle_count >= IDLE_REPROMPT: + await self.capability_worker.speak( + "I'm still here. What supplement or health concern can I help you with?" + ) + continue + + idle_count = 0 + + if self._wants_exit(user_input): + await self.capability_worker.speak( + "Thanks for using Health Supplement Search. Stay healthy!" + ) + break + + # Check for rerank request on previous results + rerank = self._wants_rerank(user_input) + if rerank and self._last_results and self._last_source == "curated": + sorted_results = sorted( + self._last_results, + key=lambda r: r["payload"].get("rating", 0), + reverse=(rerank == "rating_high"), + ) + label = "highest" if rerank == "rating_high" else "lowest" + summary = self._summarize_curated(f"{label} rated {user_input}", sorted_results) + await self.capability_worker.speak(summary) + self._last_results = sorted_results + continue + + # Check for detail request on previous results + if self._last_results and self._last_source == "curated": + detail_payload = self._wants_detail(user_input, self._last_results) + if detail_payload: + detail = self._detail_response(detail_payload) + await self.capability_worker.speak(detail) + await self.capability_worker.speak( + "Would you like details on another product, or shall we search for something else?" + ) + continue + + # New search + await self.capability_worker.speak("Let me search for that...") + results, source = await self._search_with_fallback(user_input) + self._last_results = results + self._last_source = source + + if source == "curated": + summary = self._summarize_curated(user_input, results) + await self.capability_worker.speak(summary) + elif source == "web": + summary = self._summarize_web(user_input, results) + await self.capability_worker.speak(summary) + else: + await self.capability_worker.speak( + "I couldn't find any supplements matching that concern in my database or online. " + "Could you rephrase, or try a different health topic?" + ) + + except Exception as exc: + self._err(f"Fatal run error: {exc}") + await self.capability_worker.speak( + "Sorry, something went wrong. Please try again later." + ) + finally: + self.capability_worker.resume_normal_flow() + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self) + self.worker.session_tasks.create(self.run()) From ef1c3c8fa500917fd40ea4bdf2a0c06de7562485 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 03:57:01 +0200 Subject: [PATCH 02/12] Fix config approach: replace JSON file with top-level constants - Remove unused json import from main.py - Replace CONFIG_FILE/load_config with top-level constant block - Update README to document constants-based setup (not JSON file) - Fix setup branch link in README (root, not subfolder path) --- community/health-supplement-search/README.md | 59 ++-- community/health-supplement-search/main.py | 270 +++++++------------ 2 files changed, 125 insertions(+), 204 deletions(-) diff --git a/community/health-supplement-search/README.md b/community/health-supplement-search/README.md index 04073247..1dca70f1 100644 --- a/community/health-supplement-search/README.md +++ b/community/health-supplement-search/README.md @@ -7,7 +7,7 @@ Voice-driven semantic search over 100 curated health supplement products. Ask ab - Searches a curated database of 100 real supplement products from iHerb using semantic vector similarity - Falls back to live web search (Serper API) when a supplement isn't in the local database - Supports multi-turn conversation: ask for details, re-rank by rating, or search for something new -- Works with **Qdrant Cloud** or **Weaviate** as the vector database (switchable via config flag) +- Works with **Qdrant Cloud** or **Weaviate** as the vector database (switchable via a constant in `main.py`) ## Suggested Trigger Words @@ -33,57 +33,62 @@ Voice-driven semantic search over 100 curated health supplement products. Ask ab This ability needs a vector database pre-loaded with supplement data. **Setup scripts and full instructions are in the companion branch:** -[`feat/health-supplement-search-setup`](https://github.com/megz2020/abilities/tree/feat/health-supplement-search-setup/community/health-supplement-search/setup) +[`feat/health-supplement-search-setup`](https://github.com/megz2020/abilities/tree/feat/health-supplement-search-setup) **Quick summary:** 1. Get a free [Weaviate Cloud](https://console.weaviate.cloud) cluster (built-in embeddings — no extra embedding API needed) — or a free [Qdrant Cloud](https://cloud.qdrant.io) cluster + [Jina AI](https://jina.ai/embeddings/) key -2. Clone the setup branch and run `setup_vectordb.py --provider weaviate` (or `qdrant`) to load the 100 products -3. Save your API keys in `health_supplement_config.json` -4. Optional: get a free [Serper](https://serper.dev) API key for web fallback (2,500 free searches/month) +2. Clone the setup branch and run `python setup_vectordb.py --provider weaviate` (or `qdrant`) to load the 100 products +3. Optional: get a free [Serper](https://serper.dev) API key for web fallback (2,500 free searches/month) ## Configuration -Save as `health_supplement_config.json` in your OpenHome file storage: - -```json -{ - "vector_db_provider": "qdrant", - "jina_api_key": "jina_xxxx", - "qdrant_url": "https://xxx.qdrant.io:6333", - "qdrant_api_key": "xxxx", - "qdrant_collection": "supplements", - "weaviate_url": "", - "weaviate_api_key": "", - "weaviate_class": "Supplement", - "serper_api_key": "", - "distance_threshold": 0.7 -} +Open `main.py` and fill in the constants at the top before uploading to OpenHome: + +```python +# Choose your vector DB provider: "weaviate" or "qdrant" +VECTOR_DB_PROVIDER = "weaviate" + +# Weaviate (no Jina key needed — embeddings are built-in) +WEAVIATE_URL = "https://xxx.weaviate.cloud" +WEAVIATE_API_KEY = "your-weaviate-api-key" +WEAVIATE_CLASS = "Supplement" # keep as-is unless you changed it in setup + +# Qdrant (requires Jina for embeddings) +QDRANT_URL = "https://xxx.qdrant.io:6333" +QDRANT_API_KEY = "your-qdrant-api-key" +QDRANT_COLLECTION = "supplements" # keep as-is unless you changed it in setup +JINA_API_KEY = "jina_xxxx" # only needed for Qdrant + +# Serper web fallback (optional — leave empty to disable) +SERPER_API_KEY = "" # get a free key at serper.dev (2,500/month) ``` -Set `vector_db_provider` to `"qdrant"` or `"weaviate"` to switch backends. +Set `VECTOR_DB_PROVIDER` to `"qdrant"` or `"weaviate"` to switch backends. Only fill in the keys for the provider you're using. ## How It Works 1. User speaks a health concern -2. Query is embedded into a 1536-dimensional vector via Jina AI -3. The vector DB is searched for the most similar supplement products (cosine similarity) -4. If no good match is found (distance ≥ 0.7), falls back to Serper web search +2. **Weaviate path**: query is sent as text — Weaviate embeds it internally using Snowflake Arctic (free, built-in) + **Qdrant path**: query is embedded into a 1536-dim vector via Jina AI, then sent to Qdrant +3. The vector DB returns the most similar supplement products (cosine similarity) +4. If no good match is found (distance >= threshold), falls back to Serper web search 5. Results are summarized by the OpenHome LLM into a natural voice response ## Vector DB Options | Option | Free Tier | Best For | |--------|-----------|----------| -| **Qdrant Cloud** | 1GB forever, auto-suspends after 1 week idle | Production / long-term use | -| **Weaviate Sandbox** | 14-day trial, then deleted | Quick testing only | +| **Weaviate Cloud** | 14-day sandbox, then deleted | Quick testing — no extra embedding API needed | +| **Qdrant Cloud** | 1GB forever, auto-suspends after 1 week idle | Long-term production use | + +> **Qdrant note**: The free cluster auto-suspends after 1 week without traffic. It auto-resumes on the next API call, but the first request after a pause may be slow. ## Key SDK Methods Used - `speak()` — deliver responses to the user - `user_response()` — listen for user input - `text_to_text_response()` — LLM summarization and intent detection -- `check_if_file_exists()` / `read_file()` / `write_file()` / `delete_file()` — config persistence ## Data Source diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index fd5dbe6b..860821c5 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -1,5 +1,3 @@ -import asyncio -import json import re import httpx @@ -15,18 +13,35 @@ # Qdrant: uses Jina AI embeddings (free tier). # Falls back to Serper web search when the supplement is not in the local DB. # -# One-time setup required: run setup/setup_vectordb.py before first use. +# SETUP: Fill in your keys below, then upload to OpenHome. +# Run the setup script first: github.com/megz2020/abilities/tree/feat/health-supplement-search-setup # ============================================================================= -CONFIG_FILE = "health_supplement_config.json" +# ----------------------------------------------------------------------------- +# CONFIGURATION — fill in before uploading +# ----------------------------------------------------------------------------- -# Top-level constants for keys (empty = read from config at runtime) -JINA_API_KEY = "" -QDRANT_URL = "" -QDRANT_API_KEY = "" -WEAVIATE_URL = "" +# Choose your vector DB provider: "weaviate" or "qdrant" +VECTOR_DB_PROVIDER = "weaviate" + +# Weaviate (provider = "weaviate") — no Jina key needed, embeddings are built-in +WEAVIATE_URL = "" # e.g. "https://xxx.weaviate.cloud" WEAVIATE_API_KEY = "" -SERPER_API_KEY = "" +WEAVIATE_CLASS = "Supplement" + +# Qdrant (provider = "qdrant") — requires Jina for embeddings +QDRANT_URL = "" # e.g. "https://xxx.qdrant.io:6333" +QDRANT_API_KEY = "" +QDRANT_COLLECTION = "supplements" +JINA_API_KEY = "" # only needed for Qdrant + +# Serper web fallback (optional — leave empty to disable) +SERPER_API_KEY = "" # get a free key at serper.dev (2,500/month free) + +# How similar a result must be to count as a match (0.0 = identical, 1.0 = unrelated) +DISTANCE_THRESHOLD = 0.70 + +# ----------------------------------------------------------------------------- JINA_EMBED_URL = "https://api.jina.ai/v1/embeddings" JINA_MODEL = "jina-embeddings-v3" @@ -34,11 +49,6 @@ SERPER_SEARCH_URL = "https://google.serper.dev/search" -# Distance threshold: results with distance >= this value are considered "no match" -# Cosine distance in Qdrant: 0.0 = identical, 2.0 = opposite -# Weaviate certainty: 1.0 = identical, 0.0 = opposite (converted to distance = 1 - certainty) -DEFAULT_DISTANCE_THRESHOLD = 0.70 - MAX_RESULTS = 5 MAX_TURNS = 20 IDLE_REPROMPT = 2 @@ -46,19 +56,6 @@ EXIT_WORDS = {"stop", "exit", "quit", "done", "bye", "goodbye", "no thanks", "cancel"} -DEFAULT_CONFIG = { - "vector_db_provider": "qdrant", - "jina_api_key": JINA_API_KEY, - "qdrant_url": QDRANT_URL, - "qdrant_api_key": QDRANT_API_KEY, - "qdrant_collection": "supplements", - "weaviate_url": WEAVIATE_URL, - "weaviate_api_key": WEAVIATE_API_KEY, - "weaviate_class": "Supplement", - "serper_api_key": SERPER_API_KEY, - "distance_threshold": DEFAULT_DISTANCE_THRESHOLD, -} - def _strip_llm_fences(text: str) -> str: text = text.strip() @@ -71,61 +68,21 @@ class HealthSupplementSearchCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - # Runtime config (populated by _load_config) - _cfg: dict = None _last_results: list = None # last vector DB results for follow-up "tell me more" _last_source: str = "" # "curated" | "web" # {{register capability}} # ------------------------------------------------------------------------- - # Config + # Validation # ------------------------------------------------------------------------- - async def _load_config(self) -> bool: - """Load config from OpenHome file storage. Returns True if ready to use.""" - try: - exists = await self.capability_worker.check_if_file_exists(CONFIG_FILE, False) - if exists: - raw = await self.capability_worker.read_file(CONFIG_FILE, False) - loaded = json.loads(raw) - self._cfg = {**DEFAULT_CONFIG, **loaded} - else: - self._cfg = dict(DEFAULT_CONFIG) - except Exception as exc: - self._log(f"Config load error: {exc}") - self._cfg = dict(DEFAULT_CONFIG) - - # Merge top-level constants (let env/hardcoded keys override empty config) - for key, const in [ - ("jina_api_key", JINA_API_KEY), - ("qdrant_api_key", QDRANT_API_KEY), - ("qdrant_url", QDRANT_URL), - ("weaviate_api_key", WEAVIATE_API_KEY), - ("weaviate_url", WEAVIATE_URL), - ("serper_api_key", SERPER_API_KEY), - ]: - if const and not self._cfg.get(key): - self._cfg[key] = const - - provider = self._cfg.get("vector_db_provider", "qdrant") - jina_ok = bool(self._cfg.get("jina_api_key", "").strip()) - qdrant_ok = bool(self._cfg.get("qdrant_url", "").strip() and self._cfg.get("qdrant_api_key", "").strip()) - weaviate_ok = bool(self._cfg.get("weaviate_url", "").strip() and self._cfg.get("weaviate_api_key", "").strip()) - - # Jina is only required for Qdrant — Weaviate embeds internally - if provider == "qdrant" and not jina_ok: - return False - if provider == "qdrant" and not qdrant_ok: - return False - if provider == "weaviate" and not weaviate_ok: - return False - return True - - async def _save_config(self): - if await self.capability_worker.check_if_file_exists(CONFIG_FILE, False): - await self.capability_worker.delete_file(CONFIG_FILE, False) - await self.capability_worker.write_file(CONFIG_FILE, json.dumps(self._cfg), False) + def _config_ok(self) -> bool: + if VECTOR_DB_PROVIDER == "weaviate": + return bool(WEAVIATE_URL.strip() and WEAVIATE_API_KEY.strip()) + if VECTOR_DB_PROVIDER == "qdrant": + return bool(QDRANT_URL.strip() and QDRANT_API_KEY.strip() and JINA_API_KEY.strip()) + return False # ------------------------------------------------------------------------- # Logging @@ -138,20 +95,19 @@ def _err(self, msg: str): self.worker.editor_logging_handler.error(f"[HealthSupSearch] {msg}") # ------------------------------------------------------------------------- - # Embedding + # Embedding (Qdrant path only) # ------------------------------------------------------------------------- async def _embed_query(self, text: str) -> list: """Embed a query string via Jina AI. Returns 1536-dim vector or [].""" - key = self._cfg.get("jina_api_key", "").strip() - if not key: + if not JINA_API_KEY.strip(): self._err("Jina API key missing") return [] try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( JINA_EMBED_URL, - headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + headers={"Authorization": f"Bearer {JINA_API_KEY}", "Content-Type": "application/json"}, json={"model": JINA_MODEL, "input": [text], "dimensions": JINA_DIMENSIONS}, ) resp.raise_for_status() @@ -166,13 +122,12 @@ async def _embed_query(self, text: str) -> list: async def _search_supplements(self, user_query: str, limit: int = MAX_RESULTS) -> list: """ - Unified search entry point. Handles embedding per provider: - - Weaviate: passes raw text via nearText (Weaviate embeds internally) + Unified search. Handles embedding per provider: + - Weaviate: raw text via nearText (Weaviate embeds internally, no Jina needed) - Qdrant: embeds with Jina first, then passes vector Returns list of {payload, score, distance}. """ - provider = self._cfg.get("vector_db_provider", "qdrant") - if provider == "weaviate": + if VECTOR_DB_PROVIDER == "weaviate": return await self._weaviate_search(user_query, limit) vector = await self._embed_query(user_query) if not vector: @@ -182,14 +137,11 @@ async def _search_supplements(self, user_query: str, limit: int = MAX_RESULTS) - # --- Qdrant --- async def _qdrant_search(self, query_vector: list, limit: int) -> list: - url = self._cfg.get("qdrant_url", "").rstrip("/") - collection = self._cfg.get("qdrant_collection", "supplements") - key = self._cfg.get("qdrant_api_key", "") try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( - f"{url}/collections/{collection}/points/search", - headers={"api-key": key, "Content-Type": "application/json"}, + f"{QDRANT_URL.rstrip('/')}/collections/{QDRANT_COLLECTION}/points/search", + headers={"api-key": QDRANT_API_KEY, "Content-Type": "application/json"}, json={"vector": query_vector, "limit": limit, "with_payload": True}, ) resp.raise_for_status() @@ -198,7 +150,7 @@ async def _qdrant_search(self, query_vector: list, limit: int) -> list: { "payload": h.get("payload", {}), "score": h.get("score", 0.0), - "distance": 1.0 - h.get("score", 0.0), # Qdrant cosine score → distance + "distance": 1.0 - h.get("score", 0.0), } for h in hits ] @@ -209,24 +161,21 @@ async def _qdrant_search(self, query_vector: list, limit: int) -> list: # --- Weaviate (nearText — embedding handled by Weaviate internally) --- async def _weaviate_search(self, query_text: str, limit: int) -> list: - url = self._cfg.get("weaviate_url", "").rstrip("/") - key = self._cfg.get("weaviate_api_key", "") - cls = self._cfg.get("weaviate_class", "Supplement") - # Escape any quotes in the query to avoid breaking the GraphQL string safe_query = query_text.replace('"', "'") gql = ( - f'{{ Get {{ {cls}(' + f'{{ Get {{ {WEAVIATE_CLASS}(' f'nearText: {{concepts: ["{safe_query}"]}}, ' f'limit: {limit}' f') {{ name brand rating description ingredients summary effects image reviews ' f'_additional {{ certainty distance }} }} }} }}' ) try: + url = WEAVIATE_URL.rstrip("/") async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( f"{url}/v1/graphql", headers={ - "Authorization": f"Bearer {key}", + "Authorization": f"Bearer {WEAVIATE_API_KEY}", "Content-Type": "application/json", "X-Weaviate-Cluster-Url": url, }, @@ -234,11 +183,10 @@ async def _weaviate_search(self, query_text: str, limit: int) -> list: ) resp.raise_for_status() data = resp.json() - errors = data.get("errors") - if errors: - self._err(f"Weaviate GraphQL errors: {errors}") + if data.get("errors"): + self._err(f"Weaviate GraphQL errors: {data['errors']}") return [] - hits = data.get("data", {}).get("Get", {}).get(cls, []) or [] + hits = data.get("data", {}).get("Get", {}).get(WEAVIATE_CLASS, []) or [] results = [] for h in hits: additional = h.get("_additional", {}) or {} @@ -256,16 +204,14 @@ async def _weaviate_search(self, query_text: str, limit: int) -> list: # ------------------------------------------------------------------------- async def _serper_search(self, query: str) -> list: - """Search Serper for supplement info. Returns [{title, snippet, link}].""" - key = self._cfg.get("serper_api_key", "").strip() - if not key: + if not SERPER_API_KEY.strip(): return [] search_q = f"{query} supplement benefits reviews site:examine.com OR site:iherb.com OR site:webmd.com" try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( SERPER_SEARCH_URL, - headers={"X-API-KEY": key, "Content-Type": "application/json"}, + headers={"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"}, json={"q": search_q, "num": 5}, ) resp.raise_for_status() @@ -284,21 +230,17 @@ async def _serper_search(self, query: str) -> list: async def _search_with_fallback(self, user_query: str) -> tuple: """ - Returns (results, source) where source is 'curated' or 'web'. - 1. Try vector DB — if best distance < threshold, return curated results. - 2. If no good match and serper key is set, fall back to web search. + 1. Vector DB search — return curated results if best distance < threshold. + 2. If no good match and Serper key is set — fall back to web search. 3. Otherwise return ([], 'none'). - Weaviate embeds the query internally; Qdrant uses Jina AI. """ - threshold = float(self._cfg.get("distance_threshold", DEFAULT_DISTANCE_THRESHOLD)) - db_results = await self._search_supplements(user_query) - if db_results and db_results[0]["distance"] < threshold: - self._log(f"Curated match (best distance: {db_results[0]['distance']:.3f})") + if db_results and db_results[0]["distance"] < DISTANCE_THRESHOLD: + self._log(f"Curated match (distance: {db_results[0]['distance']:.3f})") return db_results, "curated" best = f"{db_results[0]['distance']:.3f}" if db_results else "N/A" - self._log(f"No curated match (best distance: {best}), trying Serper") + self._log(f"No curated match (distance: {best}), trying Serper") web_results = await self._serper_search(user_query) if web_results: return web_results, "web" @@ -310,13 +252,11 @@ async def _search_with_fallback(self, user_query: str) -> tuple: # ------------------------------------------------------------------------- def _summarize_curated(self, user_query: str, results: list) -> str: - """Build voice-friendly summary of curated DB results.""" products_text = "" for i, r in enumerate(results[:3], 1): p = r["payload"] - effects_raw = p.get("effects", "") positives = [] - for part in str(effects_raw).split(","): + for part in str(p.get("effects", "")).split(","): part = part.strip().strip("[]'\"") if part.startswith("POSITIVE on "): positives.append(part.replace("POSITIVE on ", "").replace("_", " ")) @@ -327,35 +267,29 @@ def _summarize_curated(self, user_query: str, results: list) -> str: f"Key benefits: {effects_str}. " f"Summary: {p.get('summary', '')[:150]}\n" ) - prompt = ( f"The user asked: \"{user_query}\"\n\n" - f"Here are the top matching supplements from a curated database:\n{products_text}\n" - "Provide a friendly, conversational voice response recommending the most relevant products. " - "Mention product names, ratings, and key benefits. Keep it to 3-4 sentences maximum. " - "End with asking if they want more details on any product. " - "Do not include markdown. This is not medical advice." + f"Top matching supplements from a curated database:\n{products_text}\n" + "Give a friendly, conversational voice response recommending the most relevant products. " + "Mention product names, ratings, and key benefits. Keep it to 3-4 sentences. " + "End by asking if they want more details on any product. No markdown. Not medical advice." ) return self.capability_worker.text_to_text_response(prompt) def _summarize_web(self, user_query: str, web_results: list) -> str: - """Build voice-friendly summary of web search results.""" - snippets = "" - for r in web_results[:4]: - snippets += f"- {r.get('title', '')}: {r.get('snippet', '')}\n" - + snippets = "".join( + f"- {r.get('title', '')}: {r.get('snippet', '')}\n" for r in web_results[:4] + ) prompt = ( f"The user asked about: \"{user_query}\"\n\n" - f"I didn't find this in my curated supplement database, but here is what I found online:\n{snippets}\n" - "Provide a brief, helpful voice response based on these web results. " - "Keep it to 2-3 sentences. Be clear that this comes from web results and not a curated product database. " - "Always remind the user to consult a healthcare provider before taking any supplement. " - "Do not include markdown or URLs." + f"Not found in curated database. Web results:\n{snippets}\n" + "Give a brief, helpful 2-3 sentence voice response. Make clear this is from web results, " + "not a curated product database. Remind the user to consult a healthcare provider. " + "No markdown or URLs." ) return self.capability_worker.text_to_text_response(prompt) def _detail_response(self, product_payload: dict) -> str: - """Generate a detailed voice response for a single product.""" p = product_payload reviews = p.get("reviews", []) review_sample = reviews[0][:120] if reviews else "No reviews available." @@ -368,8 +302,7 @@ def _detail_response(self, product_payload: dict) -> str: f"Ingredients: {p.get('ingredients', '')[:200]}\n" f"Effects: {p.get('effects', '')[:200]}\n" f"Sample review: {review_sample}\n" - "Keep it to 4 sentences. Friendly and informative. No markdown. " - "Remind the user this is not medical advice." + "4 sentences max. Friendly, informative. No markdown. Not medical advice." ) return self.capability_worker.text_to_text_response(prompt) @@ -381,31 +314,23 @@ def _wants_exit(self, user_input: str) -> bool: lowered = user_input.lower().strip() if any(word in lowered for word in EXIT_WORDS): return True - prompt = ( - f"Does this voice input mean the user wants to stop/exit the supplement search?\n" - f"Input: \"{user_input}\"\n" - "Reply with only YES or NO." - ) - result = self.capability_worker.text_to_text_response(prompt).strip().upper() + result = self.capability_worker.text_to_text_response( + f"Does this mean the user wants to stop the supplement search?\nInput: \"{user_input}\"\nReply YES or NO only." + ).strip().upper() return result.startswith("YES") def _wants_detail(self, user_input: str, last_results: list) -> dict: - """Return the matching product payload if user wants details on a specific product, else {}.""" if not last_results: return {} detail_triggers = ("more", "detail", "tell me about", "ingredients", "reviews", "what's in") - lowered = user_input.lower() - if not any(t in lowered for t in detail_triggers): + if not any(t in user_input.lower() for t in detail_triggers): return {} - product_names = [r["payload"].get("name", "") for r in last_results if "payload" in r] names_str = "\n".join(f"{i+1}. {n}" for i, n in enumerate(product_names)) - prompt = ( + raw = self.capability_worker.text_to_text_response( f"The user said: \"{user_input}\"\n" - f"Which of these products are they asking about? Reply with only the number (1-{len(product_names)}) " - f"or 0 if unclear.\n{names_str}" - ) - raw = self.capability_worker.text_to_text_response(prompt).strip() + f"Which product are they asking about? Reply with only the number (1-{len(product_names)}) or 0.\n{names_str}" + ).strip() try: idx = int(raw) - 1 if 0 <= idx < len(last_results): @@ -415,11 +340,10 @@ def _wants_detail(self, user_input: str, last_results: list) -> dict: return {} def _wants_rerank(self, user_input: str) -> str: - """Return 'rating_high', 'rating_low', or '' if no rerank intent detected.""" lowered = user_input.lower() - if any(w in lowered for w in ("best rated", "highest rated", "top rated", "best rating")): + if any(w in lowered for w in ("best rated", "highest rated", "top rated")): return "rating_high" - if any(w in lowered for w in ("lowest rated", "worst rated", "cheapest")): + if any(w in lowered for w in ("lowest rated", "worst rated")): return "rating_low" return "" @@ -429,23 +353,21 @@ def _wants_rerank(self, user_input: str) -> str: async def run(self): try: - config_ok = await self._load_config() - if not config_ok: + if not self._config_ok(): await self.capability_worker.speak( "Health Supplement Search isn't configured yet. " - "Please run the setup script and add your API keys to the config file. " - "Check the README for instructions." + "Please fill in your API keys in main.py and re-upload. " + "Check the README for setup instructions." ) self.capability_worker.resume_normal_flow() return - provider = self._cfg.get("vector_db_provider", "qdrant") - self._log(f"Starting. Provider: {provider}") + self._log(f"Starting. Provider: {VECTOR_DB_PROVIDER}") await self.capability_worker.speak( "Welcome to Health Supplement Search. " "I can help you find supplements for specific health concerns using a curated database " - "of 100 reviewed products. Note: this is for informational purposes only and not medical advice. " + "of 100 reviewed products. This is informational only — not medical advice. " "What health concern can I help you with today?" ) @@ -472,12 +394,10 @@ async def run(self): idle_count = 0 if self._wants_exit(user_input): - await self.capability_worker.speak( - "Thanks for using Health Supplement Search. Stay healthy!" - ) + await self.capability_worker.speak("Thanks for using Health Supplement Search. Stay healthy!") break - # Check for rerank request on previous results + # Rerank previous results rerank = self._wants_rerank(user_input) if rerank and self._last_results and self._last_source == "curated": sorted_results = sorted( @@ -486,19 +406,19 @@ async def run(self): reverse=(rerank == "rating_high"), ) label = "highest" if rerank == "rating_high" else "lowest" - summary = self._summarize_curated(f"{label} rated {user_input}", sorted_results) - await self.capability_worker.speak(summary) + await self.capability_worker.speak( + self._summarize_curated(f"{label} rated {user_input}", sorted_results) + ) self._last_results = sorted_results continue - # Check for detail request on previous results + # Detail request on previous results if self._last_results and self._last_source == "curated": detail_payload = self._wants_detail(user_input, self._last_results) if detail_payload: - detail = self._detail_response(detail_payload) - await self.capability_worker.speak(detail) + await self.capability_worker.speak(self._detail_response(detail_payload)) await self.capability_worker.speak( - "Would you like details on another product, or shall we search for something else?" + "Would you like details on another product, or search for something else?" ) continue @@ -509,22 +429,18 @@ async def run(self): self._last_source = source if source == "curated": - summary = self._summarize_curated(user_input, results) - await self.capability_worker.speak(summary) + await self.capability_worker.speak(self._summarize_curated(user_input, results)) elif source == "web": - summary = self._summarize_web(user_input, results) - await self.capability_worker.speak(summary) + await self.capability_worker.speak(self._summarize_web(user_input, results)) else: await self.capability_worker.speak( - "I couldn't find any supplements matching that concern in my database or online. " + "I couldn't find supplements matching that concern in my database or online. " "Could you rephrase, or try a different health topic?" ) except Exception as exc: self._err(f"Fatal run error: {exc}") - await self.capability_worker.speak( - "Sorry, something went wrong. Please try again later." - ) + await self.capability_worker.speak("Sorry, something went wrong. Please try again later.") finally: self.capability_worker.resume_normal_flow() From 2f7f65b54431dee313f559889daaf41675983080 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 04:05:30 +0200 Subject: [PATCH 03/12] Apply review recommendations from reference ability comparison Architecture: - Add per-provider threshold note to DISTANCE_THRESHOLD config comment - Extract trigger text in call() to pre-fill first search turn - Initialize _last_results/_last_source/_trigger_text in call() (not class-level) Code quality: - Remove LLM fallback from _wants_exit; expand EXIT_WORDS with phrase set - Add ordering comment above rerank/detail checks - Add _strip_llm_fences + ordinal word fallback to _wants_detail int parse - Wrap _log/_err in try/except matching local-event-explorer pattern - Add isinstance(reviews, list) guard in _detail_response - Add payload guard in _wants_detail list comprehension Performance: - Wrap all text_to_text_response calls in asyncio.to_thread (non-blocking) - Make _summarize_curated, _summarize_web, _detail_response async --- community/health-supplement-search/main.py | 123 +++++++++++++++------ 1 file changed, 88 insertions(+), 35 deletions(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index 860821c5..d998cb6c 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -1,3 +1,4 @@ +import asyncio import re import httpx @@ -38,7 +39,10 @@ # Serper web fallback (optional — leave empty to disable) SERPER_API_KEY = "" # get a free key at serper.dev (2,500/month free) -# How similar a result must be to count as a match (0.0 = identical, 1.0 = unrelated) +# How similar a result must be to count as a match. +# Weaviate returns "certainty" (higher = more similar). Threshold applies as distance = 1 - certainty. +# Qdrant returns cosine score; distance = 1 - score. Both use the same threshold but are not +# identical scales — if using Qdrant, you may need to tune this value (try 0.60-0.65). DISTANCE_THRESHOLD = 0.70 # ----------------------------------------------------------------------------- @@ -54,7 +58,13 @@ IDLE_REPROMPT = 2 IDLE_EXIT = 3 -EXIT_WORDS = {"stop", "exit", "quit", "done", "bye", "goodbye", "no thanks", "cancel"} +EXIT_WORDS = { + "stop", "exit", "quit", "done", "bye", "goodbye", "cancel", + "no thanks", "no thank you", "that's all", "that's it", + "never mind", "nevermind", "all done", "i'm done", "im done", +} + +_ORDINAL_TO_IDX = {"first": 0, "second": 1, "third": 2, "fourth": 3, "fifth": 4} def _strip_llm_fences(text: str) -> str: @@ -89,17 +99,22 @@ def _config_ok(self) -> bool: # ------------------------------------------------------------------------- def _log(self, msg: str): - self.worker.editor_logging_handler.info(f"[HealthSupSearch] {msg}") + try: + self.worker.editor_logging_handler.info(f"[HealthSupSearch] {msg}") + except Exception: + pass def _err(self, msg: str): - self.worker.editor_logging_handler.error(f"[HealthSupSearch] {msg}") + try: + self.worker.editor_logging_handler.error(f"[HealthSupSearch] {msg}") + except Exception: + pass # ------------------------------------------------------------------------- # Embedding (Qdrant path only) # ------------------------------------------------------------------------- async def _embed_query(self, text: str) -> list: - """Embed a query string via Jina AI. Returns 1536-dim vector or [].""" if not JINA_API_KEY.strip(): self._err("Jina API key missing") return [] @@ -251,7 +266,7 @@ async def _search_with_fallback(self, user_query: str) -> tuple: # LLM summarization # ------------------------------------------------------------------------- - def _summarize_curated(self, user_query: str, results: list) -> str: + async def _summarize_curated(self, user_query: str, results: list) -> str: products_text = "" for i, r in enumerate(results[:3], 1): p = r["payload"] @@ -274,9 +289,9 @@ def _summarize_curated(self, user_query: str, results: list) -> str: "Mention product names, ratings, and key benefits. Keep it to 3-4 sentences. " "End by asking if they want more details on any product. No markdown. Not medical advice." ) - return self.capability_worker.text_to_text_response(prompt) + return await asyncio.to_thread(self.capability_worker.text_to_text_response, prompt) - def _summarize_web(self, user_query: str, web_results: list) -> str: + async def _summarize_web(self, user_query: str, web_results: list) -> str: snippets = "".join( f"- {r.get('title', '')}: {r.get('snippet', '')}\n" for r in web_results[:4] ) @@ -287,12 +302,16 @@ def _summarize_web(self, user_query: str, web_results: list) -> str: "not a curated product database. Remind the user to consult a healthcare provider. " "No markdown or URLs." ) - return self.capability_worker.text_to_text_response(prompt) + return await asyncio.to_thread(self.capability_worker.text_to_text_response, prompt) - def _detail_response(self, product_payload: dict) -> str: + async def _detail_response(self, product_payload: dict) -> str: p = product_payload reviews = p.get("reviews", []) - review_sample = reviews[0][:120] if reviews else "No reviews available." + review_sample = ( + reviews[0][:120] + if isinstance(reviews, list) and reviews + else "No reviews available." + ) prompt = ( f"Give a detailed voice summary of this supplement:\n" f"Name: {p.get('name', '')}\n" @@ -304,7 +323,7 @@ def _detail_response(self, product_payload: dict) -> str: f"Sample review: {review_sample}\n" "4 sentences max. Friendly, informative. No markdown. Not medical advice." ) - return self.capability_worker.text_to_text_response(prompt) + return await asyncio.to_thread(self.capability_worker.text_to_text_response, prompt) # ------------------------------------------------------------------------- # Intent detection @@ -312,12 +331,7 @@ def _detail_response(self, product_payload: dict) -> str: def _wants_exit(self, user_input: str) -> bool: lowered = user_input.lower().strip() - if any(word in lowered for word in EXIT_WORDS): - return True - result = self.capability_worker.text_to_text_response( - f"Does this mean the user wants to stop the supplement search?\nInput: \"{user_input}\"\nReply YES or NO only." - ).strip().upper() - return result.startswith("YES") + return any(phrase in lowered for phrase in EXIT_WORDS) def _wants_detail(self, user_input: str, last_results: list) -> dict: if not last_results: @@ -325,18 +339,25 @@ def _wants_detail(self, user_input: str, last_results: list) -> dict: detail_triggers = ("more", "detail", "tell me about", "ingredients", "reviews", "what's in") if not any(t in user_input.lower() for t in detail_triggers): return {} + # Guard: only curated results have payload keys product_names = [r["payload"].get("name", "") for r in last_results if "payload" in r] + if not product_names: + return {} names_str = "\n".join(f"{i+1}. {n}" for i, n in enumerate(product_names)) - raw = self.capability_worker.text_to_text_response( + raw = _strip_llm_fences(self.capability_worker.text_to_text_response( f"The user said: \"{user_input}\"\n" f"Which product are they asking about? Reply with only the number (1-{len(product_names)}) or 0.\n{names_str}" - ).strip() + )) + # Try numeric first, then ordinal words ("first", "second", etc.) try: idx = int(raw) - 1 if 0 <= idx < len(last_results): return last_results[idx].get("payload", {}) except ValueError: pass + for word, idx in _ORDINAL_TO_IDX.items(): + if word in raw.lower() and idx < len(last_results): + return last_results[idx].get("payload", {}) return {} def _wants_rerank(self, user_input: str) -> str: @@ -364,21 +385,36 @@ async def run(self): self._log(f"Starting. Provider: {VECTOR_DB_PROVIDER}") - await self.capability_worker.speak( - "Welcome to Health Supplement Search. " - "I can help you find supplements for specific health concerns using a curated database " - "of 100 reviewed products. This is informational only — not medical advice. " - "What health concern can I help you with today?" - ) + # If the trigger phrase already contains a specific query (e.g. "find supplements + # for joint pain"), use it as the first turn instead of asking again. + pending_input = None + if self._trigger_text and len(self._trigger_text.split()) > 2: + pending_input = self._trigger_text + + if pending_input: + await self.capability_worker.speak( + "Welcome to Health Supplement Search. This is informational only — not medical advice. " + "Let me search for that..." + ) + else: + await self.capability_worker.speak( + "Welcome to Health Supplement Search. " + "I can help you find supplements for specific health concerns using a curated database " + "of 100 reviewed products. This is informational only — not medical advice. " + "What health concern can I help you with today?" + ) - self._last_results = [] - self._last_source = "" idle_count = 0 turn = 0 while turn < MAX_TURNS: turn += 1 - user_input = await self.capability_worker.user_response() + + if pending_input is not None: + user_input = pending_input + pending_input = None + else: + user_input = await self.capability_worker.user_response() if not user_input or not user_input.strip(): idle_count += 1 @@ -397,7 +433,8 @@ async def run(self): await self.capability_worker.speak("Thanks for using Health Supplement Search. Stay healthy!") break - # Rerank previous results + # Check rerank before detail — rerank must happen first so that a subsequent + # detail request can reference the newly ordered list. rerank = self._wants_rerank(user_input) if rerank and self._last_results and self._last_source == "curated": sorted_results = sorted( @@ -407,16 +444,16 @@ async def run(self): ) label = "highest" if rerank == "rating_high" else "lowest" await self.capability_worker.speak( - self._summarize_curated(f"{label} rated {user_input}", sorted_results) + await self._summarize_curated(f"{label} rated {user_input}", sorted_results) ) self._last_results = sorted_results continue - # Detail request on previous results + # Detail request on previous results (only valid for curated results) if self._last_results and self._last_source == "curated": detail_payload = self._wants_detail(user_input, self._last_results) if detail_payload: - await self.capability_worker.speak(self._detail_response(detail_payload)) + await self.capability_worker.speak(await self._detail_response(detail_payload)) await self.capability_worker.speak( "Would you like details on another product, or search for something else?" ) @@ -429,9 +466,9 @@ async def run(self): self._last_source = source if source == "curated": - await self.capability_worker.speak(self._summarize_curated(user_input, results)) + await self.capability_worker.speak(await self._summarize_curated(user_input, results)) elif source == "web": - await self.capability_worker.speak(self._summarize_web(user_input, results)) + await self.capability_worker.speak(await self._summarize_web(user_input, results)) else: await self.capability_worker.speak( "I couldn't find supplements matching that concern in my database or online. " @@ -447,4 +484,20 @@ async def run(self): def call(self, worker: AgentWorker): self.worker = worker self.capability_worker = CapabilityWorker(self) + # Initialize per-session state to avoid leaking across ability invocations + self._last_results = [] + self._last_source = "" + self._trigger_text = "" + # Extract the trigger phrase to pre-fill the first search turn if it contains a query + try: + if worker.transcription and worker.transcription.strip(): + self._trigger_text = worker.transcription.strip() + except Exception: + pass + if not self._trigger_text: + try: + if worker.last_transcription and worker.last_transcription.strip(): + self._trigger_text = worker.last_transcription.strip() + except Exception: + pass self.worker.session_tasks.create(self.run()) From fe0c4b767715f5763b1dbc5046aa83ee35292747 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 04:07:05 +0200 Subject: [PATCH 04/12] Use run_io_loop for idle reprompt (SDK best practice) --- community/health-supplement-search/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index d998cb6c..e7807f22 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -422,9 +422,11 @@ async def run(self): await self.capability_worker.speak("No response detected. Goodbye!") break if idle_count >= IDLE_REPROMPT: - await self.capability_worker.speak( + user_input = await self.capability_worker.run_io_loop( "I'm still here. What supplement or health concern can I help you with?" ) + if user_input and user_input.strip(): + idle_count = 0 continue idle_count = 0 From 6867f7baf7bf6c83fb0cff4f227f9a29a4dfdad7 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 04:17:04 +0200 Subject: [PATCH 05/12] Fix three issues found in live test + add off-topic guard - Expand _DETAIL_TRIGGERS: add affirmative follow-ups (yes, give me, show me, the first/second/third) so 'Yes. Give me' correctly routes to detail mode - Add clarification response when detail intent detected but product not resolved (e.g. STT garble like 'the restaurant') instead of falling through to search - Tighten _summarize_curated prompt: explicitly forbid inferring benefits not listed in the data to prevent LLM hallucination (e.g. 'cancer treatment') - Add _is_health_query() guard: keyword-first check then short LLM fallback rejects off-topic inputs before triggering a vector DB search --- community/health-supplement-search/main.py | 56 ++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index e7807f22..9f8cd679 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -66,6 +66,25 @@ _ORDINAL_TO_IDX = {"first": 0, "second": 1, "third": 2, "fourth": 3, "fifth": 4} +# Keywords that signal a health/supplement query — used to reject off-topic input +_HEALTH_KEYWORDS = { + "supplement", "vitamin", "mineral", "herb", "herbal", "capsule", "tablet", "pill", + "pain", "joint", "sleep", "energy", "immune", "anxiety", "stress", "inflammation", + "digestion", "gut", "heart", "brain", "memory", "focus", "mood", "skin", "hair", + "weight", "muscle", "bone", "liver", "kidney", "blood", "sugar", "pressure", + "cholesterol", "fatigue", "cold", "flu", "allergy", "hormone", "thyroid", "iron", + "calcium", "magnesium", "zinc", "omega", "probiotic", "prebiotic", "antioxidant", + "collagen", "protein", "fiber", "detox", "cleanse", "health", "wellness", "remedy", + "natural", "organic", "extract", "dose", "deficiency", "boost", "support", +} + +# Triggers that indicate the user wants detail on a previously shown product +_DETAIL_TRIGGERS = ( + "more", "detail", "tell me about", "ingredients", "reviews", "what's in", + "yes", "give me", "show me", "that one", "the first", "the second", "the third", + "number", "about it", "about that", "first one", "second one", "third one", +) + def _strip_llm_fences(text: str) -> str: text = text.strip() @@ -286,7 +305,9 @@ async def _summarize_curated(self, user_query: str, results: list) -> str: f"The user asked: \"{user_query}\"\n\n" f"Top matching supplements from a curated database:\n{products_text}\n" "Give a friendly, conversational voice response recommending the most relevant products. " - "Mention product names, ratings, and key benefits. Keep it to 3-4 sentences. " + "Mention product names, ratings, and key benefits listed above. " + "IMPORTANT: Only mention benefits explicitly stated in the data above — do NOT infer, " + "add, or speculate about any benefits not listed. Keep it to 3-4 sentences. " "End by asking if they want more details on any product. No markdown. Not medical advice." ) return await asyncio.to_thread(self.capability_worker.text_to_text_response, prompt) @@ -333,11 +354,24 @@ def _wants_exit(self, user_input: str) -> bool: lowered = user_input.lower().strip() return any(phrase in lowered for phrase in EXIT_WORDS) + def _is_health_query(self, user_input: str) -> bool: + """Return True if the input looks like a health or supplement question.""" + lowered = user_input.lower() + if any(kw in lowered for kw in _HEALTH_KEYWORDS): + return True + # Ask LLM only for short ambiguous inputs where keywords aren't enough + if len(user_input.split()) <= 6: + result = self.capability_worker.text_to_text_response( + f"Is this a question about health, wellness, or dietary supplements?\n" + f"Input: \"{user_input}\"\nReply YES or NO only." + ).strip().upper() + return result.startswith("YES") + return False + def _wants_detail(self, user_input: str, last_results: list) -> dict: if not last_results: return {} - detail_triggers = ("more", "detail", "tell me about", "ingredients", "reviews", "what's in") - if not any(t in user_input.lower() for t in detail_triggers): + if not any(t in user_input.lower() for t in _DETAIL_TRIGGERS): return {} # Guard: only curated results have payload keys product_names = [r["payload"].get("name", "") for r in last_results if "payload" in r] @@ -460,6 +494,22 @@ async def run(self): "Would you like details on another product, or search for something else?" ) continue + # Detail intent detected but couldn't resolve which product — ask to clarify + if any(t in user_input.lower() for t in _DETAIL_TRIGGERS): + await self.capability_worker.speak( + "Which product would you like more details on? " + "Say the first, second, or third." + ) + continue + + # Off-topic guard — only search if the input is health/supplement related + if not self._is_health_query(user_input): + self._log(f"Off-topic input rejected: {user_input[:60]}") + await self.capability_worker.speak( + "I can only help with health and supplement questions. " + "What health concern can I search supplements for?" + ) + continue # New search await self.capability_worker.speak("Let me search for that...") From 74cce41562edb8102802cf5dbeaa60ee0a86948c Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 04:21:25 +0200 Subject: [PATCH 06/12] Fix exit detection: catch STT-garbled goodbyes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add thank you/thanks/cheers to EXIT_WORDS (covers 'Thank you, Snowby' garble) - Add short-input LLM fallback in _wants_exit for inputs <=5 words that pass keyword check — catches STT garbles of goodbye that keyword matching misses --- community/health-supplement-search/main.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index 9f8cd679..e2ced199 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -62,6 +62,7 @@ "stop", "exit", "quit", "done", "bye", "goodbye", "cancel", "no thanks", "no thank you", "that's all", "that's it", "never mind", "nevermind", "all done", "i'm done", "im done", + "thank you", "thanks", "cheers", "great thanks", "ok thanks", "okay thanks", } _ORDINAL_TO_IDX = {"first": 0, "second": 1, "third": 2, "fourth": 3, "fifth": 4} @@ -352,7 +353,17 @@ async def _detail_response(self, product_payload: dict) -> str: def _wants_exit(self, user_input: str) -> bool: lowered = user_input.lower().strip() - return any(phrase in lowered for phrase in EXIT_WORDS) + if any(phrase in lowered for phrase in EXIT_WORDS): + return True + # Short inputs (≤5 words) that passed keyword check — ask LLM once. + # Catches STT garbles like "Thank you, Snowby" when user said "goodbye". + if len(user_input.split()) <= 5: + result = self.capability_worker.text_to_text_response( + f"Does this mean the user wants to stop or say goodbye?\n" + f"Input: \"{user_input}\"\nReply YES or NO only." + ).strip().upper() + return result.startswith("YES") + return False def _is_health_query(self, user_input: str) -> bool: """Return True if the input looks like a health or supplement question.""" From 5639a2ceea20b0a151a7cf0f8c1a31ac2b90ca03 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 19:41:59 +0200 Subject: [PATCH 07/12] Fix Jina dimensions: 1536 -> 1024 (jina-embeddings-v3 max is 1024) --- community/health-supplement-search/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index e2ced199..e2db5e2e 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -49,7 +49,7 @@ JINA_EMBED_URL = "https://api.jina.ai/v1/embeddings" JINA_MODEL = "jina-embeddings-v3" -JINA_DIMENSIONS = 1536 +JINA_DIMENSIONS = 1024 SERPER_SEARCH_URL = "https://google.serper.dev/search" From 93868dd22b74ec8847d40a9b4a381f91fd139788 Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 22:28:51 +0200 Subject: [PATCH 08/12] Fix double-detail loop and HTML tags in reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _just_showed_detail flag: blocks _DETAIL_TRIGGERS from re-matching on the turn immediately after detail was shown, preventing the double-detail loop - Strip HTML tags from reviews before passing to _detail_response using _strip_html() — source data contains raw tags that garble the review text and cause LLM to paraphrase instead of quoting --- community/health-supplement-search/main.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index e2db5e2e..6b3d27fc 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -94,6 +94,12 @@ def _strip_llm_fences(text: str) -> str: return text.strip() +def _strip_html(text: str) -> str: + """Remove HTML tags and normalize whitespace.""" + text = re.sub(r"<[^>]+>", " ", str(text)) + return re.sub(r"\s+", " ", text).strip() + + class HealthSupplementSearchCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None @@ -330,7 +336,7 @@ async def _detail_response(self, product_payload: dict) -> str: p = product_payload reviews = p.get("reviews", []) review_sample = ( - reviews[0][:120] + _strip_html(reviews[0])[:150] if isinstance(reviews, list) and reviews else "No reviews available." ) @@ -497,9 +503,10 @@ async def run(self): continue # Detail request on previous results (only valid for curated results) - if self._last_results and self._last_source == "curated": + if self._last_results and self._last_source == "curated" and not self._just_showed_detail: detail_payload = self._wants_detail(user_input, self._last_results) if detail_payload: + self._just_showed_detail = True await self.capability_worker.speak(await self._detail_response(detail_payload)) await self.capability_worker.speak( "Would you like details on another product, or search for something else?" @@ -513,6 +520,8 @@ async def run(self): ) continue + self._just_showed_detail = False + # Off-topic guard — only search if the input is health/supplement related if not self._is_health_query(user_input): self._log(f"Off-topic input rejected: {user_input[:60]}") @@ -551,6 +560,7 @@ def call(self, worker: AgentWorker): self._last_results = [] self._last_source = "" self._trigger_text = "" + self._just_showed_detail = False # Extract the trigger phrase to pre-fill the first search turn if it contains a query try: if worker.transcription and worker.transcription.strip(): From e92f2f4bd6c28b1016f5b9f62a566110bb1683bc Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 22:37:57 +0200 Subject: [PATCH 09/12] Fix off-topic guard: remove word-count limit on LLM fallback in _is_health_query Previously the LLM fallback only ran for inputs <=6 words, so long off-topic queries like "What is the result between Liverpool and Tottenham today?" bypassed the guard and triggered a supplement search. Now LLM is called for all inputs that don't match health keywords. --- community/health-supplement-search/main.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index 6b3d27fc..e6b222a1 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -376,14 +376,12 @@ def _is_health_query(self, user_input: str) -> bool: lowered = user_input.lower() if any(kw in lowered for kw in _HEALTH_KEYWORDS): return True - # Ask LLM only for short ambiguous inputs where keywords aren't enough - if len(user_input.split()) <= 6: - result = self.capability_worker.text_to_text_response( - f"Is this a question about health, wellness, or dietary supplements?\n" - f"Input: \"{user_input}\"\nReply YES or NO only." - ).strip().upper() - return result.startswith("YES") - return False + # LLM fallback for any input that didn't match keywords + result = self.capability_worker.text_to_text_response( + f"Is this a question about health, wellness, or dietary supplements?\n" + f"Input: \"{user_input}\"\nReply YES or NO only." + ).strip().upper() + return result.startswith("YES") def _wants_detail(self, user_input: str, last_results: list) -> dict: if not last_results: From ef142e48fb62f66f5a698b99849d247c80029cff Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 23:26:03 +0200 Subject: [PATCH 10/12] Clean up comments, update README, apply ruff formatting - Remove implementation-detail and narrative comments; keep only "why" comments - Update README: Qdrant as primary provider, STT resilience section, run_io_loop listed - Apply ruff format (no logic changes) --- community/health-supplement-search/README.md | 41 +- community/health-supplement-search/main.py | 508 ++++++++++++++----- 2 files changed, 401 insertions(+), 148 deletions(-) diff --git a/community/health-supplement-search/README.md b/community/health-supplement-search/README.md index 1dca70f1..39170678 100644 --- a/community/health-supplement-search/README.md +++ b/community/health-supplement-search/README.md @@ -7,6 +7,7 @@ Voice-driven semantic search over 100 curated health supplement products. Ask ab - Searches a curated database of 100 real supplement products from iHerb using semantic vector similarity - Falls back to live web search (Serper API) when a supplement isn't in the local database - Supports multi-turn conversation: ask for details, re-rank by rating, or search for something new +- Resilient to garbled speech-to-text input — guesses intended health phrases and asks for confirmation - Works with **Qdrant Cloud** or **Weaviate** as the vector database (switchable via a constant in `main.py`) ## Suggested Trigger Words @@ -28,6 +29,9 @@ Voice-driven semantic search over 100 curated health supplement products. Ask ab > **User:** "What about something for sleep?" > **AI:** "Let me search for that..." +> **User:** "joint" *(garbled STT)* +> **AI:** "Did you mean joint pain? Say yes to search for that, or tell me more about what you need." + ## Required Setup (One-Time) This ability needs a vector database pre-loaded with supplement data. @@ -36,9 +40,9 @@ This ability needs a vector database pre-loaded with supplement data. [`feat/health-supplement-search-setup`](https://github.com/megz2020/abilities/tree/feat/health-supplement-search-setup) **Quick summary:** -1. Get a free [Weaviate Cloud](https://console.weaviate.cloud) cluster (built-in embeddings — no extra embedding API needed) - — or a free [Qdrant Cloud](https://cloud.qdrant.io) cluster + [Jina AI](https://jina.ai/embeddings/) key -2. Clone the setup branch and run `python setup_vectordb.py --provider weaviate` (or `qdrant`) to load the 100 products +1. Get a free [Qdrant Cloud](https://cloud.qdrant.io) cluster (1 GB forever) + a free [Jina AI](https://jina.ai/embeddings/) key + — or a free [Weaviate Cloud](https://console.weaviate.cloud) sandbox (14-day trial, built-in embeddings, no Jina key needed) +2. Clone the setup branch and run `python setup_vectordb.py --provider qdrant` (or `weaviate`) to load the 100 products 3. Optional: get a free [Serper](https://serper.dev) API key for web fallback (2,500 free searches/month) ## Configuration @@ -47,18 +51,18 @@ Open `main.py` and fill in the constants at the top before uploading to OpenHome ```python # Choose your vector DB provider: "weaviate" or "qdrant" -VECTOR_DB_PROVIDER = "weaviate" - -# Weaviate (no Jina key needed — embeddings are built-in) -WEAVIATE_URL = "https://xxx.weaviate.cloud" -WEAVIATE_API_KEY = "your-weaviate-api-key" -WEAVIATE_CLASS = "Supplement" # keep as-is unless you changed it in setup +VECTOR_DB_PROVIDER = "qdrant" # Qdrant (requires Jina for embeddings) QDRANT_URL = "https://xxx.qdrant.io:6333" QDRANT_API_KEY = "your-qdrant-api-key" QDRANT_COLLECTION = "supplements" # keep as-is unless you changed it in setup -JINA_API_KEY = "jina_xxxx" # only needed for Qdrant +JINA_API_KEY = "jina_xxxx" + +# Weaviate (no Jina key needed — embeddings are built-in) +WEAVIATE_URL = "https://xxx.weaviate.cloud" +WEAVIATE_API_KEY = "your-weaviate-api-key" +WEAVIATE_CLASS = "Supplement" # keep as-is unless you changed it in setup # Serper web fallback (optional — leave empty to disable) SERPER_API_KEY = "" # get a free key at serper.dev (2,500/month) @@ -69,18 +73,25 @@ Set `VECTOR_DB_PROVIDER` to `"qdrant"` or `"weaviate"` to switch backends. Only ## How It Works 1. User speaks a health concern -2. **Weaviate path**: query is sent as text — Weaviate embeds it internally using Snowflake Arctic (free, built-in) - **Qdrant path**: query is embedded into a 1536-dim vector via Jina AI, then sent to Qdrant +2. **Qdrant path**: query is embedded into a 1024-dim vector via Jina AI (`jina-embeddings-v3`), then sent to Qdrant + **Weaviate path**: query is sent as text — Weaviate embeds it internally using Snowflake Arctic (free, built-in) 3. The vector DB returns the most similar supplement products (cosine similarity) -4. If no good match is found (distance >= threshold), falls back to Serper web search +4. If no good match is found (distance ≥ threshold), falls back to Serper web search 5. Results are summarized by the OpenHome LLM into a natural voice response +## STT Resilience + +Speech-to-text can garble health queries (e.g. "join te pin" for "joint pain"). The ability handles this in two ways: + +- **LLM intent check**: all inputs of 3+ words go through an LLM to judge the likely health intent, even if no keyword was recognized +- **Guess and confirm**: if the input is ambiguous or too short, the LLM guesses the intended health phrase and offers it to the user ("Did you mean joint pain?"). A "yes" confirms and triggers the search + ## Vector DB Options | Option | Free Tier | Best For | |--------|-----------|----------| +| **Qdrant Cloud** | 1 GB forever, auto-suspends after 1 week idle | Long-term production use | | **Weaviate Cloud** | 14-day sandbox, then deleted | Quick testing — no extra embedding API needed | -| **Qdrant Cloud** | 1GB forever, auto-suspends after 1 week idle | Long-term production use | > **Qdrant note**: The free cluster auto-suspends after 1 week without traffic. It auto-resumes on the next API call, but the first request after a pause may be slow. @@ -88,7 +99,9 @@ Set `VECTOR_DB_PROVIDER` to `"qdrant"` or `"weaviate"` to switch backends. Only - `speak()` — deliver responses to the user - `user_response()` — listen for user input +- `run_io_loop()` — prompt and listen in a single call (used for idle reprompts) - `text_to_text_response()` — LLM summarization and intent detection +- `resume_normal_flow()` — return control to the OpenHome agent ## Data Source diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index e6b222a1..0ce9f018 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -10,39 +10,38 @@ # ============================================================================= # HEALTH SUPPLEMENT SEARCH # Voice-driven semantic search over 100 curated health supplement products. -# Weaviate: uses built-in Snowflake Arctic embeddings (no Jina needed). -# Qdrant: uses Jina AI embeddings (free tier). -# Falls back to Serper web search when the supplement is not in the local DB. +# Weaviate: uses built-in Snowflake Arctic embeddings (no Jina key needed). +# Qdrant: uses Jina AI embeddings (free tier, requires JINA_API_KEY). +# Falls back to Serper web search when a product is not found in the local DB. # # SETUP: Fill in your keys below, then upload to OpenHome. -# Run the setup script first: github.com/megz2020/abilities/tree/feat/health-supplement-search-setup +# Setup script: github.com/megz2020/abilities/tree/feat/health-supplement-search-setup # ============================================================================= # ----------------------------------------------------------------------------- # CONFIGURATION — fill in before uploading # ----------------------------------------------------------------------------- -# Choose your vector DB provider: "weaviate" or "qdrant" +# Vector DB provider: "weaviate" or "qdrant" VECTOR_DB_PROVIDER = "weaviate" -# Weaviate (provider = "weaviate") — no Jina key needed, embeddings are built-in -WEAVIATE_URL = "" # e.g. "https://xxx.weaviate.cloud" +# Weaviate — embeddings are built-in (no Jina key needed) +WEAVIATE_URL = "" # e.g. "https://xxx.weaviate.cloud" WEAVIATE_API_KEY = "" WEAVIATE_CLASS = "Supplement" -# Qdrant (provider = "qdrant") — requires Jina for embeddings -QDRANT_URL = "" # e.g. "https://xxx.qdrant.io:6333" +# Qdrant — requires Jina for embeddings +QDRANT_URL = "" # e.g. "https://xxx.qdrant.io:6333" QDRANT_API_KEY = "" QDRANT_COLLECTION = "supplements" -JINA_API_KEY = "" # only needed for Qdrant +JINA_API_KEY = "" # only needed for Qdrant # Serper web fallback (optional — leave empty to disable) -SERPER_API_KEY = "" # get a free key at serper.dev (2,500/month free) +SERPER_API_KEY = "" # free key at serper.dev (2,500 searches/month) -# How similar a result must be to count as a match. -# Weaviate returns "certainty" (higher = more similar). Threshold applies as distance = 1 - certainty. -# Qdrant returns cosine score; distance = 1 - score. Both use the same threshold but are not -# identical scales — if using Qdrant, you may need to tune this value (try 0.60-0.65). +# Similarity threshold: Weaviate uses certainty (0–1), Qdrant uses cosine score. +# Both are normalised to distance = 1 - score before comparison. +# Qdrant distances may differ slightly — tune between 0.60 and 0.65 if needed. DISTANCE_THRESHOLD = 0.70 # ----------------------------------------------------------------------------- @@ -59,31 +58,148 @@ IDLE_EXIT = 3 EXIT_WORDS = { - "stop", "exit", "quit", "done", "bye", "goodbye", "cancel", - "no thanks", "no thank you", "that's all", "that's it", - "never mind", "nevermind", "all done", "i'm done", "im done", - "thank you", "thanks", "cheers", "great thanks", "ok thanks", "okay thanks", + "stop", + "exit", + "quit", + "done", + "bye", + "goodbye", + "cancel", + "no thanks", + "no thank you", + "that's all", + "that's it", + "never mind", + "nevermind", + "all done", + "i'm done", + "im done", + "thank you", + "thanks", + "cheers", + "great thanks", + "ok thanks", + "okay thanks", } _ORDINAL_TO_IDX = {"first": 0, "second": 1, "third": 2, "fourth": 3, "fifth": 4} -# Keywords that signal a health/supplement query — used to reject off-topic input _HEALTH_KEYWORDS = { - "supplement", "vitamin", "mineral", "herb", "herbal", "capsule", "tablet", "pill", - "pain", "joint", "sleep", "energy", "immune", "anxiety", "stress", "inflammation", - "digestion", "gut", "heart", "brain", "memory", "focus", "mood", "skin", "hair", - "weight", "muscle", "bone", "liver", "kidney", "blood", "sugar", "pressure", - "cholesterol", "fatigue", "cold", "flu", "allergy", "hormone", "thyroid", "iron", - "calcium", "magnesium", "zinc", "omega", "probiotic", "prebiotic", "antioxidant", - "collagen", "protein", "fiber", "detox", "cleanse", "health", "wellness", "remedy", - "natural", "organic", "extract", "dose", "deficiency", "boost", "support", + "supplement", + "vitamin", + "mineral", + "herb", + "herbal", + "capsule", + "tablet", + "pill", + "pain", + "joint", + "sleep", + "energy", + "immune", + "anxiety", + "stress", + "inflammation", + "digestion", + "gut", + "heart", + "brain", + "memory", + "focus", + "mood", + "skin", + "hair", + "weight", + "muscle", + "bone", + "liver", + "kidney", + "blood", + "sugar", + "pressure", + "cholesterol", + "fatigue", + "cold", + "flu", + "allergy", + "hormone", + "thyroid", + "iron", + "calcium", + "magnesium", + "zinc", + "omega", + "probiotic", + "prebiotic", + "antioxidant", + "collagen", + "protein", + "fiber", + "detox", + "cleanse", + "health", + "wellness", + "remedy", + "natural", + "organic", + "extract", + "dose", + "deficiency", + "boost", + "support", + "headache", + "migraine", + "nausea", + "insomnia", + "depression", + "acne", + "eczema", + "arthritis", + "osteoporosis", + "menopause", + "testosterone", + "estrogen", + "libido", + "cramp", + "cramps", + "swelling", + "infection", + "immunity", + "stamina", + "recover", + "recovery", + "healing", + "aging", + "antifungal", + "antibacterial", } -# Triggers that indicate the user wants detail on a previously shown product _DETAIL_TRIGGERS = ( - "more", "detail", "tell me about", "ingredients", "reviews", "what's in", - "yes", "give me", "show me", "that one", "the first", "the second", "the third", - "number", "about it", "about that", "first one", "second one", "third one", + "more", + "detail", + "tell me about", + "ingredients", + "reviews", + "what's in", + "yes", + "yep", + "yeah", + "sure", + "ok", + "okay", + "give me", + "show me", + "that one", + "the first", + "the second", + "the third", + "number", + "about it", + "about that", + "first one", + "second one", + "third one", ) @@ -95,7 +211,6 @@ def _strip_llm_fences(text: str) -> str: def _strip_html(text: str) -> str: - """Remove HTML tags and normalize whitespace.""" text = re.sub(r"<[^>]+>", " ", str(text)) return re.sub(r"\s+", " ", text).strip() @@ -104,26 +219,24 @@ class HealthSupplementSearchCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - _last_results: list = None # last vector DB results for follow-up "tell me more" - _last_source: str = "" # "curated" | "web" + _last_results: list = None + _last_source: str = "" # {{register capability}} # ------------------------------------------------------------------------- - # Validation + # Config & logging # ------------------------------------------------------------------------- def _config_ok(self) -> bool: if VECTOR_DB_PROVIDER == "weaviate": return bool(WEAVIATE_URL.strip() and WEAVIATE_API_KEY.strip()) if VECTOR_DB_PROVIDER == "qdrant": - return bool(QDRANT_URL.strip() and QDRANT_API_KEY.strip() and JINA_API_KEY.strip()) + return bool( + QDRANT_URL.strip() and QDRANT_API_KEY.strip() and JINA_API_KEY.strip() + ) return False - # ------------------------------------------------------------------------- - # Logging - # ------------------------------------------------------------------------- - def _log(self, msg: str): try: self.worker.editor_logging_handler.info(f"[HealthSupSearch] {msg}") @@ -137,7 +250,7 @@ def _err(self, msg: str): pass # ------------------------------------------------------------------------- - # Embedding (Qdrant path only) + # Embedding (Qdrant only) # ------------------------------------------------------------------------- async def _embed_query(self, text: str) -> list: @@ -148,8 +261,15 @@ async def _embed_query(self, text: str) -> list: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( JINA_EMBED_URL, - headers={"Authorization": f"Bearer {JINA_API_KEY}", "Content-Type": "application/json"}, - json={"model": JINA_MODEL, "input": [text], "dimensions": JINA_DIMENSIONS}, + headers={ + "Authorization": f"Bearer {JINA_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": JINA_MODEL, + "input": [text], + "dimensions": JINA_DIMENSIONS, + }, ) resp.raise_for_status() return resp.json()["data"][0]["embedding"] @@ -158,16 +278,12 @@ async def _embed_query(self, text: str) -> list: return [] # ------------------------------------------------------------------------- - # Vector DB — abstract interface + # Vector DB search # ------------------------------------------------------------------------- - async def _search_supplements(self, user_query: str, limit: int = MAX_RESULTS) -> list: - """ - Unified search. Handles embedding per provider: - - Weaviate: raw text via nearText (Weaviate embeds internally, no Jina needed) - - Qdrant: embeds with Jina first, then passes vector - Returns list of {payload, score, distance}. - """ + async def _search_supplements( + self, user_query: str, limit: int = MAX_RESULTS + ) -> list: if VECTOR_DB_PROVIDER == "weaviate": return await self._weaviate_search(user_query, limit) vector = await self._embed_query(user_query) @@ -175,14 +291,15 @@ async def _search_supplements(self, user_query: str, limit: int = MAX_RESULTS) - return [] return await self._qdrant_search(vector, limit) - # --- Qdrant --- - async def _qdrant_search(self, query_vector: list, limit: int) -> list: try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( f"{QDRANT_URL.rstrip('/')}/collections/{QDRANT_COLLECTION}/points/search", - headers={"api-key": QDRANT_API_KEY, "Content-Type": "application/json"}, + headers={ + "api-key": QDRANT_API_KEY, + "Content-Type": "application/json", + }, json={"vector": query_vector, "limit": limit, "with_payload": True}, ) resp.raise_for_status() @@ -199,16 +316,14 @@ async def _qdrant_search(self, query_vector: list, limit: int) -> list: self._err(f"Qdrant search error: {exc}") return [] - # --- Weaviate (nearText — embedding handled by Weaviate internally) --- - async def _weaviate_search(self, query_text: str, limit: int) -> list: safe_query = query_text.replace('"', "'") gql = ( - f'{{ Get {{ {WEAVIATE_CLASS}(' + f"{{ Get {{ {WEAVIATE_CLASS}(" f'nearText: {{concepts: ["{safe_query}"]}}, ' - f'limit: {limit}' - f') {{ name brand rating description ingredients summary effects image reviews ' - f'_additional {{ certainty distance }} }} }} }}' + f"limit: {limit}" + f") {{ name brand rating description ingredients summary effects image reviews " + f"_additional {{ certainty distance }} }} }} }}" ) try: url = WEAVIATE_URL.rstrip("/") @@ -234,14 +349,16 @@ async def _weaviate_search(self, query_text: str, limit: int) -> list: certainty = float(additional.get("certainty") or 0.0) distance = float(additional.get("distance") or (1.0 - certainty)) payload = {k: v for k, v in h.items() if k != "_additional"} - results.append({"payload": payload, "score": certainty, "distance": distance}) + results.append( + {"payload": payload, "score": certainty, "distance": distance} + ) return results except Exception as exc: self._err(f"Weaviate search error: {exc}") return [] # ------------------------------------------------------------------------- - # Serper fallback + # Serper web fallback # ------------------------------------------------------------------------- async def _serper_search(self, query: str) -> list: @@ -252,29 +369,27 @@ async def _serper_search(self, query: str) -> list: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( SERPER_SEARCH_URL, - headers={"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"}, + headers={ + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", + }, json={"q": search_q, "num": 5}, ) resp.raise_for_status() organic = resp.json().get("organic", []) return [ - {"title": r.get("title", ""), "snippet": r.get("snippet", ""), "link": r.get("link", "")} + { + "title": r.get("title", ""), + "snippet": r.get("snippet", ""), + "link": r.get("link", ""), + } for r in organic ] except Exception as exc: self._err(f"Serper search error: {exc}") return [] - # ------------------------------------------------------------------------- - # Search with fallback logic - # ------------------------------------------------------------------------- - async def _search_with_fallback(self, user_query: str) -> tuple: - """ - 1. Vector DB search — return curated results if best distance < threshold. - 2. If no good match and Serper key is set — fall back to web search. - 3. Otherwise return ([], 'none'). - """ db_results = await self._search_supplements(user_query) if db_results and db_results[0]["distance"] < DISTANCE_THRESHOLD: self._log(f"Curated match (distance: {db_results[0]['distance']:.3f})") @@ -309,7 +424,7 @@ async def _summarize_curated(self, user_query: str, results: list) -> str: f"Summary: {p.get('summary', '')[:150]}\n" ) prompt = ( - f"The user asked: \"{user_query}\"\n\n" + f'The user asked: "{user_query}"\n\n' f"Top matching supplements from a curated database:\n{products_text}\n" "Give a friendly, conversational voice response recommending the most relevant products. " "Mention product names, ratings, and key benefits listed above. " @@ -317,20 +432,24 @@ async def _summarize_curated(self, user_query: str, results: list) -> str: "add, or speculate about any benefits not listed. Keep it to 3-4 sentences. " "End by asking if they want more details on any product. No markdown. Not medical advice." ) - return await asyncio.to_thread(self.capability_worker.text_to_text_response, prompt) + return await asyncio.to_thread( + self.capability_worker.text_to_text_response, prompt + ) async def _summarize_web(self, user_query: str, web_results: list) -> str: snippets = "".join( f"- {r.get('title', '')}: {r.get('snippet', '')}\n" for r in web_results[:4] ) prompt = ( - f"The user asked about: \"{user_query}\"\n\n" + f'The user asked about: "{user_query}"\n\n' f"Not found in curated database. Web results:\n{snippets}\n" "Give a brief, helpful 2-3 sentence voice response. Make clear this is from web results, " "not a curated product database. Remind the user to consult a healthcare provider. " "No markdown or URLs." ) - return await asyncio.to_thread(self.capability_worker.text_to_text_response, prompt) + return await asyncio.to_thread( + self.capability_worker.text_to_text_response, prompt + ) async def _detail_response(self, product_payload: dict) -> str: p = product_payload @@ -351,7 +470,9 @@ async def _detail_response(self, product_payload: dict) -> str: f"Sample review: {review_sample}\n" "4 sentences max. Friendly, informative. No markdown. Not medical advice." ) - return await asyncio.to_thread(self.capability_worker.text_to_text_response, prompt) + return await asyncio.to_thread( + self.capability_worker.text_to_text_response, prompt + ) # ------------------------------------------------------------------------- # Intent detection @@ -359,45 +480,99 @@ async def _detail_response(self, product_payload: dict) -> str: def _wants_exit(self, user_input: str) -> bool: lowered = user_input.lower().strip() - if any(phrase in lowered for phrase in EXIT_WORDS): - return True - # Short inputs (≤5 words) that passed keyword check — ask LLM once. - # Catches STT garbles like "Thank you, Snowby" when user said "goodbye". - if len(user_input.split()) <= 5: - result = self.capability_worker.text_to_text_response( - f"Does this mean the user wants to stop or say goodbye?\n" - f"Input: \"{user_input}\"\nReply YES or NO only." - ).strip().upper() + word_count = len(lowered.split()) + # Short inputs: substring match is safe; exit words won't appear accidentally. + if word_count <= 5: + if any(phrase in lowered for phrase in EXIT_WORDS): + return True + # LLM catches STT garbles of farewell phrases. + result = ( + self.capability_worker.text_to_text_response( + f"Does this mean the user wants to stop or say goodbye?\n" + f'Input: "{user_input}"\nReply YES or NO only.' + ) + .strip() + .upper() + ) return result.startswith("YES") - return False + # Longer inputs: exit words can appear inside unrelated sentences; use LLM only. + result = ( + self.capability_worker.text_to_text_response( + f"Does this input primarily mean the user wants to stop or say goodbye? " + f"Ignore incidental words like 'thanks' if the sentence has other content.\n" + f'Input: "{user_input}"\nReply YES or NO only.' + ) + .strip() + .upper() + ) + return result.startswith("YES") - def _is_health_query(self, user_input: str) -> bool: - """Return True if the input looks like a health or supplement question.""" - lowered = user_input.lower() - if any(kw in lowered for kw in _HEALTH_KEYWORDS): - return True - # LLM fallback for any input that didn't match keywords - result = self.capability_worker.text_to_text_response( - f"Is this a question about health, wellness, or dietary supplements?\n" - f"Input: \"{user_input}\"\nReply YES or NO only." - ).strip().upper() + def _is_health_query(self, user_input: str) -> bool | None: + """ + True — valid health/supplement search request. + None — too short to judge (1–2 words); caller should ask for clarification. + False — clearly off-topic. + """ + stripped = user_input.strip().rstrip(".,!?") + word_count = len(stripped.split()) + lowered = stripped.lower() + has_keyword = any(kw in lowered for kw in _HEALTH_KEYWORDS) + + if word_count <= 2: + return None if has_keyword else False + + # Always use LLM for longer inputs: STT can garble health words beyond + # keyword recognition (e.g. "join te pin" for "joint pain"). + if has_keyword: + prompt = ( + f"Does this input contain a meaningful health or supplement question, " + f"even if the wording is imperfect or garbled by voice recognition?\n" + f'Input: "{user_input}"\nReply YES or NO only.' + ) + else: + prompt = ( + f"Is this a question or request about health, wellness, or dietary supplements? " + f"The input may be garbled by voice recognition — judge the likely intent.\n" + f'Input: "{user_input}"\nReply YES or NO only.' + ) + result = self.capability_worker.text_to_text_response(prompt).strip().upper() return result.startswith("YES") + def _guess_health_intent(self, user_input: str) -> str: + """ + Return the most likely health phrase the user meant (e.g. 'joint pain'), + or an empty string if no health intent can be detected. + Used to offer a clarification prompt when input is ambiguous or garbled. + """ + raw = self.capability_worker.text_to_text_response( + f"This voice input may be garbled by speech recognition. " + f"If it seems like the user was trying to ask about a health concern or supplement, " + f"reply with only the most likely 2-4 word health phrase they meant " + f"(e.g. 'joint pain', 'sleep issues', 'headache relief'). " + f"If you cannot detect any health intent, reply with exactly: NONE\n" + f'Input: "{user_input}"' + ).strip() + if not raw or raw.upper() == "NONE" or len(raw) > 60: + return "" + return raw + def _wants_detail(self, user_input: str, last_results: list) -> dict: if not last_results: return {} if not any(t in user_input.lower() for t in _DETAIL_TRIGGERS): return {} - # Guard: only curated results have payload keys - product_names = [r["payload"].get("name", "") for r in last_results if "payload" in r] + product_names = [ + r["payload"].get("name", "") for r in last_results if "payload" in r + ] if not product_names: return {} - names_str = "\n".join(f"{i+1}. {n}" for i, n in enumerate(product_names)) - raw = _strip_llm_fences(self.capability_worker.text_to_text_response( - f"The user said: \"{user_input}\"\n" - f"Which product are they asking about? Reply with only the number (1-{len(product_names)}) or 0.\n{names_str}" - )) - # Try numeric first, then ordinal words ("first", "second", etc.) + names_str = "\n".join(f"{i + 1}. {n}" for i, n in enumerate(product_names)) + raw = _strip_llm_fences( + self.capability_worker.text_to_text_response( + f'The user said: "{user_input}"\n' + f"Which product are they asking about? Reply with only the number (1-{len(product_names)}) or 0.\n{names_str}" + ) + ) try: idx = int(raw) - 1 if 0 <= idx < len(last_results): @@ -418,7 +593,7 @@ def _wants_rerank(self, user_input: str) -> str: return "" # ------------------------------------------------------------------------- - # Main loop + # Main session loop # ------------------------------------------------------------------------- async def run(self): @@ -434,12 +609,18 @@ async def run(self): self._log(f"Starting. Provider: {VECTOR_DB_PROVIDER}") - # If the trigger phrase already contains a specific query (e.g. "find supplements - # for joint pain"), use it as the first turn instead of asking again. + # Pre-fill first turn if the trigger phrase already contains a query. pending_input = None if self._trigger_text and len(self._trigger_text.split()) > 2: pending_input = self._trigger_text + pending_guess = ( + None # last LLM-guessed health phrase offered for confirmation + ) + confirmed_search = ( + False # bypass health check when guess was confirmed by user + ) + if pending_input: await self.capability_worker.speak( "Welcome to Health Supplement Search. This is informational only — not medical advice. " @@ -468,7 +649,9 @@ async def run(self): if not user_input or not user_input.strip(): idle_count += 1 if idle_count >= IDLE_EXIT: - await self.capability_worker.speak("No response detected. Goodbye!") + await self.capability_worker.speak( + "No response detected. Goodbye!" + ) break if idle_count >= IDLE_REPROMPT: user_input = await self.capability_worker.run_io_loop( @@ -480,12 +663,35 @@ async def run(self): idle_count = 0 - if self._wants_exit(user_input): - await self.capability_worker.speak("Thanks for using Health Supplement Search. Stay healthy!") + # Skip exit check while awaiting guess confirmation — affirmatives like + # "yes" must confirm the guess, not exit the session. + if not pending_guess and self._wants_exit(user_input): + await self.capability_worker.speak( + "Thanks for using Health Supplement Search. Stay healthy!" + ) break - # Check rerank before detail — rerank must happen first so that a subsequent - # detail request can reference the newly ordered list. + if pending_guess: + lowered_ui = user_input.lower().strip() + if any( + w in lowered_ui + for w in ( + "yes", + "yep", + "yeah", + "sure", + "ok", + "okay", + "correct", + "right", + ) + ): + pending_input = pending_guess + pending_guess = None + confirmed_search = True + continue + pending_guess = None + rerank = self._wants_rerank(user_input) if rerank and self._last_results and self._last_source == "curated": sorted_results = sorted( @@ -495,22 +701,28 @@ async def run(self): ) label = "highest" if rerank == "rating_high" else "lowest" await self.capability_worker.speak( - await self._summarize_curated(f"{label} rated {user_input}", sorted_results) + await self._summarize_curated( + f"{label} rated {user_input}", sorted_results + ) ) self._last_results = sorted_results continue - # Detail request on previous results (only valid for curated results) - if self._last_results and self._last_source == "curated" and not self._just_showed_detail: + if ( + self._last_results + and self._last_source == "curated" + and not self._just_showed_detail + ): detail_payload = self._wants_detail(user_input, self._last_results) if detail_payload: self._just_showed_detail = True - await self.capability_worker.speak(await self._detail_response(detail_payload)) + await self.capability_worker.speak( + await self._detail_response(detail_payload) + ) await self.capability_worker.speak( "Would you like details on another product, or search for something else?" ) continue - # Detail intent detected but couldn't resolve which product — ask to clarify if any(t in user_input.lower() for t in _DETAIL_TRIGGERS): await self.capability_worker.speak( "Which product would you like more details on? " @@ -520,25 +732,53 @@ async def run(self): self._just_showed_detail = False - # Off-topic guard — only search if the input is health/supplement related - if not self._is_health_query(user_input): - self._log(f"Off-topic input rejected: {user_input[:60]}") - await self.capability_worker.speak( - "I can only help with health and supplement questions. " - "What health concern can I search supplements for?" - ) - continue + if confirmed_search: + confirmed_search = False + else: + health_check = self._is_health_query(user_input) + if health_check is None: + guess = self._guess_health_intent(user_input) + if guess: + pending_guess = guess + await self.capability_worker.speak( + f"Did you mean {guess}? Say yes to search for that, " + f"or tell me more about what you need." + ) + else: + await self.capability_worker.speak( + "Can you tell me more? What health concern are you looking for supplements for?" + ) + continue + if not health_check: + self._log(f"Off-topic input rejected: {user_input[:60]}") + guess = self._guess_health_intent(user_input) + if guess: + pending_guess = guess + await self.capability_worker.speak( + f"I didn't quite catch that. Did you mean something like {guess}? " + f"Or tell me what health concern you're looking for." + ) + else: + pending_guess = None + await self.capability_worker.speak( + "I can only help with health and supplement questions. " + "What health concern can I search supplements for?" + ) + continue - # New search await self.capability_worker.speak("Let me search for that...") results, source = await self._search_with_fallback(user_input) self._last_results = results self._last_source = source if source == "curated": - await self.capability_worker.speak(await self._summarize_curated(user_input, results)) + await self.capability_worker.speak( + await self._summarize_curated(user_input, results) + ) elif source == "web": - await self.capability_worker.speak(await self._summarize_web(user_input, results)) + await self.capability_worker.speak( + await self._summarize_web(user_input, results) + ) else: await self.capability_worker.speak( "I couldn't find supplements matching that concern in my database or online. " @@ -547,19 +787,19 @@ async def run(self): except Exception as exc: self._err(f"Fatal run error: {exc}") - await self.capability_worker.speak("Sorry, something went wrong. Please try again later.") + await self.capability_worker.speak( + "Sorry, something went wrong. Please try again later." + ) finally: self.capability_worker.resume_normal_flow() def call(self, worker: AgentWorker): self.worker = worker self.capability_worker = CapabilityWorker(self) - # Initialize per-session state to avoid leaking across ability invocations self._last_results = [] self._last_source = "" self._trigger_text = "" self._just_showed_detail = False - # Extract the trigger phrase to pre-fill the first search turn if it contains a query try: if worker.transcription and worker.transcription.strip(): self._trigger_text = worker.transcription.strip() From a03c01507b74fa0a10cc79f1bb3c965a07143c5a Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Sun, 15 Mar 2026 23:28:39 +0200 Subject: [PATCH 11/12] Fix class attribute declarations and clean up ruff-generated paren wrapping - Declare _trigger_text and _just_showed_detail as class attributes to match OpenHome convention (alongside _last_results / _last_source) - Remove awkward multi-line parens ruff introduced around pending_guess and confirmed_search inline comments --- community/health-supplement-search/main.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index 0ce9f018..0d2e3396 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -221,6 +221,8 @@ class HealthSupplementSearchCapability(MatchingCapability): _last_results: list = None _last_source: str = "" + _trigger_text: str = "" + _just_showed_detail: bool = False # {{register capability}} @@ -614,12 +616,8 @@ async def run(self): if self._trigger_text and len(self._trigger_text.split()) > 2: pending_input = self._trigger_text - pending_guess = ( - None # last LLM-guessed health phrase offered for confirmation - ) - confirmed_search = ( - False # bypass health check when guess was confirmed by user - ) + pending_guess = None + confirmed_search = False if pending_input: await self.capability_worker.speak( From c9edc99e6ea07c2d8ab87c4d1745972bfce533be Mon Sep 17 00:00:00 2001 From: Ahmed Eissa Date: Thu, 19 Mar 2026 03:28:05 +0200 Subject: [PATCH 12/12] Address PR review feedback: voice naturalness and STT resilience - Add _normalize_query() to clean garbled STT before vector search - Replace hardcoded _wants_rerank with LLM classifier - Replace keyword-based _wants_exit with fully LLM-based classifier - Add few-shot examples to exit/intent/guess prompts - Expand affirmatives; remove false-positive detail triggers - Cap LLM responses to 30-40 words for voice-appropriate length - Bump DISTANCE_THRESHOLD to 0.85 for Weaviate compatibility - Replace magic numbers with named constants --- community/health-supplement-search/main.py | 182 +++++++++++---------- 1 file changed, 94 insertions(+), 88 deletions(-) diff --git a/community/health-supplement-search/main.py b/community/health-supplement-search/main.py index 0d2e3396..66b70646 100644 --- a/community/health-supplement-search/main.py +++ b/community/health-supplement-search/main.py @@ -39,10 +39,10 @@ # Serper web fallback (optional — leave empty to disable) SERPER_API_KEY = "" # free key at serper.dev (2,500 searches/month) -# Similarity threshold: Weaviate uses certainty (0–1), Qdrant uses cosine score. -# Both are normalised to distance = 1 - score before comparison. -# Qdrant distances may differ slightly — tune between 0.60 and 0.65 if needed. -DISTANCE_THRESHOLD = 0.70 +# Similarity threshold — compared against normalised distance (lower = better match). +# Weaviate cosine distance = 2 * (1 - certainty), so 0.80 ≈ certainty 0.60. +# Qdrant distance = 1 - cosine_score. +DISTANCE_THRESHOLD = 0.85 # ----------------------------------------------------------------------------- @@ -53,34 +53,16 @@ SERPER_SEARCH_URL = "https://google.serper.dev/search" MAX_RESULTS = 5 +MAX_DISPLAY = 3 MAX_TURNS = 20 IDLE_REPROMPT = 2 IDLE_EXIT = 3 +HTTP_TIMEOUT = 15 +SUMMARY_TRUNCATE = 150 +DESCRIPTION_TRUNCATE = 300 +FIELD_TRUNCATE = 200 +GUESS_MAX_LEN = 60 -EXIT_WORDS = { - "stop", - "exit", - "quit", - "done", - "bye", - "goodbye", - "cancel", - "no thanks", - "no thank you", - "that's all", - "that's it", - "never mind", - "nevermind", - "all done", - "i'm done", - "im done", - "thank you", - "thanks", - "cheers", - "great thanks", - "ok thanks", - "okay thanks", -} _ORDINAL_TO_IDX = {"first": 0, "second": 1, "third": 2, "fourth": 3, "fifth": 4} @@ -176,7 +158,6 @@ } _DETAIL_TRIGGERS = ( - "more", "detail", "tell me about", "ingredients", @@ -186,8 +167,6 @@ "yep", "yeah", "sure", - "ok", - "okay", "give me", "show me", "that one", @@ -260,7 +239,7 @@ async def _embed_query(self, text: str) -> list: self._err("Jina API key missing") return [] try: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: resp = await client.post( JINA_EMBED_URL, headers={ @@ -295,7 +274,7 @@ async def _search_supplements( async def _qdrant_search(self, query_vector: list, limit: int) -> list: try: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: resp = await client.post( f"{QDRANT_URL.rstrip('/')}/collections/{QDRANT_COLLECTION}/points/search", headers={ @@ -329,7 +308,7 @@ async def _weaviate_search(self, query_text: str, limit: int) -> list: ) try: url = WEAVIATE_URL.rstrip("/") - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: resp = await client.post( f"{url}/v1/graphql", headers={ @@ -368,7 +347,7 @@ async def _serper_search(self, query: str) -> list: return [] search_q = f"{query} supplement benefits reviews site:examine.com OR site:iherb.com OR site:webmd.com" try: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: resp = await client.post( SERPER_SEARCH_URL, headers={ @@ -411,28 +390,29 @@ async def _search_with_fallback(self, user_query: str) -> tuple: async def _summarize_curated(self, user_query: str, results: list) -> str: products_text = "" - for i, r in enumerate(results[:3], 1): + for i, r in enumerate(results[:MAX_DISPLAY], 1): p = r["payload"] positives = [] for part in str(p.get("effects", "")).split(","): part = part.strip().strip("[]'\"") if part.startswith("POSITIVE on "): positives.append(part.replace("POSITIVE on ", "").replace("_", " ")) - effects_str = ", ".join(positives[:3]) if positives else "general wellness" + effects_str = ( + ", ".join(positives[:MAX_DISPLAY]) if positives else "general wellness" + ) products_text += ( f"{i}. {p.get('name', 'Unknown')} by {p.get('brand', 'Unknown')} " f"(rating: {p.get('rating', 0)}/5). " f"Key benefits: {effects_str}. " - f"Summary: {p.get('summary', '')[:150]}\n" + f"Summary: {p.get('summary', '')[:SUMMARY_TRUNCATE]}\n" ) prompt = ( f'The user asked: "{user_query}"\n\n' f"Top matching supplements from a curated database:\n{products_text}\n" - "Give a friendly, conversational voice response recommending the most relevant products. " - "Mention product names, ratings, and key benefits listed above. " - "IMPORTANT: Only mention benefits explicitly stated in the data above — do NOT infer, " - "add, or speculate about any benefits not listed. Keep it to 3-4 sentences. " - "End by asking if they want more details on any product. No markdown. Not medical advice." + "Give a SHORT voice response under 40 words. Mention the top 1-2 product names and ratings. " + "Only mention benefits explicitly listed above — do NOT infer or add any. " + "End with 'Want details on any of these?' " + "Plain spoken English only. No lists, no formatting. Not medical advice." ) return await asyncio.to_thread( self.capability_worker.text_to_text_response, prompt @@ -445,9 +425,9 @@ async def _summarize_web(self, user_query: str, web_results: list) -> str: prompt = ( f'The user asked about: "{user_query}"\n\n' f"Not found in curated database. Web results:\n{snippets}\n" - "Give a brief, helpful 2-3 sentence voice response. Make clear this is from web results, " - "not a curated product database. Remind the user to consult a healthcare provider. " - "No markdown or URLs." + "Give a SHORT voice response under 30 words. Mention this is from web results, not a curated database. " + "Remind them to consult a healthcare provider. " + "Plain spoken English only. No lists, no formatting, no URLs." ) return await asyncio.to_thread( self.capability_worker.text_to_text_response, prompt @@ -457,7 +437,7 @@ async def _detail_response(self, product_payload: dict) -> str: p = product_payload reviews = p.get("reviews", []) review_sample = ( - _strip_html(reviews[0])[:150] + _strip_html(reviews[0])[:SUMMARY_TRUNCATE] if isinstance(reviews, list) and reviews else "No reviews available." ) @@ -466,11 +446,12 @@ async def _detail_response(self, product_payload: dict) -> str: f"Name: {p.get('name', '')}\n" f"Brand: {p.get('brand', '')}\n" f"Rating: {p.get('rating', 0)}/5\n" - f"Description: {p.get('description', '')[:300]}\n" - f"Ingredients: {p.get('ingredients', '')[:200]}\n" - f"Effects: {p.get('effects', '')[:200]}\n" + f"Description: {p.get('description', '')[:DESCRIPTION_TRUNCATE]}\n" + f"Ingredients: {p.get('ingredients', '')[:FIELD_TRUNCATE]}\n" + f"Effects: {p.get('effects', '')[:FIELD_TRUNCATE]}\n" f"Sample review: {review_sample}\n" - "4 sentences max. Friendly, informative. No markdown. Not medical advice." + "Keep it under 40 words. Friendly, informative. " + "Plain spoken English only. No lists, no formatting. Not medical advice." ) return await asyncio.to_thread( self.capability_worker.text_to_text_response, prompt @@ -481,27 +462,14 @@ async def _detail_response(self, product_payload: dict) -> str: # ------------------------------------------------------------------------- def _wants_exit(self, user_input: str) -> bool: - lowered = user_input.lower().strip() - word_count = len(lowered.split()) - # Short inputs: substring match is safe; exit words won't appear accidentally. - if word_count <= 5: - if any(phrase in lowered for phrase in EXIT_WORDS): - return True - # LLM catches STT garbles of farewell phrases. - result = ( - self.capability_worker.text_to_text_response( - f"Does this mean the user wants to stop or say goodbye?\n" - f'Input: "{user_input}"\nReply YES or NO only.' - ) - .strip() - .upper() - ) - return result.startswith("YES") - # Longer inputs: exit words can appear inside unrelated sentences; use LLM only. result = ( self.capability_worker.text_to_text_response( - f"Does this input primarily mean the user wants to stop or say goodbye? " - f"Ignore incidental words like 'thanks' if the sentence has other content.\n" + f"Does this input mean the user wants to stop, leave, or say goodbye? " + f"YES examples: 'bye', 'thanks', 'im done', 'all set', 'i am good', " + f"'that is all', 'nothing else', 'ok thanks', 'cheers', 'sounds good thanks'. " + f"NO examples: 'joint pain', 'headache relief', 'no I need something for sleep', " + f"'tell me more about the first one'. " + f"If the sentence contains a health question or supplement request, reply NO. " f'Input: "{user_input}"\nReply YES or NO only.' ) .strip() @@ -540,6 +508,27 @@ def _is_health_query(self, user_input: str) -> bool | None: result = self.capability_worker.text_to_text_response(prompt).strip().upper() return result.startswith("YES") + def _normalize_query(self, user_input: str) -> str: + """ + Extract a clean health search phrase from raw (possibly garbled) STT input. + e.g. "I need something for joint bean" -> "joint pain supplements" + Returns the original input if normalization fails. + """ + raw = self.capability_worker.text_to_text_response( + f"Extract the core health or supplement search phrase from this voice input. " + f"Fix any garbled words to their most likely health-related meaning. " + f"Examples: 'I need something for joint bean' -> 'joint pain', " + f"'search for something for headache' -> 'headache relief', " + f"'find supplements for sleep iz shoes' -> 'sleep issues'. " + f"Reply with ONLY the 2-5 word search phrase, nothing else.\n" + f'Input: "{user_input}"' + ).strip() + cleaned = raw.strip("'\".") + if not cleaned or len(cleaned) > GUESS_MAX_LEN: + return user_input + self._log(f"Normalized query: '{user_input[:GUESS_MAX_LEN]}' -> '{cleaned}'") + return cleaned + def _guess_health_intent(self, user_input: str) -> str: """ Return the most likely health phrase the user meant (e.g. 'joint pain'), @@ -549,12 +538,13 @@ def _guess_health_intent(self, user_input: str) -> str: raw = self.capability_worker.text_to_text_response( f"This voice input may be garbled by speech recognition. " f"If it seems like the user was trying to ask about a health concern or supplement, " - f"reply with only the most likely 2-4 word health phrase they meant " - f"(e.g. 'joint pain', 'sleep issues', 'headache relief'). " + f"reply with only the most likely 2-4 word health phrase they meant. " + f"Examples: 'join te pin' -> 'joint pain', 'sleep iz shoes' -> 'sleep issues', " + f"'some senga for gently being' -> 'joint pain'. " f"If you cannot detect any health intent, reply with exactly: NONE\n" f'Input: "{user_input}"' ).strip() - if not raw or raw.upper() == "NONE" or len(raw) > 60: + if not raw or raw.upper() == "NONE" or len(raw) > GUESS_MAX_LEN: return "" return raw @@ -587,10 +577,20 @@ def _wants_detail(self, user_input: str, last_results: list) -> dict: return {} def _wants_rerank(self, user_input: str) -> str: - lowered = user_input.lower() - if any(w in lowered for w in ("best rated", "highest rated", "top rated")): + result = ( + self.capability_worker.text_to_text_response( + f"The user said: '{user_input}'. Are they asking to sort or rank results " + f"by rating, popularity, or reviews? " + f"Examples: 'best rated' = RATING_HIGH, 'most popular' = RATING_HIGH, " + f"'which has the best reviews' = RATING_HIGH, 'lowest rated' = RATING_LOW.\n" + f"Reply ONLY with: RATING_HIGH, RATING_LOW, or NO." + ) + .strip() + .upper() + ) + if "RATING_HIGH" in result: return "rating_high" - if any(w in lowered for w in ("lowest rated", "worst rated")): + if "RATING_LOW" in result: return "rating_low" return "" @@ -603,8 +603,7 @@ async def run(self): if not self._config_ok(): await self.capability_worker.speak( "Health Supplement Search isn't configured yet. " - "Please fill in your API keys in main.py and re-upload. " - "Check the README for setup instructions." + "Please add your API keys and re-upload the ability." ) self.capability_worker.resume_normal_flow() return @@ -621,15 +620,13 @@ async def run(self): if pending_input: await self.capability_worker.speak( - "Welcome to Health Supplement Search. This is informational only — not medical advice. " - "Let me search for that..." + "Welcome to Health Supplement Search — informational only, not medical advice. " + "Let me search for that." ) else: await self.capability_worker.speak( - "Welcome to Health Supplement Search. " - "I can help you find supplements for specific health concerns using a curated database " - "of 100 reviewed products. This is informational only — not medical advice. " - "What health concern can I help you with today?" + "Welcome to Health Supplement Search — informational only, not medical advice. " + "What health concern can I help you with?" ) idle_count = 0 @@ -677,11 +674,17 @@ async def run(self): "yes", "yep", "yeah", + "yup", "sure", "ok", "okay", "correct", "right", + "absolutely", + "go ahead", + "do it", + "sounds good", + "for sure", ) ): pending_input = pending_guess @@ -748,7 +751,9 @@ async def run(self): ) continue if not health_check: - self._log(f"Off-topic input rejected: {user_input[:60]}") + self._log( + f"Off-topic input rejected: {user_input[:GUESS_MAX_LEN]}" + ) guess = self._guess_health_intent(user_input) if guess: pending_guess = guess @@ -764,18 +769,19 @@ async def run(self): ) continue + search_query = self._normalize_query(user_input) await self.capability_worker.speak("Let me search for that...") - results, source = await self._search_with_fallback(user_input) + results, source = await self._search_with_fallback(search_query) self._last_results = results self._last_source = source if source == "curated": await self.capability_worker.speak( - await self._summarize_curated(user_input, results) + await self._summarize_curated(search_query, results) ) elif source == "web": await self.capability_worker.speak( - await self._summarize_web(user_input, results) + await self._summarize_web(search_query, results) ) else: await self.capability_worker.speak(