From 69e56d3db11b5bdb05c0781b9ae32cab3f4e1e2c Mon Sep 17 00:00:00 2001 From: Shelby James Date: Mon, 2 Nov 2026 16:46:10 -0500 Subject: [PATCH 1/2] feat(integrations): add Google Street View and Maps Platform integration --- docs/guides/README.md | 1 + docs/guides/integrations/street_view/index.md | 156 +++++ .../adk/integrations/street_view/README.md | 118 ++++ .../adk/integrations/street_view/__init__.py | 65 ++ .../adk/integrations/street_view/_config.py | 145 ++++ .../adk/integrations/street_view/_models.py | 124 ++++ .../adk/integrations/street_view/_services.py | 640 ++++++++++++++++++ .../street_view/_street_view_toolset.py | 167 +++++ .../adk/integrations/street_view/_tools.py | 451 ++++++++++++ .../integrations/street_view/__init__.py | 13 + .../street_view/test_street_view.py | 558 +++++++++++++++ 11 files changed, 2438 insertions(+) create mode 100644 docs/guides/integrations/street_view/index.md create mode 100644 src/google/adk/integrations/street_view/README.md create mode 100644 src/google/adk/integrations/street_view/__init__.py create mode 100644 src/google/adk/integrations/street_view/_config.py create mode 100644 src/google/adk/integrations/street_view/_models.py create mode 100644 src/google/adk/integrations/street_view/_services.py create mode 100644 src/google/adk/integrations/street_view/_street_view_toolset.py create mode 100644 src/google/adk/integrations/street_view/_tools.py create mode 100644 tests/unittests/integrations/street_view/__init__.py create mode 100644 tests/unittests/integrations/street_view/test_street_view.py diff --git a/docs/guides/README.md b/docs/guides/README.md index 556d593836d..767acfcbdd5 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -83,6 +83,7 @@ This directory contains specific developer guides for the ADK Python implementat * [MongoDbToolset](integrations/mongodb/mongodb_toolset/index.md) - Vector and hybrid search over a MongoDB database, with the query text embedded on the way through. * [OpenAIDecisionsClassifier](integrations/openai/openai_decisions_classifier/index.md) - Answering `Choice`, `Binary`, and `Score` questions with the OpenAI Decisions API, including questions OpenAI declines to answer. * [RedisSessionService](integrations/redis/redis_session_service/index.md) - Sharing sessions across processes through Redis, including the expiry every other backend lacks. +* [StreetViewToolset](integrations/street_view/index.md) - Multimodal spatial verification, Street View outdoor baselines, address classification, and reverse geocoding. * [TeamsRunner](integrations/teams/teams_runner/index.md) - Running an agent as a Microsoft Teams bot, and why replies arrive after the request is acknowledged. * [TypesafeClassifier](integrations/typesafe/typesafe_classifier/index.md) - Answering `Choice`, `Binary`, and `Score` questions with TypeSafe's Jev decision models instead of a model turn. diff --git a/docs/guides/integrations/street_view/index.md b/docs/guides/integrations/street_view/index.md new file mode 100644 index 00000000000..d778d421051 --- /dev/null +++ b/docs/guides/integrations/street_view/index.md @@ -0,0 +1,156 @@ +# StreetViewToolset + +`StreetViewToolset` connects ADK agents to Google Maps Platform APIs—Street View Static API, Address Validation API, and Reverse Geocoding API—for multimodal spatial verification, cold-start visual baselines, and property boundary classification. + +## Introduction + +Agents that verify physical locations (such as disputed deliveries, insurance claims, merchant storefront checks, or field-service inspections) often encounter two gaps: + +1. **Cold-start visual baselines**: Newly serviced addresses have no historical ground-truth photos to compare against a submitted photo. +2. **Property boundary ambiguity**: A single-family home has a tight drop-off boundary around its porch or garage, whereas a multi-unit apartment complex has distributed drop-off locations across lobbies, mailrooms, and parcel lockers. + +`StreetViewToolset` bundles three tools (`street_view_fallback_tool`, `property_classification_tool`, and `reverse_geocode_tool`) backed by `GoogleMapsConfig` and structured Pydantic models (`StreetViewResult`, `PropertyClassification`, and `VisualAuditVerdict`). + +## Get started + +Core HTTP and validation features work out of the box with `google-adk`. If you resolve API keys from Google Cloud Secret Manager, install the `gcp` extra: + +```shell +pip install 'google-adk[gcp]' +``` + +Configure `MAPS_API_KEY` (or pass `GoogleMapsConfig`) and attach `StreetViewToolset` to an `LlmAgent`: + +```python +from google.adk.agents import LlmAgent +from google.adk.integrations.street_view import GoogleMapsConfig +from google.adk.integrations.street_view import StreetViewToolset +from google.adk.integrations.street_view import default_config + +agent = LlmAgent( + name="spatial_auditor", + model="gemini-2.5-flash", + instruction=( + "Classify the recipient address, verify GPS telemetry with reverse " + "geocoding, and capture a Street View baseline when prior imagery is " + "unavailable." + ), + tools=[ + StreetViewToolset( + config=GoogleMapsConfig( + default_pitch=default_config.default_pitch, + default_fov=120, + ) + ) + ], +) +``` + +You can also register individual standalone tool functions directly on an agent: + +```python +from google.adk.agents import LlmAgent +from google.adk.integrations.street_view import property_classification_tool +from google.adk.integrations.street_view import reverse_geocode_tool +from google.adk.integrations.street_view import street_view_fallback_tool + +agent = LlmAgent( + name="lightweight_auditor", + model="gemini-2.5-flash", + tools=[ + street_view_fallback_tool, + property_classification_tool, + reverse_geocode_tool, + ], +) +``` + +## How it works + +### Two-step Street View pre-flight and compass bearing + +`fetch_street_view_baseline` queries the Street View Metadata endpoint (`source="outdoor"`) before requesting image tiles: + +1. Coordinates are checked with `validate_coordinates` to ensure finite WGS84 bounds. +2. If no outdoor panorama exists (`ZERO_RESULTS`), the call returns `StreetViewResult(available=False, ...)` immediately without billing Static API image quota or downloading gray placeholder tiles. +3. When outdoor imagery exists, `calculate_compass_bearing` computes the forward azimuth (0–360°) from the Street View camera vehicle's road coordinates toward the target property coordinates so the panorama faces the building rather than down the street. +4. `street_view_fallback_tool` saves the JPEG bytes as an ADK artifact named `STREET_VIEW_ARTIFACT_FILENAME` (`"street_view_baseline.jpg"`) and writes JSON-safe metadata (`street_view_url`, `street_view_pano_id`, `street_view_imagery_date`, `street_view_heading`) into `tool_context.state`. + +### Address validation and regex fallback + +`classify_property_address_async` (and synchronous `classify_property_address`) sends the raw address to the Address Validation API (`validateAddress`) using the `X-Goog-Api-Key` header: + +- Extracts normalized street, locality, state, postal code, and subpremise components into `PropertyClassification` with `AddressType.SINGLE_FAMILY` or `AddressType.MULTI_UNIT`. +- Checks `missingComponentTypes` for `"subpremise"` or `"room"` to flag multi-unit buildings where the customer omitted an apartment or suite number (`is_missing_subunit=True`). +- Falls back to deterministic regex sub-unit parsing if `MAPS_API_KEY` is unconfigured or the API returns a transient error (without caching failed lookups). + +### Reverse geocoding and caching + +`reverse_geocode_coordinates_async` (and synchronous `reverse_geocode_coordinates`) resolves GPS telemetry rounded to 5 decimal places (~1.1 m precision) into a formatted street address. Successful lookups are stored in a bounded LRU cache partitioned by a non-reversible SHA-256 key fingerprint; call `clear_caches` in tests or credential rotation hooks to reset cached entries. + +### Multimodal baseline comparison + +For custom workflows or tool pipelines, `audit_photo_with_fallback` compares a target photo against historical photos (or automatically falls back to Street View when `historical_photos` is empty) using `BASELINE_SYNTHESIS_PROMPT` and `AUDIT_VERDICT_PROMPT`, returning a structured `VisualAuditVerdict` dictionary classified by `VerdictLabel`: + +```python +from google import genai +from google.adk.integrations.street_view import AUDIT_VERDICT_PROMPT +from google.adk.integrations.street_view import BASELINE_SYNTHESIS_PROMPT +from google.adk.integrations.street_view import STREET_VIEW_ARTIFACT_FILENAME +from google.adk.integrations.street_view import audit_photo_with_fallback +from google.adk.integrations.street_view import calculate_compass_bearing +from google.adk.integrations.street_view import classify_property_address +from google.adk.integrations.street_view import classify_property_address_async +from google.adk.integrations.street_view import clear_caches +from google.adk.integrations.street_view import fetch_street_view_baseline +from google.adk.integrations.street_view import reverse_geocode_coordinates +from google.adk.integrations.street_view import reverse_geocode_coordinates_async +from google.adk.integrations.street_view import validate_coordinates + + +async def run_visual_audit(target_jpeg: bytes, tool_context): + client = genai.Client() + verdict = await audit_photo_with_fallback( + target_photo_bytes=target_jpeg, + historical_photos=None, + latitude=37.4220656, + longitude=-122.0840897, + tool_context=tool_context, + genai_client=client, + model="gemini-2.5-flash", + ) + return verdict +``` + +## Public API reference + +| Export | Description | +| :--- | :--- | +| `StreetViewToolset` | `BaseToolset` implementation exposing configurable Maps & Street View `FunctionTool` instances with `tool_filter` and `tool_name_prefix` support. | +| `GoogleMapsConfig` | Credential and request configuration resolving keys from `api_key`, `MAPS_API_KEY`, or Secret Manager (`MAPS_API_KEY_SECRET_RESOURCE`). | +| `default_config` | Module-level default `GoogleMapsConfig` instance used by standalone tools. | +| `street_view_fallback_tool` | Async ADK tool function that captures outdoor Street View imagery into session artifacts and state. | +| `property_classification_tool` | Async ADK tool function that standardizes addresses and classifies single-family vs. multi-unit boundaries. | +| `reverse_geocode_tool` | Async ADK tool function that resolves GPS coordinates into a street address in session state. | +| `audit_photo_with_fallback` | Two-stage Gemini Vision helper that synthesizes a visual baseline and scores a target photo. | +| `fetch_street_view_baseline` | Low-level async service executing the Street View Metadata pre-flight check and image fetch. | +| `classify_property_address_async` | Async Address Validation service with deterministic local regex fallback. | +| `classify_property_address` | Synchronous counterpart to `classify_property_address_async`. | +| `reverse_geocode_coordinates_async` | Async Reverse Geocoding service with bounded LRU caching. | +| `reverse_geocode_coordinates` | Synchronous counterpart to `reverse_geocode_coordinates_async`. | +| `calculate_compass_bearing` | Computes forward compass heading (0–360°) from camera coordinates to target coordinates. | +| `validate_coordinates` | Validates finite WGS84 latitude `[-90, 90]` and longitude `[-180, 180]` bounds. | +| `clear_caches` | Clears the in-memory bounded LRU caches for address validation and reverse geocoding. | +| `AddressType` | `StrEnum` with `SINGLE_FAMILY` and `MULTI_UNIT` values. | +| `PropertyClassification` | Pydantic model representing normalized address components and sub-unit flags. | +| `StreetViewResult` | Pydantic model holding panorama bytes, deep-link URL, `pano_id`, `imagery_date`, and `heading`. | +| `VerdictLabel` | `Literal` union of `CONFIRMED_MATCH`, `CONFIRMED_MISMATCH`, `PLAUSIBLE_ALTERNATE_LOCATION`, and `INSUFFICIENT_VISUAL_CONTEXT`. | +| `VisualAuditVerdict` | Pydantic model returned by multimodal baseline comparison. | +| `STREET_VIEW_ARTIFACT_FILENAME` | Default ADK artifact filename (`"street_view_baseline.jpg"`). | +| `BASELINE_SYNTHESIS_PROMPT` | Reference prompt template for extracting stable architectural features from baseline imagery. | +| `AUDIT_VERDICT_PROMPT` | Reference prompt template for scoring target imagery against property boundaries. | + +## Limitations + +- **Outdoor coverage only**: `fetch_street_view_baseline` filters with `source="outdoor"` to avoid indoor photospheres, so gated communities or private roads without public Street View coverage return `available=False`. +- **Imagery recency**: Street View panoramas reflect the capture date reported in `StreetViewResult.imagery_date` (`YYYY-MM`) rather than real-time conditions; visual comparison prompts therefore focus on stable architectural features rather than transient objects or seasonal foliage. diff --git a/src/google/adk/integrations/street_view/README.md b/src/google/adk/integrations/street_view/README.md new file mode 100644 index 00000000000..2f36a0c17e2 --- /dev/null +++ b/src/google/adk/integrations/street_view/README.md @@ -0,0 +1,118 @@ +# Street View & Google Maps Platform Integration for ADK + +Integrates **Google Maps Platform APIs** (Street View Static API, Address Validation API, and Geocoding API) with **Google GenAI / Gemini Vision** for multimodal spatial and physical verification workflows in ADK. + +## Architecture Overview + +In multimodal spatial and physical verification workflows (such as proof-of-delivery audits, field inspections, real estate assessments, and insurance claim reviews), agents often face two distinct challenges: + +1. **Cold-Start Visual Disconnect ($N = 0$)**: When an agent needs to visually audit a target scene against historical baseline photos, newly serviced addresses or sparse historical data offer zero prior imagery. +2. **Ambiguous Property Boundaries**: Knowing whether an address is a **single-family home** (tight 50–150 ft drop-off boundary) or a **multi-unit apartment complex** (loose, distributed drop-off points across leasing offices, parcel lockers, or building clusters) determines the spatial decision boundary. + +```text + ┌──────────────────────────────────────────────┐ + │ Incoming Event │ + │ (GPS Lat/Lng, Raw Address) │ + └──────────────────────┬───────────────────────┘ + │ + ┌─────────────────────┴──────────────────────┐ + ▼ ▼ + ┌───────────────────────────────┐ ┌───────────────────────────────┐ + │ Address Validation API │ │ Reverse Geocoding API │ + │ (Standardize, classify unit, │ │ (Resolve drop-off GPS into │ + │ detect omitted apartment #) │ │ physical street address) │ + └───────────────┬───────────────┘ └───────────────┬───────────────┘ + │ │ + ▼ ▼ + [Property Class] [Street Context] + (SINGLE_FAMILY vs. (Saved to ADK State) + MULTI_UNIT) │ + │ │ + └─────────────────────┬──────────────────────┘ + │ + ┌─────────────────────┴──────────────────────┐ + │ Historical Imagery Available? │ + └──────┬──────────────────────────────┬──────┘ + Yes │ │ No (Cold Start) + ▼ ▼ + ┌──────────────────────────┐ ┌───────────────────────────┐ + │ GCS Historical Deliveries│ │ Street View Static API │ + │ (1..N Prior Photos) │ │ • Pre-flight metadata chk│ + └────────────┬─────────────┘ │ • Compute camera heading │ + │ │ • Fetch outdoor panorama │ + │ │ • Save ADK Artifact │ + │ └─────────────┬─────────────┘ + │ │ + └──────────────┬────────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ Gemini Vision Baseline │ + │ (Generate visual features │ + │ + score target photo) │ + └─────────────────────────────┘ +``` + +## Enterprise Use Cases + +| Vertical | Use Case | How `integrations.street_view` Solves It | +| :--- | :--- | :--- | +| **Logistics & Delivery** | Disputed Delivery ("Item Not Received") Audits | Classifies `SINGLE_FAMILY` vs. `MULTI_UNIT` boundaries, catches missing apartment numbers, reverse-geocodes driver GPS telemetry, and pulls Street View cold-start baselines ($N=0$). | +| **Property & Casualty Insurance** | Exterior Claim Triage & Underwriting | Captures pre-loss outdoor Street View imagery (including `imagery_date` metadata) to verify claimant-submitted property photos. | +| **FinTech & Marketplaces** | Merchant KYB & Storefront Verification | Validates `BUSINESS` vs. `RESIDENTIAL` address metadata and captures curb-view exterior imagery to confirm physical commercial signage. | +| **Field Services & Utilities** | Work-Order Completion Verification | Confirms technician inspection photos (EV chargers, solar installs, telecom drops) align with dispatched coordinates and surrounding streetscape. | +| **Real Estate & Lending** | Automated Desktop Appraisal Checks | Normalizes parcel/unit addresses and orients the Street View camera directly toward the target coordinates (`heading`) to cross-check listing imagery. | + +## Prerequisites & Setup + +Enable the required Google Maps Platform APIs on your Google Cloud project: + +- **Street View Static API** +- **Address Validation API** +- **Geocoding API** + +```shell +gcloud services enable \ + streetviewpublish.googleapis.com \ + static-maps-backend.googleapis.com \ + addressvalidation.googleapis.com \ + geocoding-backend.googleapis.com +``` + +Configure credentials via environment variable or Google Cloud Secret Manager: + +```shell +export MAPS_API_KEY="your-maps-api-key" + +# Optional: fetch from Secret Manager instead (requires pip install 'google-adk[gcp]') +export MAPS_API_KEY_SECRET_RESOURCE="projects/my-project/secrets/maps-api-key/versions/latest" +``` + +## Usage + +### Registering `StreetViewToolset` on an ADK Agent + +```python +from google.adk.agents import LlmAgent +from google.adk.integrations.street_view import GoogleMapsConfig +from google.adk.integrations.street_view import StreetViewToolset + +agent = LlmAgent( + name="spatial_auditor", + model="gemini-2.5-flash", + instruction="Verify delivery addresses, GPS telemetry, and outdoor imagery.", + tools=[ + StreetViewToolset( + config=GoogleMapsConfig(default_pitch=10, default_fov=120), + ) + ], +) +``` + +### Session State & Artifacts Written by Tools + +| Tool | Session State / Artifact Keys | Description | +| :--- | :--- | :--- | +| `street_view_fallback_tool` | Artifact `street_view_baseline.jpg`, `state["street_view_baseline_used"]`, `state["street_view_url"]`, `state["street_view_pano_id"]`, `state["street_view_imagery_date"]`, `state["street_view_heading"]` | Pre-flight checks `source="outdoor"`, computes compass bearing from the camera vehicle to target coordinates, and persists the panorama. | +| `property_classification_tool` | `state["address_classification"]`, `state["is_multi_unit"]`, `state["is_missing_subunit"]`, `state["standardized_address"]` | Standardizes the street address, classifies `SINGLE_FAMILY` vs. `MULTI_UNIT`, and detects omitted sub-units. | +| `reverse_geocode_tool` | `state["reverse_geocoded_address"]`, `state["dropoff_coordinates"]` | Resolves raw GPS latitude/longitude into a formatted street address. | diff --git a/src/google/adk/integrations/street_view/__init__.py b/src/google/adk/integrations/street_view/__init__.py new file mode 100644 index 00000000000..4d139319cf8 --- /dev/null +++ b/src/google/adk/integrations/street_view/__init__.py @@ -0,0 +1,65 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Google Street View and Maps Platform integration for ADK.""" + +from ._config import default_config +from ._config import GoogleMapsConfig +from ._models import AddressType +from ._models import PropertyClassification +from ._models import StreetViewResult +from ._models import VerdictLabel +from ._models import VisualAuditVerdict +from ._services import calculate_compass_bearing +from ._services import classify_property_address +from ._services import classify_property_address_async +from ._services import clear_caches +from ._services import fetch_street_view_baseline +from ._services import reverse_geocode_coordinates +from ._services import reverse_geocode_coordinates_async +from ._services import validate_coordinates +from ._street_view_toolset import StreetViewToolset +from ._tools import audit_photo_with_fallback +from ._tools import AUDIT_VERDICT_PROMPT +from ._tools import BASELINE_SYNTHESIS_PROMPT +from ._tools import property_classification_tool +from ._tools import reverse_geocode_tool +from ._tools import STREET_VIEW_ARTIFACT_FILENAME +from ._tools import street_view_fallback_tool + +__all__ = [ + "AUDIT_VERDICT_PROMPT", + "AddressType", + "BASELINE_SYNTHESIS_PROMPT", + "GoogleMapsConfig", + "PropertyClassification", + "STREET_VIEW_ARTIFACT_FILENAME", + "StreetViewResult", + "StreetViewToolset", + "VerdictLabel", + "VisualAuditVerdict", + "audit_photo_with_fallback", + "calculate_compass_bearing", + "classify_property_address", + "classify_property_address_async", + "clear_caches", + "default_config", + "fetch_street_view_baseline", + "property_classification_tool", + "reverse_geocode_coordinates", + "reverse_geocode_coordinates_async", + "reverse_geocode_tool", + "street_view_fallback_tool", + "validate_coordinates", +] diff --git a/src/google/adk/integrations/street_view/_config.py b/src/google/adk/integrations/street_view/_config.py new file mode 100644 index 00000000000..92b3865f752 --- /dev/null +++ b/src/google/adk/integrations/street_view/_config.py @@ -0,0 +1,145 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configuration and Secret Manager credential resolution for Google Maps.""" + +from __future__ import annotations + +import hashlib +import importlib +import logging +import os + +import google.auth +from google.auth import impersonated_credentials + +logger = logging.getLogger("google_adk." + __name__) + +_SECRET_MANAGER_REQUIRED = ( + "Secret Manager support requires google-cloud-secret-manager. " + "Install it with: pip install 'google-adk[gcp]'." +) + + +def _clean_env(value: str | None) -> str | None: + """Returns None if a variable is unset or still holds a template token.""" + if not value: + return None + stripped = value.strip() + if not stripped or stripped.startswith((" None: + self._explicit_api_key = _clean_env(api_key) + self.secret_resource_name = _clean_env(secret_resource_name) or _clean_env( + os.environ.get("MAPS_API_KEY_SECRET_RESOURCE") + ) + self.service_account_email = _clean_env( + service_account_email + ) or _clean_env(os.environ.get("GOOGLE_SA_EMAIL")) + self.default_pitch = default_pitch + self.default_fov = default_fov + self.default_size = default_size + self.timeout = timeout + self._cached_api_key: str | None = None + + def get_api_key(self) -> str | None: + """Resolves Maps API key from memory, environment, or Secret Manager.""" + if self._explicit_api_key: + return self._explicit_api_key + if self._cached_api_key: + return self._cached_api_key + + env_key = _clean_env(os.environ.get("MAPS_API_KEY")) + if env_key: + self._cached_api_key = env_key + return self._cached_api_key + + secret_res = self.secret_resource_name or _clean_env( + os.environ.get("MAPS_API_KEY_SECRET_RESOURCE") + ) + if not secret_res: + return None + + try: + secretmanager = importlib.import_module("google.cloud.secretmanager") + except ImportError as exc: + raise ImportError(_SECRET_MANAGER_REQUIRED) from exc + + sa_email = self.service_account_email or _clean_env( + os.environ.get("GOOGLE_SA_EMAIL") + ) + agent_engine_id = _clean_env(os.environ.get("GOOGLE_CLOUD_AGENT_ENGINE_ID")) + + try: + credentials, _ = google.auth.default() + if ( + sa_email + and not agent_engine_id + and getattr(credentials, "service_account_email", None) != sa_email + ): + credentials = impersonated_credentials.Credentials( + source_credentials=credentials, + target_principal=sa_email, + target_scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + sm_client = secretmanager.SecretManagerServiceClient( + credentials=credentials + ) + resp = sm_client.access_secret_version(request={"name": secret_res}) + key = _clean_env(resp.payload.data.decode("UTF-8")) + if key: + self._cached_api_key = key + return self._cached_api_key + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Secret Manager lookup failed for Maps API key (%s): %s", + secret_res, + type(exc).__name__, + ) + + return None + + def get_key_fingerprint(self) -> str: + """Returns a non-reversible SHA-256 prefix for cache partitioning.""" + api_key = self.get_api_key() + if not api_key: + return "unconfigured" + return hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16] + + +default_config = GoogleMapsConfig() diff --git a/src/google/adk/integrations/street_view/_models.py b/src/google/adk/integrations/street_view/_models.py new file mode 100644 index 00000000000..4e22499c316 --- /dev/null +++ b/src/google/adk/integrations/street_view/_models.py @@ -0,0 +1,124 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Data models for the Street View and Google Maps Platform integration.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel +from pydantic import Field + + +class AddressType(StrEnum): + """Classification of the physical real-estate property.""" + + SINGLE_FAMILY = "SINGLE_FAMILY" + MULTI_UNIT = "MULTI_UNIT" + + +class PropertyClassification(BaseModel): + """Result of address normalization and property classification.""" + + standardized_address: str = Field( + ..., description="Full normalized address string." + ) + base_address: str = Field( + ..., description="Address without apartment or unit identifier." + ) + address_type: AddressType = Field( + ..., description="Single-family vs. multi-unit classification." + ) + subunit: str | None = Field( + default=None, description="Extracted subunit (e.g. APT 4B, SUITE 200)." + ) + location_type: str | None = Field( + default="RESIDENTIAL", description="RESIDENTIAL or BUSINESS." + ) + is_missing_subunit: bool = Field( + default=False, + description="True if a subpremise is expected but omitted.", + ) + maps_api_error: str | None = Field( + default=None, + description="Diagnostic message if API fallback was triggered.", + ) + + +class StreetViewResult(BaseModel): + """Result of a Street View outdoor baseline query.""" + + available: bool = Field( + ..., description="Whether a valid outdoor panoramic photo was found." + ) + image_bytes: bytes | None = Field( + default=None, description="JPEG image bytes of the Street View panorama." + ) + maps_url: str | None = Field( + default=None, + description="Direct deep-link URL to Google Maps panorama view.", + ) + coordinates: tuple[float, float] = Field( + ..., description="(latitude, longitude) evaluated." + ) + pano_id: str | None = Field( + default=None, + description="Panorama ID from Street View pre-flight metadata.", + ) + imagery_date: str | None = Field( + default=None, + description="Capture date (YYYY-MM) of the Street View panorama.", + ) + camera_coordinates: tuple[float, float] | None = Field( + default=None, + description="Actual (latitude, longitude) of the Street View camera car.", + ) + heading: float | None = Field( + default=None, + description="Compass bearing (0-360 degrees) from camera to target.", + ) + + +VerdictLabel = Literal[ + "CONFIRMED_MATCH", + "CONFIRMED_MISMATCH", + "PLAUSIBLE_ALTERNATE_LOCATION", + "INSUFFICIENT_VISUAL_CONTEXT", +] + + +class VisualAuditVerdict(BaseModel): + """Structured output from Gemini Vision baseline comparison.""" + + verdict_label: VerdictLabel = Field( + ..., + description="Classification of visual match against reference baseline.", + ) + match: bool = Field( + ..., description="True if target photo matches expected location." + ) + confidence: float = Field( + ..., description="Confidence score clamped between 0.0 and 1.0." + ) + reason: str = Field(..., description="Detailed visual rationale.") + baseline_source: str = Field( + default="NONE", + description="HISTORICAL_IMAGERY, STREET_VIEW_FALLBACK, or NONE.", + ) + street_view_url: str | None = Field( + default=None, + description="Deep link to Street View if fallback was used.", + ) diff --git a/src/google/adk/integrations/street_view/_services.py b/src/google/adk/integrations/street_view/_services.py new file mode 100644 index 00000000000..c640f1f2033 --- /dev/null +++ b/src/google/adk/integrations/street_view/_services.py @@ -0,0 +1,640 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Core Google Maps Platform service functions for ADK multimodal agents.""" + +from __future__ import annotations + +from collections import OrderedDict +import json +import logging +import math +import re +from typing import Any +from typing import Generic +from typing import TypeVar +import urllib.parse + +import httpx + +from ...utils import _mtls_utils +from ._config import default_config +from ._config import GoogleMapsConfig +from ._models import AddressType +from ._models import PropertyClassification +from ._models import StreetViewResult + +logger = logging.getLogger("google_adk." + __name__) + +STREET_VIEW_METADATA_URL = ( + "https://maps.googleapis.com/maps/api/streetview/metadata" +) +STREET_VIEW_METADATA_MTLS_URL = ( + "https://maps.mtls.googleapis.com/maps/api/streetview/metadata" +) +STREET_VIEW_IMAGE_URL = "https://maps.googleapis.com/maps/api/streetview" +STREET_VIEW_IMAGE_MTLS_URL = ( + "https://maps.mtls.googleapis.com/maps/api/streetview" +) +ADDRESS_VALIDATION_URL = ( + "https://addressvalidation.googleapis.com/v1:validateAddress" +) +ADDRESS_VALIDATION_MTLS_URL = ( + "https://addressvalidation.mtls.googleapis.com/v1:validateAddress" +) +GEOCODE_URL = "https://maps.googleapis.com/maps/api/geocode/json" +GEOCODE_MTLS_URL = "https://maps.mtls.googleapis.com/maps/api/geocode/json" + +_SUBUNIT_REGEX = re.compile( + r"(?:\b(APT|APARTMENT|UNIT|STE|SUITE|BLDG|BUILDING|FL|FLOOR|RM|ROOM)" + r"\b\.?\s*([A-Z0-9\-]+)?|#\s*([A-Z0-9\-]+))" +) +_QUERY_KEY_REDACT_RE = re.compile(r"([?&]key=)[^&\s\"']+", re.IGNORECASE) + +K = TypeVar("K") +V = TypeVar("V") + + +class _BoundedLRUCache(Generic[K, V]): + """In-memory bounded LRU cache using OrderedDict.""" + + def __init__(self, maxsize: int = 1024) -> None: + self._maxsize = maxsize + self._store: OrderedDict[K, V] = OrderedDict() + + def get(self, key: K) -> tuple[bool, V | None]: + if key not in self._store: + return False, None + self._store.move_to_end(key) + return True, self._store[key] + + def put(self, key: K, value: V) -> None: + if key in self._store: + self._store.move_to_end(key) + self._store[key] = value + if len(self._store) > self._maxsize: + self._store.popitem(last=False) + + def clear(self) -> None: + self._store.clear() + + +def _redact_secret(text: str, api_key: str | None) -> str: + """Removes API keys from exception or diagnostic strings.""" + sanitized = _QUERY_KEY_REDACT_RE.sub(r"\1[REDACTED]", text) + if api_key: + sanitized = sanitized.replace(api_key, "[REDACTED]") + return sanitized + + +def validate_coordinates(latitude: float, longitude: float) -> bool: + """Returns True if latitude and longitude are finite within WGS84 bounds.""" + if not (math.isfinite(latitude) and math.isfinite(longitude)): + return False + return -90.0 <= latitude <= 90.0 and -180.0 <= longitude <= 180.0 + + +def calculate_compass_bearing( + start_lat: float, + start_lng: float, + end_lat: float, + end_lng: float, +) -> float | None: + """Calculates forward compass bearing (0-360 deg) from start to end GPS. + + Used to orient the Street View camera from its road capture position + directly toward the target property coordinates. Returns ``None`` if both + points are identical or invalid. + """ + if not ( + validate_coordinates(start_lat, start_lng) + and validate_coordinates(end_lat, end_lng) + ): + return None + if abs(start_lat - end_lat) < 1e-6 and abs(start_lng - end_lng) < 1e-6: + return None + + lat1 = math.radians(start_lat) + lat2 = math.radians(end_lat) + diff_lng = math.radians(end_lng - start_lng) + + x = math.sin(diff_lng) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - ( + math.sin(lat1) * math.cos(lat2) * math.cos(diff_lng) + ) + initial_bearing = math.degrees(math.atan2(x, y)) + return round((initial_bearing + 360.0) % 360.0, 1) + + +def _ensure_https_url(url: str) -> None: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError(f"Only HTTPS Google Maps endpoints are allowed: {url}") + + +def _sync_mtls_client_and_url(url: str) -> tuple[dict[str, Any], str]: + """Resolves httpx.Client mTLS kwargs and effective endpoint for sync calls.""" + mtls_kwargs = _mtls_utils.client_cert_httpx_kwargs(url=url) + if "verify" not in mtls_kwargs: + return {}, url + return ( + {"verify": mtls_kwargs["verify"]}, + _mtls_utils.effective_googleapis_endpoint(url), + ) + + +def _sync_http_get( + url: str, + params: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, +) -> tuple[int, bytes]: + _ensure_https_url(url) + client_kwargs, target_url = _sync_mtls_client_and_url(url) + with httpx.Client(timeout=timeout, **client_kwargs) as client: + resp = client.get(target_url, params=params, headers=headers) + return resp.status_code, resp.content + + +def _sync_http_post_json( + url: str, + params: dict[str, Any], + payload: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, +) -> tuple[int, bytes]: + _ensure_https_url(url) + client_kwargs, target_url = _sync_mtls_client_and_url(url) + with httpx.Client(timeout=timeout, **client_kwargs) as client: + resp = client.post(target_url, params=params, json=payload, headers=headers) + return resp.status_code, resp.content + + +async def _async_http_get( + url: str, + params: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, +) -> tuple[int, bytes]: + _ensure_https_url(url) + mtls_kwargs = _mtls_utils.client_cert_httpx_kwargs(url=url) + async with httpx.AsyncClient(timeout=timeout, **mtls_kwargs) as client: + resp = await client.get(url, params=params, headers=headers) + return resp.status_code, resp.content + + +async def _async_http_post_json( + url: str, + params: dict[str, Any], + payload: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, +) -> tuple[int, bytes]: + _ensure_https_url(url) + mtls_kwargs = _mtls_utils.client_cert_httpx_kwargs(url=url) + async with httpx.AsyncClient(timeout=timeout, **mtls_kwargs) as client: + resp = await client.post(url, params=params, json=payload, headers=headers) + return resp.status_code, resp.content + + +async def fetch_street_view_baseline( + latitude: float, + longitude: float, + config: GoogleMapsConfig | None = None, + timeout: float | None = None, + pitch: int | None = None, + fov: int | None = None, + size: str | None = None, + heading: float | None = None, +) -> StreetViewResult: + """Fetches outdoor Street View imagery using the 2-step Metadata pattern.""" + if not validate_coordinates(latitude, longitude): + logger.warning( + "Invalid GPS coordinates (%s, %s); skipping Street View query.", + latitude, + longitude, + ) + return StreetViewResult(available=False, coordinates=(latitude, longitude)) + + cfg = config or default_config + api_key = cfg.get_api_key() + if not api_key: + logger.info("Maps API key not available; skipping Street View baseline.") + return StreetViewResult(available=False, coordinates=(latitude, longitude)) + + req_timeout = timeout if timeout is not None else cfg.timeout + req_pitch = pitch if pitch is not None else cfg.default_pitch + req_fov = fov if fov is not None else cfg.default_fov + req_size = size if size is not None else cfg.default_size + + meta_params = { + "location": f"{latitude},{longitude}", + "source": "outdoor", + "key": api_key, + } + pano_id: str | None = None + imagery_date: str | None = None + camera_coords: tuple[float, float] | None = None + computed_heading: float | None = heading + + try: + meta_status, meta_body = await _async_http_get( + STREET_VIEW_METADATA_URL, meta_params, req_timeout + ) + if meta_status != 200: + return StreetViewResult( + available=False, coordinates=(latitude, longitude) + ) + + meta_json = json.loads(meta_body.decode("utf-8")) + if meta_json.get("status") != "OK": + logger.info( + "No Street View imagery at (%s, %s): %s", + latitude, + longitude, + meta_json.get("status"), + ) + return StreetViewResult( + available=False, coordinates=(latitude, longitude) + ) + + pano_id = meta_json.get("pano_id") + imagery_date = meta_json.get("date") + loc = meta_json.get("location") + if isinstance(loc, dict) and "lat" in loc and "lng" in loc: + cam_lat = float(loc["lat"]) + cam_lng = float(loc["lng"]) + camera_coords = (cam_lat, cam_lng) + if computed_heading is None: + computed_heading = calculate_compass_bearing( + cam_lat, cam_lng, latitude, longitude + ) + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Street View metadata check failed: %s", + _redact_secret(str(exc), api_key), + ) + return StreetViewResult(available=False, coordinates=(latitude, longitude)) + + sv_params: dict[str, Any] = { + "size": req_size, + "pitch": req_pitch, + "fov": req_fov, + "source": "outdoor", + "key": api_key, + } + if pano_id and computed_heading is not None: + sv_params["pano"] = pano_id + sv_params["heading"] = computed_heading + else: + sv_params["location"] = f"{latitude},{longitude}" + if pano_id: + sv_params["pano"] = pano_id + if computed_heading is not None: + sv_params["heading"] = computed_heading + + try: + img_status, img_bytes = await _async_http_get( + STREET_VIEW_IMAGE_URL, sv_params, req_timeout + ) + if img_status == 200 and img_bytes: + maps_url = ( + "https://www.google.com/maps/@?api=1&map_action=pano" + f"&viewpoint={latitude},{longitude}" + ) + if pano_id: + maps_url += f"&pano={pano_id}" + if computed_heading is not None: + maps_url += ( + f"&heading={computed_heading}&pitch={req_pitch}&fov={req_fov}" + ) + + return StreetViewResult( + available=True, + image_bytes=img_bytes, + maps_url=maps_url, + coordinates=(latitude, longitude), + pano_id=pano_id, + imagery_date=imagery_date, + camera_coordinates=camera_coords, + heading=computed_heading, + ) + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Street View image download failed: %s", + _redact_secret(str(exc), api_key), + ) + + return StreetViewResult( + available=False, + coordinates=(latitude, longitude), + pano_id=pano_id, + imagery_date=imagery_date, + camera_coordinates=camera_coords, + heading=computed_heading, + ) + + +def _parse_address_validation_response( + data: dict[str, Any], +) -> PropertyClassification: + """Parses a Google Address Validation API v1 JSON response.""" + result = data.get("result", {}) + address_data = result.get("address", {}) + components = address_data.get("addressComponents", []) + missing = address_data.get("missingComponentTypes", []) + is_business = bool(result.get("metadata", {}).get("business", False)) + location_type = "BUSINESS" if is_business else "RESIDENTIAL" + + subunit: str | None = None + street_number = "" + route = "" + locality = "" + admin_area = "" + postal_code = "" + + for comp in components: + ctype = comp.get("componentType") + cname = comp.get("componentName", {}).get("text", "").strip().upper() + if not cname: + continue + if ctype in ("subpremise", "room"): + if any( + cname.startswith(prefix) + for prefix in ("APT", "UNIT", "STE", "SUITE", "RM", "ROOM", "#") + ): + subunit = cname + else: + subunit = f"APT {cname}" + elif ctype == "street_number": + street_number = cname + elif ctype == "route": + route = cname + elif ctype == "locality": + locality = cname + elif ctype == "administrative_area_level_1": + admin_area = cname + elif ctype == "postal_code": + postal_code = cname + + street_line = f"{street_number} {route}".strip().upper() + if not street_line: + formatted = address_data.get("formattedAddress", "") + street_line = ( + formatted.split(",")[0].strip().upper() if formatted else "UNKNOWN" + ) + + state_zip = f"{admin_area} {postal_code}".strip() + city_state_zip = ", ".join(p for p in (locality, state_zip) if p) + base = f"{street_line}, {city_state_zip}" if city_state_zip else street_line + + is_missing_sub = "subpremise" in missing or "room" in missing + if subunit or is_missing_sub: + if subunit: + std_parts = [street_line, subunit] + if city_state_zip: + std_parts.append(city_state_zip) + std_addr = ", ".join(std_parts) + else: + std_addr = base + return PropertyClassification( + standardized_address=std_addr, + base_address=base, + address_type=AddressType.MULTI_UNIT, + subunit=subunit, + location_type=location_type, + is_missing_subunit=is_missing_sub, + ) + + return PropertyClassification( + standardized_address=base, + base_address=base, + address_type=AddressType.SINGLE_FAMILY, + subunit=None, + location_type=location_type, + is_missing_subunit=False, + ) + + +def _fallback_regex_classify( + raw_address: str, maps_error: str | None +) -> PropertyClassification: + """Deterministic regex fallback when Address Validation API is unreachable.""" + cleaned = " ".join(raw_address.strip().upper().split()) + match = _SUBUNIT_REGEX.search(cleaned) + if match: + prefix = cleaned[: match.start()].strip().rstrip(",") + suffix = cleaned[match.end() :].strip().lstrip(",").strip() + sub = match.group(0).strip() + if sub.startswith("#"): + num = sub.lstrip("#").strip() + sub = f"APT {num}" if num else "APT" + + base_parts = [p for p in (prefix, suffix) if p] + base = ", ".join(base_parts) if base_parts else cleaned + + std_parts = [p for p in (prefix, sub, suffix) if p] + std_addr = ", ".join(std_parts) + + return PropertyClassification( + standardized_address=std_addr, + base_address=base, + address_type=AddressType.MULTI_UNIT, + subunit=sub, + location_type="RESIDENTIAL", + is_missing_subunit=False, + maps_api_error=maps_error, + ) + + return PropertyClassification( + standardized_address=cleaned, + base_address=cleaned, + address_type=AddressType.SINGLE_FAMILY, + subunit=None, + location_type="RESIDENTIAL", + is_missing_subunit=False, + maps_api_error=maps_error, + ) + + +_address_cache: _BoundedLRUCache[tuple[str, str], PropertyClassification] = ( + _BoundedLRUCache(maxsize=1024) +) +_geocode_cache: _BoundedLRUCache[tuple[float, float, str], str] = ( + _BoundedLRUCache(maxsize=1024) +) + + +async def classify_property_address_async( + raw_address: str, + config: GoogleMapsConfig | None = None, +) -> PropertyClassification: + """Asynchronously standardizes an address and classifies property type.""" + cfg = config or default_config + api_key = cfg.get_api_key() + normalized_input = " ".join(raw_address.strip().upper().split()) + + if api_key: + cache_key = (normalized_input, cfg.get_key_fingerprint()) + hit, cached = _address_cache.get(cache_key) + if hit and cached is not None: + return cached + + try: + status, body = await _async_http_post_json( + ADDRESS_VALIDATION_URL, + params={}, + payload={"address": {"addressLines": [raw_address]}}, + timeout=cfg.timeout, + headers={"X-Goog-Api-Key": api_key}, + ) + if status == 200: + data = json.loads(body.decode("utf-8")) + classification = _parse_address_validation_response(data) + _address_cache.put(cache_key, classification) + return classification + maps_error: str | None = f"HTTP {status}" + except Exception as exc: # pylint: disable=broad-except + maps_error = _redact_secret(str(exc), api_key) + else: + maps_error = "MAPS_API_KEY unconfigured" + + return _fallback_regex_classify(raw_address, maps_error) + + +def classify_property_address( + raw_address: str, + config: GoogleMapsConfig | None = None, +) -> PropertyClassification: + """Synchronous version of ``classify_property_address_async``.""" + cfg = config or default_config + api_key = cfg.get_api_key() + normalized_input = " ".join(raw_address.strip().upper().split()) + + if api_key: + cache_key = (normalized_input, cfg.get_key_fingerprint()) + hit, cached = _address_cache.get(cache_key) + if hit and cached is not None: + return cached + + try: + status, body = _sync_http_post_json( + ADDRESS_VALIDATION_URL, + params={}, + payload={"address": {"addressLines": [raw_address]}}, + timeout=cfg.timeout, + headers={"X-Goog-Api-Key": api_key}, + ) + if status == 200: + data = json.loads(body.decode("utf-8")) + classification = _parse_address_validation_response(data) + _address_cache.put(cache_key, classification) + return classification + maps_error: str | None = f"HTTP {status}" + except Exception as exc: # pylint: disable=broad-except + maps_error = _redact_secret(str(exc), api_key) + else: + maps_error = "MAPS_API_KEY unconfigured" + + return _fallback_regex_classify(raw_address, maps_error) + + +async def reverse_geocode_coordinates_async( + latitude: float, + longitude: float, + config: GoogleMapsConfig | None = None, +) -> str | None: + """Asynchronously resolves GPS coordinates into a street address.""" + if not validate_coordinates(latitude, longitude): + return None + + cfg = config or default_config + api_key = cfg.get_api_key() + if not api_key: + return None + + rounded_lat = round(latitude, 5) + rounded_lng = round(longitude, 5) + cache_key = (rounded_lat, rounded_lng, cfg.get_key_fingerprint()) + hit, cached = _geocode_cache.get(cache_key) + if hit and cached is not None: + return cached + + try: + status, body = await _async_http_get( + GEOCODE_URL, + params={"latlng": f"{rounded_lat},{rounded_lng}", "key": api_key}, + timeout=cfg.timeout, + ) + if status == 200: + data = json.loads(body.decode("utf-8")) + if data.get("status") == "OK" and data.get("results"): + formatted = data["results"][0].get("formatted_address") + if isinstance(formatted, str) and formatted: + _geocode_cache.put(cache_key, formatted) + return formatted + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Reverse geocoding failed: %s", + _redact_secret(str(exc), api_key), + ) + return None + + +def reverse_geocode_coordinates( + latitude: float, + longitude: float, + config: GoogleMapsConfig | None = None, +) -> str | None: + """Synchronous version of ``reverse_geocode_coordinates_async``.""" + if not validate_coordinates(latitude, longitude): + return None + + cfg = config or default_config + api_key = cfg.get_api_key() + if not api_key: + return None + + rounded_lat = round(latitude, 5) + rounded_lng = round(longitude, 5) + cache_key = (rounded_lat, rounded_lng, cfg.get_key_fingerprint()) + hit, cached = _geocode_cache.get(cache_key) + if hit and cached is not None: + return cached + + try: + status, body = _sync_http_get( + GEOCODE_URL, + params={"latlng": f"{rounded_lat},{rounded_lng}", "key": api_key}, + timeout=cfg.timeout, + ) + if status == 200: + data = json.loads(body.decode("utf-8")) + if data.get("status") == "OK" and data.get("results"): + formatted = data["results"][0].get("formatted_address") + if isinstance(formatted, str) and formatted: + _geocode_cache.put(cache_key, formatted) + return formatted + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Reverse geocoding failed: %s", + _redact_secret(str(exc), api_key), + ) + return None + + +def clear_caches() -> None: + """Clears internal address validation and reverse geocoding caches.""" + _address_cache.clear() + _geocode_cache.clear() diff --git a/src/google/adk/integrations/street_view/_street_view_toolset.py b/src/google/adk/integrations/street_view/_street_view_toolset.py new file mode 100644 index 00000000000..d05c1df884d --- /dev/null +++ b/src/google/adk/integrations/street_view/_street_view_toolset.py @@ -0,0 +1,167 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configurable ADK BaseToolset for Google Street View and Maps Platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from typing_extensions import override + +from ...agents.readonly_context import ReadonlyContext +from ...tools.base_tool import BaseTool +from ...tools.base_toolset import BaseToolset +from ...tools.base_toolset import ToolPredicate +from ...tools.function_tool import FunctionTool +from ...tools.tool_context import ToolContext +from ._config import default_config +from ._config import GoogleMapsConfig +from ._tools import _execute_property_classification +from ._tools import _execute_reverse_geocode +from ._tools import _execute_street_view_fallback + + +class StreetViewToolset(BaseToolset): + """ADK Toolset bundling Street View, Address Validation, and Geocoding tools. + + The tool names are: + - street_view_fallback_tool + - property_classification_tool + - reverse_geocode_tool + """ + + def __init__( + self, + config: GoogleMapsConfig | None = None, + *, + tool_filter: ToolPredicate | list[str] | None = None, + tool_name_prefix: str | None = None, + include_street_view: bool = True, + include_property_classification: bool = True, + include_reverse_geocode: bool = True, + ) -> None: + super().__init__( + tool_filter=tool_filter, + tool_name_prefix=tool_name_prefix, + ) + self.config = config or default_config + self.include_street_view = include_street_view + self.include_property_classification = include_property_classification + self.include_reverse_geocode = include_reverse_geocode + + def get_tool_functions(self) -> list[Callable[..., Any]]: + """Returns async tool callables bound to ``self.config``.""" + cfg = self.config + funcs: list[Callable[..., Any]] = [] + + if self.include_street_view: + + async def street_view_fallback_tool( + latitude: float, + longitude: float, + tool_context: ToolContext, + ) -> str: + """Queries Google Street View for an outdoor visual baseline. + + Use this tool when no historical ground-truth photos exist for an + address (cold-start). Performs a pre-flight metadata check before + downloading imagery and saves the resulting panorama into ADK session + artifacts and state. + + Args: + latitude: Target property latitude in decimal degrees. + longitude: Target property longitude in decimal degrees. + tool_context: ADK ToolContext injected by the runner. + """ + return await _execute_street_view_fallback( + latitude=latitude, + longitude=longitude, + tool_context=tool_context, + config=cfg, + ) + + funcs.append(street_view_fallback_tool) + + if self.include_property_classification: + + async def property_classification_tool( + raw_address: str, + tool_context: ToolContext, + ) -> str: + """Standardizes an address and classifies SINGLE_FAMILY vs MULTI_UNIT. + + Detects omitted apartment/suite numbers via the Google Address + Validation API and stores the property classification in ADK session + state to set spatial decision boundaries. + + Args: + raw_address: Raw customer or recipient street address. + tool_context: ADK ToolContext injected by the runner. + """ + return await _execute_property_classification( + raw_address=raw_address, + tool_context=tool_context, + config=cfg, + ) + + funcs.append(property_classification_tool) + + if self.include_reverse_geocode: + + async def reverse_geocode_tool( + latitude: float, + longitude: float, + tool_context: ToolContext, + ) -> str: + """Resolves GPS telemetry coordinates into a physical street address. + + Saves the resolved street context into ADK session state for comparison + against the intended recipient address. + + Args: + latitude: GPS latitude in decimal degrees. + longitude: GPS longitude in decimal degrees. + tool_context: ADK ToolContext injected by the runner. + """ + return await _execute_reverse_geocode( + latitude=latitude, + longitude=longitude, + tool_context=tool_context, + config=cfg, + ) + + funcs.append(reverse_geocode_tool) + + return funcs + + @override + async def get_tools( + self, readonly_context: ReadonlyContext | None = None + ) -> list[BaseTool]: + """Returns ADK ``FunctionTool`` instances selected by ``tool_filter``.""" + all_tools: list[BaseTool] = [ + FunctionTool(func=func) for func in self.get_tool_functions() + ] + return [ + tool + for tool in all_tools + if self._is_tool_selected(tool, readonly_context) + ] + + @override + async def close(self) -> None: + """Cleans up resources held by the toolset.""" + return None diff --git a/src/google/adk/integrations/street_view/_tools.py b/src/google/adk/integrations/street_view/_tools.py new file mode 100644 index 00000000000..5e503c7de92 --- /dev/null +++ b/src/google/adk/integrations/street_view/_tools.py @@ -0,0 +1,451 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ADK Tool interfaces and Gemini Vision baseline comparison utilities.""" + +from __future__ import annotations + +import base64 +import inspect +import json +import logging +import os +import re +from typing import Any + +from google.genai import types + +from ...tools.tool_context import ToolContext +from ._config import default_config +from ._config import GoogleMapsConfig +from ._models import AddressType +from ._models import VisualAuditVerdict +from ._services import classify_property_address_async +from ._services import fetch_street_view_baseline +from ._services import reverse_geocode_coordinates_async +from ._services import validate_coordinates + +logger = logging.getLogger("google_adk." + __name__) + +STREET_VIEW_ARTIFACT_FILENAME = "street_view_baseline.jpg" +_CODE_FENCE_RE = re.compile( + r"^```(?:json)?\s*|\s*```$", re.IGNORECASE | re.MULTILINE +) + +BASELINE_SYNTHESIS_PROMPT = ( + "You are analyzing outdoor reference imagery of a physical address.\n" + "Synthesize a concise reference description of the property's STABLE" + " features:\n" + "- Architectural building type (single-family home, apartment building," + " townhouse, commercial)\n" + "- Exterior materials (brick, vinyl siding, stone, stucco, wood)\n" + "- Primary entryway visual features (porch, steps, door color, columns," + " awnings, driveway alignment)\n" + "- Distinguishing permanent landmarks or landscaping\n" + "Ignore weather, vehicle positions, lighting, seasonal foliage, and" + " transient objects." +) + +AUDIT_VERDICT_PROMPT = ( + "Compare the target audit photo against the visual baseline reference.\n" + "Determine whether the target photo matches the expected physical" + " location.\n\n" + "Address Context: {address}\n" + "Property Type: {property_type}\n" + "Drop-off Street Context (Reverse Geocoded): {street_context}\n\n" + "Decision Boundary Guidance:\n" + "- If Property Type is SINGLE_FAMILY: Expect tight visual alignment with" + " the primary porch, front entryway, garage, or side door of the home.\n" + "- If Property Type is MULTI_UNIT: Allow for distributed drop-off" + " locations such as building lobbies, parcel lockers, mailrooms, leasing" + " offices, or unit doors consistent with the complex's architecture.\n\n" + "Output strict JSON with the following schema:\n" + "{{\n" + ' "verdict_label": "CONFIRMED_MATCH | CONFIRMED_MISMATCH |' + ' PLAUSIBLE_ALTERNATE_LOCATION | INSUFFICIENT_VISUAL_CONTEXT",\n' + ' "match": true | false,\n' + ' "confidence": 0.0-1.0,\n' + ' "reason": "Detailed visual rationale comparing stable features"\n' + "}}" +) + + +def _parse_json_object(raw_text: str) -> dict[str, Any]: + """Safely parses a JSON object from LLM text, stripping markdown fences.""" + cleaned = _CODE_FENCE_RE.sub("", raw_text).strip() + parsed = json.loads(cleaned) + if not isinstance(parsed, dict): + raise ValueError("Expected JSON object from Gemini audit response.") + return parsed + + +async def _persist_street_view_image( + tool_context: ToolContext | None, + image_bytes: bytes, + artifact_filename: str = STREET_VIEW_ARTIFACT_FILENAME, +) -> str | None: + """Saves Street View JPEG bytes as an ADK Artifact plus base64 state.""" + if tool_context is None: + return None + + saved_artifact_name: str | None = None + if hasattr(tool_context, "save_artifact"): + try: + part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg") + res = tool_context.save_artifact( + filename=artifact_filename, artifact=part + ) + if inspect.isawaitable(res): + await res + saved_artifact_name = artifact_filename + except Exception as exc: # pylint: disable=broad-except + logger.debug( + "save_artifact unavailable; falling back to base64 state: %s", + exc, + ) + + if hasattr(tool_context, "state"): + if saved_artifact_name: + tool_context.state["street_view_artifact_name"] = saved_artifact_name + tool_context.state["street_view_image_b64"] = base64.b64encode( + image_bytes + ).decode("ascii") + + return saved_artifact_name + + +async def _execute_street_view_fallback( + latitude: float, + longitude: float, + tool_context: ToolContext, + config: GoogleMapsConfig | None = None, +) -> str: + """Executes Street View pre-flight and panorama capture into session state.""" + if not validate_coordinates(latitude, longitude): + return ( + f"Invalid GPS coordinates ({latitude}, {longitude}). " + "Latitude must be in [-90, 90] and longitude in [-180, 180]." + ) + + result = await fetch_street_view_baseline(latitude, longitude, config=config) + if not result.available or not result.image_bytes: + if tool_context and hasattr(tool_context, "state"): + tool_context.state["street_view_baseline_used"] = False + return ( + "Street View imagery is not available for coordinates " + f"({latitude}, {longitude})." + ) + + artifact_name = await _persist_street_view_image( + tool_context, result.image_bytes + ) + + if tool_context and hasattr(tool_context, "state"): + tool_context.state["street_view_baseline_used"] = True + tool_context.state["street_view_url"] = result.maps_url + tool_context.state["street_view_pano_id"] = result.pano_id + tool_context.state["street_view_imagery_date"] = result.imagery_date + tool_context.state["street_view_heading"] = result.heading + + details = [ + ( + "Successfully captured Street View baseline imagery for " + f"({latitude}, {longitude})." + ), + f"- **Deep Link**: {result.maps_url}", + ] + if result.pano_id: + details.append(f"- **Panorama ID**: `{result.pano_id}`") + if result.imagery_date: + details.append(f"- **Capture Date**: {result.imagery_date}") + if result.heading is not None: + details.append(f"- **Camera Heading**: {result.heading}°") + if artifact_name: + details.append(f"- **ADK Artifact**: `{artifact_name}`") + + return "\n".join(details) + + +async def _execute_property_classification( + raw_address: str, + tool_context: ToolContext, + config: GoogleMapsConfig | None = None, +) -> str: + """Executes address validation and property boundary classification.""" + if not raw_address or not raw_address.strip(): + return "Cannot classify an empty address string." + + classification = await classify_property_address_async( + raw_address, config=config + ) + + if tool_context and hasattr(tool_context, "state"): + tool_context.state["address_classification"] = classification.model_dump() + tool_context.state["is_multi_unit"] = ( + classification.address_type == AddressType.MULTI_UNIT + ) + tool_context.state["is_missing_subunit"] = classification.is_missing_subunit + tool_context.state["standardized_address"] = ( + classification.standardized_address + ) + + report = [ + "### Address Property Classification", + f"- **Standardized Address**: {classification.standardized_address}", + f"- **Base Address**: {classification.base_address}", + f"- **Property Type**: {classification.address_type.value}", + f"- **Location Type**: {classification.location_type}", + ] + if classification.subunit: + report.append(f"- **Sub-unit**: {classification.subunit}") + if classification.is_missing_subunit: + report.append( + "- **Warning**: Address appears to be a multi-unit property " + "with a missing unit/apartment number." + ) + if classification.maps_api_error: + report.append( + f"- *Note*: Maps API fallback active ({classification.maps_api_error})" + ) + + return "\n".join(report) + + +async def _execute_reverse_geocode( + latitude: float, + longitude: float, + tool_context: ToolContext, + config: GoogleMapsConfig | None = None, +) -> str: + """Executes reverse geocoding and stores resolved street context in state.""" + if not validate_coordinates(latitude, longitude): + return ( + f"Invalid GPS coordinates ({latitude}, {longitude}). " + "Latitude must be in [-90, 90] and longitude in [-180, 180]." + ) + + formatted_address = await reverse_geocode_coordinates_async( + latitude, longitude, config=config + ) + if not formatted_address: + return ( + "Could not resolve a street address for GPS coordinates " + f"({latitude}, {longitude})." + ) + + if tool_context and hasattr(tool_context, "state"): + tool_context.state["reverse_geocoded_address"] = formatted_address + tool_context.state["dropoff_coordinates"] = [latitude, longitude] + + return ( + "### Reverse Geocoded Street Context\n" + f"- **Coordinates**: ({latitude}, {longitude})\n" + f"- **Resolved Street Address**: {formatted_address}" + ) + + +async def street_view_fallback_tool( + latitude: float, + longitude: float, + tool_context: ToolContext, +) -> str: + """Queries Google Street View to establish an outdoor visual baseline. + + Use this tool when no historical ground-truth photos exist for an address + (cold-start). Performs a pre-flight metadata check before downloading imagery + and saves the resulting panorama into ADK session artifacts and state. + + Args: + latitude: Target property or drop-off latitude in decimal degrees. + longitude: Target property or drop-off longitude in decimal degrees. + tool_context: ADK ToolContext automatically injected by the runner. + """ + return await _execute_street_view_fallback( + latitude=latitude, + longitude=longitude, + tool_context=tool_context, + config=default_config, + ) + + +async def property_classification_tool( + raw_address: str, + tool_context: ToolContext, +) -> str: + """Standardizes an address and classifies SINGLE_FAMILY vs MULTI_UNIT. + + Detects omitted apartment/suite numbers via the Google Address Validation API + and stores the property classification in ADK session state to set spatial + decision boundaries. + + Args: + raw_address: The raw customer or recipient street address to validate. + tool_context: ADK ToolContext automatically injected by the runner. + """ + return await _execute_property_classification( + raw_address=raw_address, + tool_context=tool_context, + config=default_config, + ) + + +async def reverse_geocode_tool( + latitude: float, + longitude: float, + tool_context: ToolContext, +) -> str: + """Resolves GPS telemetry coordinates into a physical street address. + + Saves the resolved street context into ADK session state for comparison + against the intended recipient address. + + Args: + latitude: GPS latitude in decimal degrees. + longitude: GPS longitude in decimal degrees. + tool_context: ADK ToolContext automatically injected by the runner. + """ + return await _execute_reverse_geocode( + latitude=latitude, + longitude=longitude, + tool_context=tool_context, + config=default_config, + ) + + +async def audit_photo_with_fallback( + target_photo_bytes: bytes, + historical_photos: list[bytes] | None, + latitude: float, + longitude: float, + tool_context: ToolContext, + genai_client: Any, + model: str | None = None, + config: GoogleMapsConfig | None = None, +) -> dict[str, Any]: + """Compares a target photo against historical photos or Street View.""" + resolved_model = model if model is not None else os.getenv("MODEL_NAME") + if not resolved_model: + raise ValueError( + "Model name must be passed via `model` or set in `MODEL_NAME`." + ) + + baseline_parts: list[types.Part] = [] + baseline_source = "NONE" + sv_url: str | None = None + + if historical_photos: + for photo in historical_photos: + baseline_parts.append( + types.Part.from_bytes(data=photo, mime_type="image/jpeg") + ) + baseline_source = "HISTORICAL_IMAGERY" + else: + sv_result = await fetch_street_view_baseline( + latitude, longitude, config=config + ) + if sv_result.available and sv_result.image_bytes: + baseline_parts.append( + types.Part.from_bytes( + data=sv_result.image_bytes, mime_type="image/jpeg" + ) + ) + baseline_source = "STREET_VIEW_FALLBACK" + sv_url = sv_result.maps_url + await _persist_street_view_image(tool_context, sv_result.image_bytes) + if tool_context and hasattr(tool_context, "state"): + tool_context.state["street_view_baseline_used"] = True + tool_context.state["street_view_url"] = sv_result.maps_url + tool_context.state["street_view_pano_id"] = sv_result.pano_id + tool_context.state["street_view_imagery_date"] = sv_result.imagery_date + + if not baseline_parts: + verdict = VisualAuditVerdict( + verdict_label="INSUFFICIENT_VISUAL_CONTEXT", + match=False, + confidence=0.0, + reason=( + "No historical baseline photos or outdoor Street View imagery " + "available for location." + ), + baseline_source="NONE", + street_view_url=None, + ) + return verdict.model_dump() + + baseline_response = await genai_client.aio.models.generate_content( + model=resolved_model, + contents=[BASELINE_SYNTHESIS_PROMPT, *baseline_parts], + ) + baseline_description = baseline_response.text or "" + + state: Any = ( + tool_context.state + if (tool_context and hasattr(tool_context, "state")) + else {} + ) + address_str = state.get("standardized_address", "Unknown") + property_type_str = ( + "MULTI_UNIT" if state.get("is_multi_unit") else "SINGLE_FAMILY" + ) + street_context_str = state.get("reverse_geocoded_address", "Not evaluated") + + audit_response = await genai_client.aio.models.generate_content( + model=resolved_model, + contents=[ + AUDIT_VERDICT_PROMPT.format( + address=address_str, + property_type=property_type_str, + street_context=street_context_str, + ), + ( + f"Visual Baseline Reference ({baseline_source}):\n" + f"{baseline_description}" + ), + types.Part.from_bytes( + data=target_photo_bytes, mime_type="image/jpeg" + ), + ], + config=types.GenerateContentConfig(response_mime_type="application/json"), + ) + + parsed_data: dict[str, Any] = {} + if getattr(audit_response, "parsed", None) and isinstance( + audit_response.parsed, dict + ): + parsed_data = dict(audit_response.parsed) + elif getattr(audit_response, "text", None): + parsed_data = _parse_json_object(audit_response.text) + + raw_conf = parsed_data.get("confidence", 0.0) + try: + clamped_conf = max(0.0, min(1.0, float(raw_conf))) + except (TypeError, ValueError): + clamped_conf = 0.0 + + verdict_obj = VisualAuditVerdict( + verdict_label=parsed_data.get( + "verdict_label", "INSUFFICIENT_VISUAL_CONTEXT" + ), + match=bool(parsed_data.get("match", False)), + confidence=clamped_conf, + reason=str(parsed_data.get("reason", "No rationale returned by model.")), + baseline_source=baseline_source, + street_view_url=sv_url, + ) + output = verdict_obj.model_dump() + + if tool_context and hasattr(tool_context, "state"): + tool_context.state["visual_audit_verdict"] = output + + return output diff --git a/tests/unittests/integrations/street_view/__init__.py b/tests/unittests/integrations/street_view/__init__.py new file mode 100644 index 00000000000..58d482ea386 --- /dev/null +++ b/tests/unittests/integrations/street_view/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unittests/integrations/street_view/test_street_view.py b/tests/unittests/integrations/street_view/test_street_view.py new file mode 100644 index 00000000000..ec7f3daa78e --- /dev/null +++ b/tests/unittests/integrations/street_view/test_street_view.py @@ -0,0 +1,558 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the Google Street View and Maps Platform integration.""" + +import inspect +import json +import math +import os +import sys +from typing import Any +import unittest +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +from google.adk.integrations.street_view import AddressType +from google.adk.integrations.street_view import audit_photo_with_fallback +from google.adk.integrations.street_view import calculate_compass_bearing +from google.adk.integrations.street_view import classify_property_address +from google.adk.integrations.street_view import classify_property_address_async +from google.adk.integrations.street_view import clear_caches +from google.adk.integrations.street_view import fetch_street_view_baseline +from google.adk.integrations.street_view import GoogleMapsConfig +from google.adk.integrations.street_view import property_classification_tool +from google.adk.integrations.street_view import reverse_geocode_coordinates +from google.adk.integrations.street_view import reverse_geocode_coordinates_async +from google.adk.integrations.street_view import reverse_geocode_tool +from google.adk.integrations.street_view import STREET_VIEW_ARTIFACT_FILENAME +from google.adk.integrations.street_view import street_view_fallback_tool +from google.adk.integrations.street_view import StreetViewToolset +from google.adk.integrations.street_view import validate_coordinates +from google.adk.integrations.street_view._services import _BoundedLRUCache + + +class _DummyToolContext: + """Lightweight ToolContext test double tracking state and saved artifacts.""" + + def __init__(self) -> None: + self.state: dict[str, Any] = {} + self.saved_artifacts: dict[str, Any] = {} + + async def save_artifact( + self, + filename: str, + artifact: Any, + custom_metadata: dict[str, Any] | None = None, + ) -> int: + del custom_metadata + self.saved_artifacts[filename] = artifact + return 0 + + +class TestCompassBearingAndCoordinates(unittest.TestCase): + """Tests WGS84 coordinate bounds and compass heading calculation.""" + + def test_cardinal_directions(self) -> None: + self.assertAlmostEqual( + calculate_compass_bearing(37.0, -122.0, 37.001, -122.0) or 0.0, + 0.0, + places=1, + ) + self.assertAlmostEqual( + calculate_compass_bearing(37.0, -122.0, 37.0, -121.999) or 0.0, + 90.0, + places=0, + ) + self.assertAlmostEqual( + calculate_compass_bearing(37.0, -122.0, 36.999, -122.0) or 0.0, + 180.0, + places=1, + ) + self.assertAlmostEqual( + calculate_compass_bearing(37.0, -122.0, 37.0, -122.001) or 0.0, + 270.0, + places=0, + ) + + def test_identical_or_invalid_coordinates_returns_none(self) -> None: + self.assertIsNone( + calculate_compass_bearing(37.4220, -122.0841, 37.4220, -122.0841) + ) + self.assertFalse(validate_coordinates(95.0, -122.0)) + self.assertFalse(validate_coordinates(37.0, -190.0)) + self.assertFalse(validate_coordinates(math.nan, -122.0)) + self.assertIsNone( + calculate_compass_bearing(95.0, -122.0, 37.4220, -122.0841) + ) + + def test_bounded_lru_cache_evicts_least_recently_used(self) -> None: + cache: _BoundedLRUCache[str, int] = _BoundedLRUCache(maxsize=2) + cache.put("a", 1) + cache.put("b", 2) + self.assertEqual(cache.get("a"), (True, 1)) + cache.put("c", 3) + self.assertEqual(cache.get("b"), (False, None)) + self.assertEqual(cache.get("a"), (True, 1)) + self.assertEqual(cache.get("c"), (True, 3)) + + +class TestGoogleMapsConfig(unittest.TestCase): + """Tests API key resolution priority, fingerprinting, and lazy imports.""" + + def test_explicit_api_key_priority(self) -> None: + with patch.dict(os.environ, {"MAPS_API_KEY": "env-key"}, clear=False): + cfg = GoogleMapsConfig(api_key="explicit-key") + self.assertEqual(cfg.get_api_key(), "explicit-key") + self.assertNotEqual(cfg.get_key_fingerprint(), "explicit-key") + self.assertEqual(len(cfg.get_key_fingerprint()), 16) + + def test_environment_variable_fallback(self) -> None: + with patch.dict(os.environ, {"MAPS_API_KEY": "env-key-123"}, clear=False): + cfg = GoogleMapsConfig() + self.assertEqual(cfg.get_api_key(), "env-key-123") + + def test_unconfigured_or_placeholder_returns_none(self) -> None: + with patch.dict( + os.environ, + { + "MAPS_API_KEY": "", + "MAPS_API_KEY_SECRET_RESOURCE": "", + }, + clear=True, + ): + cfg = GoogleMapsConfig() + self.assertIsNone(cfg.get_api_key()) + self.assertEqual(cfg.get_key_fingerprint(), "unconfigured") + + def test_missing_secret_manager_extra_raises_helpful_import_error( + self, + ) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch.dict(sys.modules, {"google.cloud.secretmanager": None}), + ): + cfg = GoogleMapsConfig( + secret_resource_name="projects/p/secrets/s/versions/latest" + ) + with self.assertRaisesRegex(ImportError, r"google-adk\[gcp\]"): + cfg.get_api_key() + + +class TestServicesAndTools(unittest.IsolatedAsyncioTestCase): + """Tests Street View pre-flight, Address Validation, Geocoding, and Tools.""" + + def setUp(self) -> None: + super().setUp() + clear_caches() + self.config = GoogleMapsConfig(api_key="AIzaSySecretKey12345") + + async def test_street_view_preflight_zero_results_skips_image(self) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + async def mock_http_get( + url: str, + params: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + del timeout, headers + calls.append((url, params)) + return 200, json.dumps({"status": "ZERO_RESULTS"}).encode("utf-8") + + with patch( + "google.adk.integrations.street_view._services._async_http_get", + side_effect=mock_http_get, + ): + result = await fetch_street_view_baseline( + 37.422, -122.084, config=self.config + ) + self.assertFalse(result.available) + self.assertIsNone(result.image_bytes) + self.assertEqual(len(calls), 1) + self.assertIn("streetview/metadata", calls[0][0]) + + async def test_street_view_preflight_ok_fetches_image_with_heading( + self, + ) -> None: + fake_jpeg = b"\xff\xd8\xff\xe0FAKE_JPEG_DATA" + calls: list[tuple[str, dict[str, Any]]] = [] + + async def mock_http_get( + url: str, + params: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + del timeout, headers + calls.append((url, params)) + if "metadata" in url: + meta = { + "status": "OK", + "pano_id": "pano_abc123", + "date": "2025-08", + "location": {"lat": 37.4219, "lng": -122.0840}, + } + return 200, json.dumps(meta).encode("utf-8") + return 200, fake_jpeg + + with patch( + "google.adk.integrations.street_view._services._async_http_get", + side_effect=mock_http_get, + ): + result = await fetch_street_view_baseline( + 37.4220, -122.0840, config=self.config + ) + self.assertTrue(result.available) + self.assertEqual(result.image_bytes, fake_jpeg) + self.assertEqual(result.pano_id, "pano_abc123") + self.assertEqual(result.imagery_date, "2025-08") + self.assertEqual(result.heading, 0.0) + self.assertIn("pano=pano_abc123", result.maps_url or "") + self.assertEqual(len(calls), 2) + self.assertEqual(calls[1][1].get("pano"), "pano_abc123") + self.assertEqual(calls[1][1].get("heading"), 0.0) + + async def test_address_validation_uses_header_and_preserves_city_state( + self, + ) -> None: + fake_api_response = { + "result": { + "address": { + "formattedAddress": ( + "500 Terry A Francois Blvd, San Francisco, CA 94158, USA" + ), + "addressComponents": [ + { + "componentType": "street_number", + "componentName": {"text": "500"}, + }, + { + "componentType": "route", + "componentName": {"text": "Terry A Francois Blvd"}, + }, + { + "componentType": "locality", + "componentName": {"text": "San Francisco"}, + }, + { + "componentType": "administrative_area_level_1", + "componentName": {"text": "CA"}, + }, + { + "componentType": "postal_code", + "componentName": {"text": "94158"}, + }, + ], + "missingComponentTypes": ["subpremise"], + }, + "metadata": {"business": False}, + } + } + captured_headers: list[dict[str, str] | None] = [] + captured_params: list[dict[str, Any]] = [] + + async def mock_post( + url: str, + params: dict[str, Any], + payload: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + del url, payload, timeout + captured_params.append(params) + captured_headers.append(headers) + return 200, json.dumps(fake_api_response).encode("utf-8") + + with patch( + "google.adk.integrations.street_view._services._async_http_post_json", + side_effect=mock_post, + ): + res = await classify_property_address_async( + "500 Terry A Francois Blvd, SF", config=self.config + ) + self.assertEqual(res.address_type, AddressType.MULTI_UNIT) + self.assertTrue(res.is_missing_subunit) + self.assertEqual( + res.base_address, + "500 TERRY A FRANCOIS BLVD, SAN FRANCISCO, CA 94158", + ) + self.assertIsNone(res.subunit) + self.assertEqual(captured_params[0], {}) + self.assertEqual( + (captured_headers[0] or {}).get("X-Goog-Api-Key"), + "AIzaSySecretKey12345", + ) + + async def test_api_key_redacted_and_transient_error_not_cached(self) -> None: + attempt = 0 + + async def flaky_post( + url: str, + params: dict[str, Any], + payload: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + del url, params, payload, timeout, headers + nonlocal attempt + attempt += 1 + if attempt == 1: + raise RuntimeError( + "Connection failed for https://example.com?key=AIzaSySecretKey12345" + ) + return ( + 200, + json.dumps({ + "result": { + "address": { + "addressComponents": [ + { + "componentType": "street_number", + "componentName": {"text": "10"}, + }, + { + "componentType": "route", + "componentName": {"text": "Oak St"}, + }, + ], + "missingComponentTypes": [], + }, + "metadata": {"business": False}, + } + }).encode("utf-8"), + ) + + with patch( + "google.adk.integrations.street_view._services._async_http_post_json", + side_effect=flaky_post, + ): + first = await classify_property_address_async( + "10 Oak St", config=self.config + ) + self.assertIsNotNone(first.maps_api_error) + self.assertNotIn("AIzaSySecretKey12345", first.maps_api_error or "") + self.assertIn("[REDACTED]", first.maps_api_error or "") + + second = await classify_property_address_async( + "10 Oak St", config=self.config + ) + self.assertIsNone(second.maps_api_error) + self.assertEqual(second.standardized_address, "10 OAK ST") + + def test_address_validation_regex_fallback_preserves_city_state(self) -> None: + unconfigured = GoogleMapsConfig(api_key=None) + with patch.dict(os.environ, {}, clear=True): + multi = classify_property_address( + "742 Evergreen Terrace Apt 2B, Springfield, IL 62704", + config=unconfigured, + ) + self.assertEqual(multi.address_type, AddressType.MULTI_UNIT) + self.assertEqual( + multi.base_address, + "742 EVERGREEN TERRACE, SPRINGFIELD, IL 62704", + ) + self.assertEqual( + multi.standardized_address, + "742 EVERGREEN TERRACE, APT 2B, SPRINGFIELD, IL 62704", + ) + self.assertEqual(multi.subunit, "APT 2B") + self.assertEqual(multi.maps_api_error, "MAPS_API_KEY unconfigured") + + single = classify_property_address( + "742 Evergreen Terrace, Springfield, IL 62704", + config=unconfigured, + ) + self.assertEqual(single.address_type, AddressType.SINGLE_FAMILY) + self.assertIsNone(single.subunit) + + async def test_reverse_geocode_async_and_sync(self) -> None: + fake_geo = { + "status": "OK", + "results": [{ + "formatted_address": ( + "1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA" + ) + }], + } + + async def mock_get( + url: str, + params: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + del url, params, timeout, headers + return 200, json.dumps(fake_geo).encode("utf-8") + + with patch( + "google.adk.integrations.street_view._services._async_http_get", + side_effect=mock_get, + ): + addr = await reverse_geocode_coordinates_async( + 37.4220656, -122.0840897, config=self.config + ) + self.assertEqual( + addr, "1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA" + ) + cached_addr = reverse_geocode_coordinates( + 37.4220659, -122.0840899, config=self.config + ) + self.assertEqual(cached_addr, addr) + + async def test_adk_tools_keep_session_state_json_serializable(self) -> None: + fake_jpeg = b"\xff\xd8\xff\xe0STREET_VIEW_BYTES" + + async def mock_http_get( + url: str, + params: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + del params, timeout, headers + if "metadata" in url: + return ( + 200, + json.dumps({ + "status": "OK", + "pano_id": "pano_xyz", + "date": "2026-01", + "location": {"lat": 37.421, "lng": -122.084}, + }).encode("utf-8"), + ) + if "geocode" in url: + return ( + 200, + json.dumps({ + "status": "OK", + "results": [{"formatted_address": "1600 Amphitheatre Pkwy"}], + }).encode("utf-8"), + ) + return 200, fake_jpeg + + ctx: Any = _DummyToolContext() + with ( + patch( + "google.adk.integrations.street_view._services._async_http_get", + side_effect=mock_http_get, + ), + patch.dict(os.environ, {"MAPS_API_KEY": "test-key"}, clear=False), + ): + sv_out = await street_view_fallback_tool(37.422, -122.084, ctx) + self.assertIn("Successfully captured Street View baseline", sv_out) + + invalid_out = await street_view_fallback_tool(999.0, -122.084, ctx) + self.assertIn("Invalid GPS coordinates", invalid_out) + + prop_out = await property_classification_tool("123 Main St #4A", ctx) + self.assertIn("MULTI_UNIT", prop_out) + + geo_out = await reverse_geocode_tool(37.422, -122.084, ctx) + self.assertIn("1600 Amphitheatre Pkwy", geo_out) + + serialized_state = json.dumps(ctx.state) + self.assertIsInstance(serialized_state, str) + self.assertTrue(ctx.state["street_view_baseline_used"]) + self.assertEqual( + ctx.state["street_view_artifact_name"], + STREET_VIEW_ARTIFACT_FILENAME, + ) + self.assertIn(STREET_VIEW_ARTIFACT_FILENAME, ctx.saved_artifacts) + self.assertTrue(ctx.state["is_multi_unit"]) + self.assertEqual( + ctx.state["reverse_geocoded_address"], + "1600 Amphitheatre Pkwy", + ) + + async def test_street_view_toolset_filtering_and_signatures(self) -> None: + toolset = StreetViewToolset(config=self.config) + funcs = toolset.get_tool_functions() + self.assertEqual(len(funcs), 3) + + for fn in funcs: + sig = inspect.signature(fn) + self.assertIn("tool_context", sig.parameters) + self.assertNotIn("config", sig.parameters) + + tools = await toolset.get_tools() + self.assertEqual(len(tools), 3) + + filtered_toolset = StreetViewToolset( + config=self.config, + tool_filter=["street_view_fallback_tool"], + ) + filtered_tools = await filtered_toolset.get_tools() + self.assertEqual(len(filtered_tools), 1) + self.assertEqual(filtered_tools[0].name, "street_view_fallback_tool") + + async def test_audit_photo_with_fallback_clamps_confidence(self) -> None: + fake_jpeg = b"\xff\xd8\xff\xe0TARGET_PHOTO" + + async def mock_http_get( + url: str, + params: dict[str, Any], + timeout: float, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + del params, timeout, headers + if "metadata" in url: + return ( + 200, + json.dumps({ + "status": "OK", + "pano_id": "pano_audit", + "date": "2026-02", + "location": {"lat": 37.421, "lng": -122.084}, + }).encode("utf-8"), + ) + return 200, fake_jpeg + + baseline_resp = MagicMock() + baseline_resp.text = "Two-story brick home with white porch columns." + verdict_resp = MagicMock() + verdict_resp.parsed = None + verdict_resp.text = ( + '```json\n{"verdict_label": "CONFIRMED_MATCH", "match": true, ' + '"confidence": 1.4, "reason": "Brick facade matches."}\n```' + ) + + mock_client = MagicMock() + mock_client.aio.models.generate_content = AsyncMock( + side_effect=[baseline_resp, verdict_resp] + ) + ctx: Any = _DummyToolContext() + + with patch( + "google.adk.integrations.street_view._services._async_http_get", + side_effect=mock_http_get, + ): + verdict = await audit_photo_with_fallback( + target_photo_bytes=fake_jpeg, + historical_photos=None, + latitude=37.422, + longitude=-122.084, + tool_context=ctx, + genai_client=mock_client, + model="gemini-2.5-flash", + config=self.config, + ) + self.assertEqual(verdict["verdict_label"], "CONFIRMED_MATCH") + self.assertTrue(verdict["match"]) + self.assertEqual(verdict["confidence"], 1.0) + self.assertEqual(verdict["baseline_source"], "STREET_VIEW_FALLBACK") + + +if __name__ == "__main__": + unittest.main() From b881592791e876d6637610946fede2c2192b3558 Mon Sep 17 00:00:00 2001 From: Shelby James Date: Tue, 3 Nov 2026 09:25:57 -0500 Subject: [PATCH 2/2] chore(street_view): default to LlmAgent.DEFAULT_MODEL instead of hardcoded model string --- docs/guides/integrations/street_view/index.md | 3 --- src/google/adk/integrations/street_view/README.md | 1 - src/google/adk/integrations/street_view/_tools.py | 7 ++----- .../unittests/integrations/street_view/test_street_view.py | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/guides/integrations/street_view/index.md b/docs/guides/integrations/street_view/index.md index d778d421051..c0f0681f86c 100644 --- a/docs/guides/integrations/street_view/index.md +++ b/docs/guides/integrations/street_view/index.md @@ -29,7 +29,6 @@ from google.adk.integrations.street_view import default_config agent = LlmAgent( name="spatial_auditor", - model="gemini-2.5-flash", instruction=( "Classify the recipient address, verify GPS telemetry with reverse " "geocoding, and capture a Street View baseline when prior imagery is " @@ -56,7 +55,6 @@ from google.adk.integrations.street_view import street_view_fallback_tool agent = LlmAgent( name="lightweight_auditor", - model="gemini-2.5-flash", tools=[ street_view_fallback_tool, property_classification_tool, @@ -117,7 +115,6 @@ async def run_visual_audit(target_jpeg: bytes, tool_context): longitude=-122.0840897, tool_context=tool_context, genai_client=client, - model="gemini-2.5-flash", ) return verdict ``` diff --git a/src/google/adk/integrations/street_view/README.md b/src/google/adk/integrations/street_view/README.md index 2f36a0c17e2..22b95d42729 100644 --- a/src/google/adk/integrations/street_view/README.md +++ b/src/google/adk/integrations/street_view/README.md @@ -99,7 +99,6 @@ from google.adk.integrations.street_view import StreetViewToolset agent = LlmAgent( name="spatial_auditor", - model="gemini-2.5-flash", instruction="Verify delivery addresses, GPS telemetry, and outdoor imagery.", tools=[ StreetViewToolset( diff --git a/src/google/adk/integrations/street_view/_tools.py b/src/google/adk/integrations/street_view/_tools.py index 5e503c7de92..6c6aa4b6c91 100644 --- a/src/google/adk/integrations/street_view/_tools.py +++ b/src/google/adk/integrations/street_view/_tools.py @@ -26,6 +26,7 @@ from google.genai import types +from ...agents.llm_agent import LlmAgent from ...tools.tool_context import ToolContext from ._config import default_config from ._config import GoogleMapsConfig @@ -334,11 +335,7 @@ async def audit_photo_with_fallback( config: GoogleMapsConfig | None = None, ) -> dict[str, Any]: """Compares a target photo against historical photos or Street View.""" - resolved_model = model if model is not None else os.getenv("MODEL_NAME") - if not resolved_model: - raise ValueError( - "Model name must be passed via `model` or set in `MODEL_NAME`." - ) + resolved_model = model or os.getenv("MODEL_NAME") or LlmAgent.DEFAULT_MODEL baseline_parts: list[types.Part] = [] baseline_source = "NONE" diff --git a/tests/unittests/integrations/street_view/test_street_view.py b/tests/unittests/integrations/street_view/test_street_view.py index ec7f3daa78e..4d7a5a1f26e 100644 --- a/tests/unittests/integrations/street_view/test_street_view.py +++ b/tests/unittests/integrations/street_view/test_street_view.py @@ -545,7 +545,6 @@ async def mock_http_get( longitude=-122.084, tool_context=ctx, genai_client=mock_client, - model="gemini-2.5-flash", config=self.config, ) self.assertEqual(verdict["verdict_label"], "CONFIRMED_MATCH")