pocket-pikachu / agent_tools.py
amal-david's picture
Full stack on ZeroGPU: VoxCPM voice + Nemotron ASR + MiniCPM brain (@spaces.GPU)
e2c2f5b verified
Raw
History Blame Contribute Delete
10.5 kB
"""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(" ,;:") + "…"