From 921e5be8e640c65992b17fbe721d288f56c2e40e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:48:24 +0000 Subject: [PATCH] fix(skills/local-places): copy files instead of submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submodules are pain. Just copy the Python code directly. 🦞 --- .gitmodules | 3 - skills/local-places/SERVER_README.md | 101 ++++++ skills/local-places/SKILL.md | 3 +- skills/local-places/pyproject.toml | 27 ++ skills/local-places/server | 1 - .../local-places/src/local_places/__init__.py | 2 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 218 bytes .../__pycache__/google_places.cpython-314.pyc | Bin 0 -> 14487 bytes .../__pycache__/main.cpython-314.pyc | Bin 0 -> 3794 bytes .../__pycache__/schemas.cpython-314.pyc | Bin 0 -> 6290 bytes .../src/local_places/google_places.py | 314 ++++++++++++++++++ skills/local-places/src/local_places/main.py | 65 ++++ .../local-places/src/local_places/schemas.py | 107 ++++++ 13 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 skills/local-places/SERVER_README.md create mode 100644 skills/local-places/pyproject.toml delete mode 160000 skills/local-places/server create mode 100644 skills/local-places/src/local_places/__init__.py create mode 100644 skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/google_places.py create mode 100644 skills/local-places/src/local_places/main.py create mode 100644 skills/local-places/src/local_places/schemas.py diff --git a/.gitmodules b/.gitmodules index 673aa118c..096e18c88 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,3 @@ path = Peekaboo url = https://github.com/steipete/Peekaboo.git branch = main -[submodule "skills/local-places/server"] - path = skills/local-places/server - url = https://github.com/Hyaxia/local_places.git diff --git a/skills/local-places/SERVER_README.md b/skills/local-places/SERVER_README.md new file mode 100644 index 000000000..1a69931f2 --- /dev/null +++ b/skills/local-places/SERVER_README.md @@ -0,0 +1,101 @@ +# Local Places + +This repo is a fusion of two pieces: + +- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API. +- A companion agent skill that explains how to use the API and can call it to find places efficiently. + +Together, the skill and server let an agent turn natural-language place queries into structured results quickly. + +## Run locally + +```bash +# copy skill definition into the relevant folder (where the agent looks for it) +# then run the server + +uv venv +uv pip install -e ".[dev]" +uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload +``` + +Open the API docs at http://127.0.0.1:8000/docs. + +## Places API + +Set the Google Places API key before running: + +```bash +export GOOGLE_PLACES_API_KEY="your-key" +``` + +Endpoints: + +- `POST /places/search` (free-text query + filters) +- `GET /places/{place_id}` (place details) +- `POST /locations/resolve` (resolve a user-provided location string) + +Example search request: + +```json +{ + "query": "italian restaurant", + "filters": { + "types": ["restaurant"], + "open_now": true, + "min_rating": 4.0, + "price_levels": [1, 2] + }, + "limit": 10 +} +``` + +Notes: + +- `filters.types` supports a single type (mapped to Google `includedType`). + +Example search request (curl): + +```bash +curl -X POST http://127.0.0.1:8000/places/search \ + -H "Content-Type: application/json" \ + -d '{ + "query": "italian restaurant", + "location_bias": { + "lat": 40.8065, + "lng": -73.9719, + "radius_m": 3000 + }, + "filters": { + "types": ["restaurant"], + "open_now": true, + "min_rating": 4.0, + "price_levels": [1, 2, 3] + }, + "limit": 10 + }' +``` + +Example resolve request (curl): + +```bash +curl -X POST http://127.0.0.1:8000/locations/resolve \ + -H "Content-Type: application/json" \ + -d '{ + "location_text": "Riverside Park, New York", + "limit": 5 + }' +``` + +## Test + +```bash +uv run pytest +``` + +## OpenAPI + +Generate the OpenAPI schema: + +```bash +uv run python scripts/generate_openapi.py +``` diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md index 8e62f4cef..bc563d419 100644 --- a/skills/local-places/SKILL.md +++ b/skills/local-places/SKILL.md @@ -12,14 +12,13 @@ Search for nearby places using a local Google Places API proxy. Two-step flow: r ## Setup ```bash -cd {baseDir}/server +cd {baseDir} echo "GOOGLE_PLACES_API_KEY=your-key" > .env uv venv && uv pip install -e ".[dev]" uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000 ``` Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment. -Server code is in `{baseDir}/server/` (submodule from Hyaxia/local_places). ## Quick Start diff --git a/skills/local-places/pyproject.toml b/skills/local-places/pyproject.toml new file mode 100644 index 000000000..c59e336a1 --- /dev/null +++ b/skills/local-places/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "my-api" +version = "0.1.0" +description = "FastAPI server" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110.0", + "httpx>=0.27.0", + "uvicorn[standard]>=0.29.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/local_places"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] diff --git a/skills/local-places/server b/skills/local-places/server deleted file mode 160000 index bfc3becfc..000000000 --- a/skills/local-places/server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfc3becfc48af865722ef35ee7cca753519dd93e diff --git a/skills/local-places/src/local_places/__init__.py b/skills/local-places/src/local_places/__init__.py new file mode 100644 index 000000000..07c5de9e2 --- /dev/null +++ b/skills/local-places/src/local_places/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a17848a45a0a0a69ee91bccfa284ee891b61154 GIT binary patch literal 218 zcmdPqD`4)G4d|7Hy zab|vAe0&wFfu5nBfuAPRE%x~M#GIV?_#!5twv`N@L8jbt(hn^Ls?{$pNzE)sElJf6 zD9X=DO)e?c&&f|t%!x0^NlZ=!N*5)g3dF}}=4F<|$LkeT-r}&y%}*)KNwq6t2O0@- eU9ljL_`uA_$asTWqC>xd{RW?C6L%3SP!s^PnmC#O literal 0 HcmV?d00001 diff --git a/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94944facf8666a5131c4bb4a8855f5acfed649c5 GIT binary patch literal 14487 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𔴣^if{SeGuq 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 literal 0 HcmV?d00001 diff --git a/skills/local-places/src/local_places/google_places.py b/skills/local-places/src/local_places/google_places.py new file mode 100644 index 000000000..5a9bd60a3 --- /dev/null +++ b/skills/local-places/src/local_places/google_places.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import logging +import os +from typing import Any + +import httpx +from fastapi import HTTPException + +from local_places.schemas import ( + LatLng, + LocationResolveRequest, + LocationResolveResponse, + PlaceDetails, + PlaceSummary, + ResolvedLocation, + SearchRequest, + SearchResponse, +) + +GOOGLE_PLACES_BASE_URL = os.getenv( + "GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1" +) +logger = logging.getLogger("local_places.google_places") + +_PRICE_LEVEL_TO_ENUM = { + 0: "PRICE_LEVEL_FREE", + 1: "PRICE_LEVEL_INEXPENSIVE", + 2: "PRICE_LEVEL_MODERATE", + 3: "PRICE_LEVEL_EXPENSIVE", + 4: "PRICE_LEVEL_VERY_EXPENSIVE", +} +_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()} + +_SEARCH_FIELD_MASK = ( + "places.id," + "places.displayName," + "places.formattedAddress," + "places.location," + "places.rating," + "places.priceLevel," + "places.types," + "places.currentOpeningHours," + "nextPageToken" +) + +_DETAILS_FIELD_MASK = ( + "id," + "displayName," + "formattedAddress," + "location," + "rating," + "priceLevel," + "types," + "regularOpeningHours," + "currentOpeningHours," + "nationalPhoneNumber," + "websiteUri" +) + +_RESOLVE_FIELD_MASK = ( + "places.id," + "places.displayName," + "places.formattedAddress," + "places.location," + "places.types" +) + + +class _GoogleResponse: + def __init__(self, response: httpx.Response): + self.status_code = response.status_code + self._response = response + + def json(self) -> dict[str, Any]: + return self._response.json() + + @property + def text(self) -> str: + return self._response.text + + +def _api_headers(field_mask: str) -> dict[str, str]: + api_key = os.getenv("GOOGLE_PLACES_API_KEY") + if not api_key: + raise HTTPException( + status_code=500, + detail="GOOGLE_PLACES_API_KEY is not set.", + ) + return { + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": field_mask, + } + + +def _request( + method: str, url: str, payload: dict[str, Any] | None, field_mask: str +) -> _GoogleResponse: + try: + with httpx.Client(timeout=10.0) as client: + response = client.request( + method=method, + url=url, + headers=_api_headers(field_mask), + json=payload, + ) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc + + return _GoogleResponse(response) + + +def _build_text_query(request: SearchRequest) -> str: + keyword = request.filters.keyword if request.filters else None + if keyword: + return f"{request.query} {keyword}".strip() + return request.query + + +def _build_search_body(request: SearchRequest) -> dict[str, Any]: + body: dict[str, Any] = { + "textQuery": _build_text_query(request), + "pageSize": request.limit, + } + + if request.page_token: + body["pageToken"] = request.page_token + + if request.location_bias: + body["locationBias"] = { + "circle": { + "center": { + "latitude": request.location_bias.lat, + "longitude": request.location_bias.lng, + }, + "radius": request.location_bias.radius_m, + } + } + + if request.filters: + filters = request.filters + if filters.types: + body["includedType"] = filters.types[0] + if filters.open_now is not None: + body["openNow"] = filters.open_now + if filters.min_rating is not None: + body["minRating"] = filters.min_rating + if filters.price_levels: + body["priceLevels"] = [ + _PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels + ] + + return body + + +def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None: + if not raw: + return None + latitude = raw.get("latitude") + longitude = raw.get("longitude") + if latitude is None or longitude is None: + return None + return LatLng(lat=latitude, lng=longitude) + + +def _parse_display_name(raw: dict[str, Any] | None) -> str | None: + if not raw: + return None + return raw.get("text") + + +def _parse_open_now(raw: dict[str, Any] | None) -> bool | None: + if not raw: + return None + return raw.get("openNow") + + +def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None: + if not raw: + return None + return raw.get("weekdayDescriptions") + + +def _parse_price_level(raw: str | None) -> int | None: + if not raw: + return None + return _ENUM_TO_PRICE_LEVEL.get(raw) + + +def search_places(request: SearchRequest) -> SearchResponse: + url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText" + response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + places = payload.get("places", []) + results = [] + for place in places: + results.append( + PlaceSummary( + place_id=place.get("id", ""), + name=_parse_display_name(place.get("displayName")), + address=place.get("formattedAddress"), + location=_parse_lat_lng(place.get("location")), + rating=place.get("rating"), + price_level=_parse_price_level(place.get("priceLevel")), + types=place.get("types"), + open_now=_parse_open_now(place.get("currentOpeningHours")), + ) + ) + + return SearchResponse( + results=results, + next_page_token=payload.get("nextPageToken"), + ) + + +def get_place_details(place_id: str) -> PlaceDetails: + url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}" + response = _request("GET", url, None, _DETAILS_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + return PlaceDetails( + place_id=payload.get("id", place_id), + name=_parse_display_name(payload.get("displayName")), + address=payload.get("formattedAddress"), + location=_parse_lat_lng(payload.get("location")), + rating=payload.get("rating"), + price_level=_parse_price_level(payload.get("priceLevel")), + types=payload.get("types"), + phone=payload.get("nationalPhoneNumber"), + website=payload.get("websiteUri"), + hours=_parse_hours(payload.get("regularOpeningHours")), + open_now=_parse_open_now(payload.get("currentOpeningHours")), + ) + + +def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse: + url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText" + body = {"textQuery": request.location_text, "pageSize": request.limit} + response = _request("POST", url, body, _RESOLVE_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + places = payload.get("places", []) + results = [] + for place in places: + results.append( + ResolvedLocation( + place_id=place.get("id", ""), + name=_parse_display_name(place.get("displayName")), + address=place.get("formattedAddress"), + location=_parse_lat_lng(place.get("location")), + types=place.get("types"), + ) + ) + + return LocationResolveResponse(results=results) diff --git a/skills/local-places/src/local_places/main.py b/skills/local-places/src/local_places/main.py new file mode 100644 index 000000000..1197719de --- /dev/null +++ b/skills/local-places/src/local_places/main.py @@ -0,0 +1,65 @@ +import logging +import os + +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from local_places.google_places import get_place_details, resolve_locations, search_places +from local_places.schemas import ( + LocationResolveRequest, + LocationResolveResponse, + PlaceDetails, + SearchRequest, + SearchResponse, +) + +app = FastAPI( + title="My API", + servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://maxims-macbook-air:8000")}], +) +logger = logging.getLogger("local_places.validation") + + +@app.get("/ping") +def ping() -> dict[str, str]: + return {"message": "pong"} + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + logger.error( + "Validation error on %s %s. body=%s errors=%s", + request.method, + request.url.path, + exc.body, + exc.errors(), + ) + return JSONResponse( + status_code=422, + content=jsonable_encoder({"detail": exc.errors()}), + ) + + +@app.post("/places/search", response_model=SearchResponse) +def places_search(request: SearchRequest) -> SearchResponse: + return search_places(request) + + +@app.get("/places/{place_id}", response_model=PlaceDetails) +def places_details(place_id: str) -> PlaceDetails: + return get_place_details(place_id) + + +@app.post("/locations/resolve", response_model=LocationResolveResponse) +def locations_resolve(request: LocationResolveRequest) -> LocationResolveResponse: + return resolve_locations(request) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("local_places.main:app", host="0.0.0.0", port=8000) diff --git a/skills/local-places/src/local_places/schemas.py b/skills/local-places/src/local_places/schemas.py new file mode 100644 index 000000000..e0590e659 --- /dev/null +++ b/skills/local-places/src/local_places/schemas.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field, field_validator + + +class LatLng(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + + +class LocationBias(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + radius_m: float = Field(gt=0) + + +class Filters(BaseModel): + types: list[str] | None = None + open_now: bool | None = None + min_rating: float | None = Field(default=None, ge=0, le=5) + price_levels: list[int] | None = None + keyword: str | None = Field(default=None, min_length=1) + + @field_validator("types") + @classmethod + def validate_types(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return value + if len(value) > 1: + raise ValueError( + "Only one type is supported. Use query/keyword for additional filtering." + ) + return value + + @field_validator("price_levels") + @classmethod + def validate_price_levels(cls, value: list[int] | None) -> list[int] | None: + if value is None: + return value + invalid = [level for level in value if level not in range(0, 5)] + if invalid: + raise ValueError("price_levels must be integers between 0 and 4.") + return value + + @field_validator("min_rating") + @classmethod + def validate_min_rating(cls, value: float | None) -> float | None: + if value is None: + return value + if (value * 2) % 1 != 0: + raise ValueError("min_rating must be in 0.5 increments.") + return value + + +class SearchRequest(BaseModel): + query: str = Field(min_length=1) + location_bias: LocationBias | None = None + filters: Filters | None = None + limit: int = Field(default=10, ge=1, le=20) + page_token: str | None = None + + +class PlaceSummary(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + rating: float | None = None + price_level: int | None = None + types: list[str] | None = None + open_now: bool | None = None + + +class SearchResponse(BaseModel): + results: list[PlaceSummary] + next_page_token: str | None = None + + +class LocationResolveRequest(BaseModel): + location_text: str = Field(min_length=1) + limit: int = Field(default=5, ge=1, le=10) + + +class ResolvedLocation(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + types: list[str] | None = None + + +class LocationResolveResponse(BaseModel): + results: list[ResolvedLocation] + + +class PlaceDetails(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + rating: float | None = None + price_level: int | None = None + types: list[str] | None = None + phone: str | None = None + website: str | None = None + hours: list[str] | None = None + open_now: bool | None = None