Spaces:
Running on Zero
Running on Zero
| """Keyless agent tools for the local Pika companion. | |
| These helpers give the offline assistant a few real, deterministic facts to | |
| ground its replies: the current local time, live weather (via the keyless | |
| Open-Meteo API), and a keyless web lookup (via the DuckDuckGo Instant Answer | |
| API). Every network call has a short timeout and degrades gracefully -- these | |
| functions never raise so the assistant path stays safe to call inline. | |
| No API keys and no third-party dependencies: stdlib ``urllib`` only. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from datetime import datetime | |
| from typing import Any | |
| from urllib import error as urlerror | |
| from urllib import parse as urlparse | |
| from urllib import request as urlrequest | |
| _NETWORK_TIMEOUT = 6.0 | |
| _DEFAULT_LOCATION = "San Francisco" | |
| _USER_AGENT = "PocketDM-Pika/1.0 (keyless agent tools)" | |
| _WEATHER_CODES = { | |
| 0: "clear", | |
| 1: "mostly clear", | |
| 2: "partly cloudy", | |
| 3: "overcast", | |
| 45: "foggy", | |
| 48: "foggy", | |
| 51: "drizzly", | |
| 53: "drizzly", | |
| 55: "drizzly", | |
| 56: "freezing drizzle", | |
| 57: "freezing drizzle", | |
| 61: "rainy", | |
| 63: "rainy", | |
| 65: "heavy rain", | |
| 66: "freezing rain", | |
| 67: "freezing rain", | |
| 71: "snowy", | |
| 73: "snowy", | |
| 75: "heavy snow", | |
| 77: "snow grains", | |
| 80: "rain showers", | |
| 81: "rain showers", | |
| 82: "heavy rain showers", | |
| 85: "snow showers", | |
| 86: "snow showers", | |
| 95: "thunderstorms", | |
| 96: "thunderstorms with hail", | |
| 99: "thunderstorms with hail", | |
| } | |
| def now_fact() -> str: | |
| """Return the current local time and date as a single sentence. | |
| Example: ``"It is 2:45 PM on Monday, June 16, 2026."``. No network access. | |
| """ | |
| now = datetime.now().astimezone() | |
| time_text = now.strftime("%I:%M %p").lstrip("0") | |
| date_text = now.strftime("%A, %B %d, %Y").replace(" 0", " ") | |
| return f"It is {time_text} on {date_text}." | |
| def weather_fact(location: str | None = None) -> str: | |
| """Return a short live-weather line via the keyless Open-Meteo API. | |
| Geocodes ``location`` with Open-Meteo's free geocoding endpoint. When | |
| ``location`` is ``None`` it falls back to ``POCKETDM_DEFAULT_LOCATION`` or a | |
| sensible default. On any failure it returns a graceful string instead of | |
| raising. | |
| """ | |
| query = (location or os.environ.get("POCKETDM_DEFAULT_LOCATION") or _DEFAULT_LOCATION).strip() | |
| if not query: | |
| query = _DEFAULT_LOCATION | |
| try: | |
| place = _geocode_location(query) | |
| if place is None: | |
| return f"I couldn't find weather for {query} right now." | |
| latitude, longitude, label = place | |
| weather = _fetch_current_weather(latitude, longitude) | |
| if weather is None: | |
| return "I couldn't reach a live forecast right now." | |
| temperature, code = weather | |
| condition = _WEATHER_CODES.get(code, "cloudy") | |
| return f"It's {temperature}°C and {condition} in {label}." | |
| except Exception: | |
| return "I couldn't reach a live forecast right now." | |
| def web_search_fact(query: str) -> str: | |
| """Return a short keyless web-search answer via the DuckDuckGo Instant Answer API. | |
| Prefers the ``AbstractText`` / ``Answer`` fields, then falls back to the | |
| first related topic. Trimmed to ~280 characters. On any failure it returns a | |
| graceful string instead of raising. | |
| """ | |
| cleaned = (query or "").strip() | |
| if not cleaned: | |
| return "I need something to look up first." | |
| try: | |
| params = urlparse.urlencode( | |
| {"q": cleaned, "format": "json", "no_html": "1", "skip_disambig": "1"} | |
| ) | |
| data = _get_json(f"https://api.duckduckgo.com/?{params}") | |
| if not isinstance(data, dict): | |
| return "I couldn't find a quick answer for that right now." | |
| answer = _best_duckduckgo_text(data) | |
| if not answer: | |
| return f"I couldn't find a quick answer about {cleaned}." | |
| return _trim(answer, 280) | |
| except Exception: | |
| return "I couldn't reach a search source right now." | |
| def gather_tool_facts(user_message: str) -> str | None: | |
| """Detect tool intent in ``user_message`` and return grounded facts, or None. | |
| Returns a combined facts string to inject into the LLM prompt when the | |
| message asks about the time/date, the weather, or is a general factual | |
| question / explicit lookup. Returns ``None`` when no tool applies. | |
| """ | |
| message = (user_message or "").strip() | |
| if not message: | |
| return None | |
| lowered = message.casefold() | |
| wants_time = _wants_time(lowered) | |
| wants_weather = _wants_weather(lowered) | |
| facts: list[str] = [] | |
| if wants_time: | |
| facts.append(now_fact()) | |
| if wants_weather: | |
| facts.append(weather_fact(_extract_location(message))) | |
| # Web search is the fallback only when the message is not already a | |
| # time/weather question, so "what time is it?" stays a pure time answer. | |
| if not wants_time and not wants_weather and _wants_web_search(lowered): | |
| facts.append(web_search_fact(message)) | |
| if not facts: | |
| return None | |
| return " ".join(facts) | |
| def _wants_time(lowered: str) -> bool: | |
| text = lowered.strip(" ?!.") | |
| if text in {"time", "date", "day"}: | |
| return True | |
| return any( | |
| phrase in lowered | |
| for phrase in ( | |
| "what time", | |
| "time is it", | |
| "the time", | |
| "time now", | |
| "current time", | |
| "what date", | |
| "what day", | |
| "today's date", | |
| "todays date", | |
| "date today", | |
| "day of the week", | |
| ) | |
| ) | |
| def _wants_weather(lowered: str) -> bool: | |
| return any( | |
| word in lowered | |
| for word in ( | |
| "weather", | |
| "forecast", | |
| "temperature", | |
| "how hot", | |
| "how cold", | |
| "raining", | |
| "snowing", | |
| "umbrella", | |
| ) | |
| ) | |
| _SEARCH_TRIGGERS = ( | |
| "search", | |
| "look up", | |
| "lookup", | |
| "google", | |
| "who is", | |
| "who was", | |
| "what is", | |
| "what was", | |
| "what are", | |
| "when is", | |
| "when was", | |
| "where is", | |
| "where was", | |
| "how many", | |
| "how tall", | |
| "how far", | |
| "tell me about", | |
| "find out", | |
| ) | |
| def _wants_web_search(lowered: str) -> bool: | |
| if any(trigger in lowered for trigger in _SEARCH_TRIGGERS): | |
| return True | |
| # A bare question that is not about the pet/adventure itself. | |
| return lowered.rstrip().endswith("?") and lowered.startswith( | |
| ("who ", "what ", "when ", "where ", "why ", "how ", "which ") | |
| ) | |
| _LOCATION_PREPOSITIONS = (" in ", " at ", " for ", " near ", " around ") | |
| def _extract_location(message: str) -> str | None: | |
| """Pull a named location out of a weather question, if one is present.""" | |
| lowered = message.casefold() | |
| cut = len(message) | |
| for preposition in _LOCATION_PREPOSITIONS: | |
| index = lowered.rfind(preposition) | |
| if index >= 0: | |
| tail = message[index + len(preposition):].strip() | |
| tail = tail.rstrip(" ?!.").strip() | |
| tail = _strip_trailing_time_words(tail) | |
| if tail: | |
| return tail | |
| cut = min(cut, index) | |
| return None | |
| def _strip_trailing_time_words(text: str) -> str: | |
| for suffix in (" today", " tonight", " tomorrow", " right now", " now", " this week"): | |
| if text.casefold().endswith(suffix): | |
| return text[: -len(suffix)].strip() | |
| return text | |
| def _geocode_location(query: str) -> tuple[float, float, str] | None: | |
| params = urlparse.urlencode({"name": query, "count": 1, "language": "en", "format": "json"}) | |
| data = _get_json(f"https://geocoding-api.open-meteo.com/v1/search?{params}") | |
| if not isinstance(data, dict): | |
| return None | |
| results = data.get("results") | |
| if not results: | |
| return None | |
| top = results[0] | |
| latitude = top.get("latitude") | |
| longitude = top.get("longitude") | |
| if latitude is None or longitude is None: | |
| return None | |
| label = top.get("name") or query | |
| return float(latitude), float(longitude), str(label) | |
| def _fetch_current_weather(latitude: float, longitude: float) -> tuple[int, int] | None: | |
| params = urlparse.urlencode( | |
| { | |
| "latitude": latitude, | |
| "longitude": longitude, | |
| "current": "temperature_2m,weather_code", | |
| } | |
| ) | |
| data = _get_json(f"https://api.open-meteo.com/v1/forecast?{params}") | |
| if not isinstance(data, dict): | |
| return None | |
| current = data.get("current") | |
| if not isinstance(current, dict): | |
| return None | |
| temperature = current.get("temperature_2m") | |
| code = current.get("weather_code") | |
| if temperature is None or code is None: | |
| return None | |
| return round(float(temperature)), int(code) | |
| def _best_duckduckgo_text(data: dict[str, Any]) -> str: | |
| for key in ("AbstractText", "Answer"): | |
| value = data.get(key) | |
| if isinstance(value, str) and value.strip(): | |
| return value.strip() | |
| definition = data.get("Definition") | |
| if isinstance(definition, str) and definition.strip(): | |
| return definition.strip() | |
| for topic in data.get("RelatedTopics", []) or []: | |
| if isinstance(topic, dict): | |
| text = topic.get("Text") | |
| if isinstance(text, str) and text.strip(): | |
| return text.strip() | |
| for nested in topic.get("Topics", []) or []: | |
| if isinstance(nested, dict): | |
| nested_text = nested.get("Text") | |
| if isinstance(nested_text, str) and nested_text.strip(): | |
| return nested_text.strip() | |
| return "" | |
| def _get_json(url: str) -> Any: | |
| request = urlrequest.Request(url, headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}) | |
| try: | |
| with urlrequest.urlopen(request, timeout=_NETWORK_TIMEOUT) as response: | |
| raw = response.read().decode("utf-8") | |
| except (OSError, urlerror.URLError, ValueError): | |
| return None | |
| try: | |
| return json.loads(raw) | |
| except (ValueError, TypeError): | |
| return None | |
| def _trim(text: str, limit: int) -> str: | |
| compact = " ".join(text.split()) | |
| if len(compact) <= limit: | |
| return compact | |
| clipped = compact[: limit - 1].rstrip() | |
| sentence_end = max(clipped.rfind("."), clipped.rfind("!"), clipped.rfind("?")) | |
| if sentence_end >= limit // 2: | |
| return clipped[: sentence_end + 1] | |
| return clipped.rstrip(" ,;:") + "…" | |