Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ OPENROUTER_API_KEY=replace-me
# Get a free API key at: https://brave.com/search/api/
# BRAVE_SEARCH_API_KEY=your_brave_search_api_key_here

# Google Maps Routes API (optional distance, ETA, and traffic tools)
# Use a separate server-side key restricted to the Routes API and apply
# project quotas or budget alerts before enabling traffic-aware requests.
# GOOGLE_MAPS_ROUTES_API_KEY=your_google_maps_routes_api_key_here

# ---------------------------------------------------------------------------
# Browser Use Cloud (optional browser automation for the agent)
# ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ jobs:
TELEGRAM_TOOL_NOTIFICATIONS: ${{ secrets.TELEGRAM_TOOL_NOTIFICATIONS }}
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
GOOGLE_MAPS_ROUTES_API_KEY: ${{ secrets.GOOGLE_MAPS_ROUTES_API_KEY }}
BROWSER_USE_API_KEY: ${{ secrets.BROWSER_USE_API_KEY }}
SANDBOX_ENABLED: ${{ secrets.SANDBOX_ENABLED }}
SANDBOX_DOMAIN: ${{ secrets.SANDBOX_DOMAIN }}
Expand Down Expand Up @@ -171,6 +172,7 @@ jobs:
TELEGRAM_TOOL_NOTIFICATIONS \
EXA_API_KEY \
BRAVE_SEARCH_API_KEY \
GOOGLE_MAPS_ROUTES_API_KEY \
BROWSER_USE_API_KEY \
SANDBOX_ENABLED \
SANDBOX_DOMAIN \
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Optional tools follow the project's cloud-first principle:

- models use OpenRouter or Google;
- search can use Exa with Brave as a fallback;
- distance, ETA, alternatives, and point-in-time traffic can use Google Maps
Routes;
- browser automation can use Browser Use Cloud;
- vector memory can use Qdrant Cloud; and
- code execution can use an OpenSandbox server.
Expand Down
1 change: 1 addition & 0 deletions docs/base-infra/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ The token is required and format-validated when Telegram is enabled.
| --- | --- | --- |
| `EXA_API_KEY` | unset | Primary Exa search integration |
| `BRAVE_SEARCH_API_KEY` | unset | Brave search fallback |
| `GOOGLE_MAPS_ROUTES_API_KEY` | unset | Distance, ETA, and traffic estimates |
| `BROWSER_USE_API_KEY` | unset | Browser Use Cloud automation |

These integrations are optional. Their absence should not replace the required
Expand Down
87 changes: 87 additions & 0 deletions docs/google-maps-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Google Maps Routes

Blacki can use the Google Maps Routes API for fresh distance, ETA, traffic, and
route-comparison questions. The integration is optional and remains disabled
unless `GOOGLE_MAPS_ROUTES_API_KEY` is configured.

## Configure the API

1. Enable billing and the Routes API in the Google Cloud project.
2. Create a server-side API key dedicated to this integration.
3. Restrict the key to the Routes API and to the deployed server where
practical.
4. Configure quotas and billing alerts.
5. Set the key in `.env`:

```dotenv
GOOGLE_MAPS_ROUTES_API_KEY=replace-me
```

Do not reuse the Gemini `GOOGLE_API_KEY`. Separating the keys allows independent
restrictions, rotation, and quotas.

For the repository's production deployment workflow, add the same value as the
GitHub Actions environment secret `GOOGLE_MAPS_ROUTES_API_KEY`. Code-quality
jobs do not need this secret because provider calls are mocked.

## Agent capabilities

`get_route_estimate` returns:

- distance in meters and kilometers;
- traffic-aware and static durations;
- calculated traffic delay;
- optional alternate routes;
- provider fallback and route warnings;
- Google Maps attribution.

`compare_route_scenarios` compares up to five explicitly named scenarios for
the same endpoints. A scenario can vary departure time, travel mode, traffic
model, and avoid options. Requests run with bounded concurrency to limit burst
traffic and cost.

For a current driving estimate, the agent uses:

- travel mode `DRIVE`;
- departure time `now`; and
- traffic model `BEST_GUESS`.

`OPTIMISTIC` and `PESSIMISTIC` are also supported for driving. Non-driving
modes use `NONE` because Google traffic models are limited to driving routes.

## Location and time inputs

Plain location strings are sent as addresses. A known Google place ID can be
supplied using the `place_id:` prefix:

```text
place_id:ChIJ...
```

Future departure times must be RFC 3339 timestamps containing a timezone
offset. This keeps an instruction such as "8:30" from being interpreted in the
wrong timezone.

## Operational boundaries

- Route responses are point-in-time estimates. The Routes API does not provide
continuous tracking or a traffic push subscription.
- Avoid-toll, highway, and ferry options are preferences, not guarantees.
- Walking, bicycling, and two-wheeler results are beta and include a warning.
- The integration requests a fixed minimal response field mask. It does not
request toll pricing, eco routes, traffic-colored polylines, or route
matrices.
- Route responses and traffic snapshots are not persisted. Google Maps
Platform storage and attribution policies still apply to downstream uses.
- Provider errors are normalized without logging the API key, request payload,
exact locations, or resolved place IDs. When Routes is enabled, OpenInference
input and output capture is disabled for the process.

See the official [Compute Routes
reference](https://developers.google.com/maps/documentation/routes/reference/rest/v2/TopLevel/computeRoutes),
[traffic model
guide](https://developers.google.com/maps/documentation/routes/traffic-model),
[field-mask
guidance](https://developers.google.com/maps/documentation/routes/choose_fields),
and [Routes
policies](https://developers.google.com/maps/documentation/routes/policies).
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ nav:
- Observability: base-infra/observability.md
- Understand:
- Architecture: architecture.md
- Google Maps Routes: google-maps-routes.md
- Docker Compose: base-infra/docker-compose-workflow.md
- Docker image: base-infra/dockerfile-strategy.md
- Reference:
Expand Down
59 changes: 44 additions & 15 deletions src/blacki/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
from .telegram.api import TelegramApiClient, TelegramApiError
from .telegram.formatting import escape_markdown, format_for_telegram
from .telegram.types import ParseMode
from .utils.privacy import (
REDACTED_ROUTE_DETAILS,
ROUTE_TOOL_NAMES,
redact_route_tool_payload,
route_data_redaction_enabled,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -241,7 +247,7 @@ async def notify_telegram_before_tool(
return None

escaped_name = escape_markdown(tool.name)
args_text = _format_tool_args(args)
args_text = _format_tool_args(redact_route_tool_payload(tool.name, args))
text = f"🔧 Using tool: *{escaped_name}*{args_text}"

try:
Expand Down Expand Up @@ -391,7 +397,9 @@ def before_agent(self, callback_context: CallbackContext) -> None:
)
self.logger.debug(f"State keys: {callback_context.state.to_dict().keys()}")

if user_content := callback_context.user_content:
if route_data_redaction_enabled():
self.logger.debug(f"User Content: {REDACTED_ROUTE_DETAILS}")
elif user_content := callback_context.user_content:
content_data = user_content.model_dump(exclude_none=True, mode="json")
self.logger.debug(f"User Content: {content_data}")

Expand All @@ -410,7 +418,9 @@ def after_agent(self, callback_context: CallbackContext) -> None:
)
self.logger.debug(f"State keys: {callback_context.state.to_dict().keys()}")

if user_content := callback_context.user_content:
if route_data_redaction_enabled():
self.logger.debug(f"User Content: {REDACTED_ROUTE_DETAILS}")
elif user_content := callback_context.user_content:
content_data = user_content.model_dump(exclude_none=True, mode="json")
self.logger.debug(f"User Content: {content_data}")

Expand All @@ -435,15 +445,21 @@ def before_model(
)
self.logger.debug(f"State keys: {callback_context.state.to_dict().keys()}")

if user_content := callback_context.user_content:
redact_content = route_data_redaction_enabled()
if redact_content:
self.logger.debug(f"User Content: {REDACTED_ROUTE_DETAILS}")
elif user_content := callback_context.user_content:
content_data = user_content.model_dump(exclude_none=True, mode="json")
self.logger.debug(f"User Content: {content_data}")

self.logger.debug(f"LLM request contains {len(llm_request.contents)} messages:")
for i, content in enumerate(llm_request.contents, start=1):
self.logger.debug(
f"Content {i}: {content.model_dump(exclude_none=True, mode='json')}"
)
if redact_content:
self.logger.debug(f"LLM request content: {REDACTED_ROUTE_DETAILS}")
else:
for i, content in enumerate(llm_request.contents, start=1):
self.logger.debug(
f"Content {i}: {content.model_dump(exclude_none=True, mode='json')}"
)

return None

Expand All @@ -465,11 +481,16 @@ def after_model(
)
self.logger.debug(f"State keys: {callback_context.state.to_dict().keys()}")

if user_content := callback_context.user_content:
redact_content = route_data_redaction_enabled()
if redact_content:
self.logger.debug(f"User Content: {REDACTED_ROUTE_DETAILS}")
elif user_content := callback_context.user_content:
content_data = user_content.model_dump(exclude_none=True, mode="json")
self.logger.debug(f"User Content: {content_data}")

if llm_content := llm_response.content:
if redact_content and llm_response.content is not None:
self.logger.debug(f"LLM response: {REDACTED_ROUTE_DETAILS}")
elif llm_content := llm_response.content:
response_data = llm_content.model_dump(exclude_none=True, mode="json")
self.logger.debug(f"LLM response: {response_data}")

Expand All @@ -496,14 +517,17 @@ def before_tool(
)
self.logger.debug(f"State keys: {tool_context.state.to_dict().keys()}")

if content := tool_context.user_content:
redact_content = route_data_redaction_enabled() or tool.name in ROUTE_TOOL_NAMES
if redact_content:
self.logger.debug(f"User Content: {REDACTED_ROUTE_DETAILS}")
elif content := tool_context.user_content:
self.logger.debug(
f"User Content: {content.model_dump(exclude_none=True, mode='json')}"
)

actions_data = tool_context.actions.model_dump(exclude_none=True, mode="json")
self.logger.debug(f"EventActions: {actions_data}")
self.logger.debug(f"args: {args}")
self.logger.debug(f"args: {redact_route_tool_payload(tool.name, args)}")

return None

Expand All @@ -530,14 +554,19 @@ def after_tool(
)
self.logger.debug(f"State keys: {tool_context.state.to_dict().keys()}")

if content := tool_context.user_content:
redact_content = route_data_redaction_enabled() or tool.name in ROUTE_TOOL_NAMES
if redact_content:
self.logger.debug(f"User Content: {REDACTED_ROUTE_DETAILS}")
elif content := tool_context.user_content:
self.logger.debug(
f"User Content: {content.model_dump(exclude_none=True, mode='json')}"
)

actions_data = tool_context.actions.model_dump(exclude_none=True, mode="json")
self.logger.debug(f"EventActions: {actions_data}")
self.logger.debug(f"args: {args}")
self.logger.debug(f"Tool response: {tool_response}")
self.logger.debug(f"args: {redact_route_tool_payload(tool.name, args)}")
self.logger.debug(
f"Tool response: {redact_route_tool_payload(tool.name, tool_response)}"
)

return None
53 changes: 46 additions & 7 deletions src/blacki/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@
</reminder_policy>"""


ROUTES_POLICY = """\
<routes_policy>
Use the dedicated route tools for distance, travel time, current traffic, route
alternatives, and route-scenario comparisons. Do not use general web search,
browser automation, or memory for those values. A request for current or live
traffic requires a fresh route lookup; the result is a point-in-time estimate,
not continuous tracking.

Use get_route_estimate for one route and compare_route_scenarios only when the
user asks to compare departure times, modes, traffic assumptions, or avoid
options. For current driving traffic use DRIVE, now, and BEST_GUESS. Use NONE
as the traffic model for non-driving modes. Treat avoid options as preferences,
not guarantees, and preserve all provider warnings and Google Maps attribution.
Ask one focused question when an endpoint or required departure time is missing.
</routes_policy>"""


DOMAIN_PATTERNS = {
"nutrition": re.compile(
r"\b(?:ate|eaten|eating|drank|drink|food|meal|breakfast|lunch|dinner|"
Expand All @@ -139,6 +156,16 @@
r"\b(?:remind|reminder|schedule|alarm|notify|notification)\b",
re.IGNORECASE,
),
"routes": re.compile(
r"\b(?:route|routes|directions?|distance\s+(?:from|to|between)|how\s+far|"
r"travel\s+time|traffic|"
r"commute|avoid\s+(?:tolls?|highways?|ferries)|get\s+there|on\s+foot|"
r"by\s+(?:car|bike|bicycle|transit)|"
r"eta\s+(?:to|from|between|for\s+(?:the\s+)?(?:route|trip|commute))|"
r"(?:drive|driving|walk|walking|bicycle|bicycling|bike|biking|"
r"two[-\s]wheeler)\s+(?:to|from|between))\b",
re.IGNORECASE,
),
"search": re.compile(
r"\b(?:latest|current|news|recent|today|as of|verify|verified|search|"
r"look up|source|sources|citation|citations)\b",
Expand Down Expand Up @@ -172,6 +199,7 @@
}
),
"reminder": frozenset({"schedule_reminder", "list_reminders", "cancel_reminder"}),
"routes": frozenset({"get_route_estimate", "compare_route_scenarios"}),
"search": frozenset({"exa_search", "brave_search"}),
}

Expand Down Expand Up @@ -231,12 +259,18 @@ def select_domain_policy_names(
) -> tuple[str, ...]:
"""Select request-relevant domains that also have enabled tools."""
selected = []
for domain in ("nutrition", "workout", "reminder", "search"):
for domain in ("nutrition", "workout", "reminder", "routes"):
if (
DOMAIN_PATTERNS[domain].search(user_text)
and DOMAIN_TOOL_NAMES[domain] & available_tool_names
):
selected.append(domain)
if (
"routes" not in selected
and DOMAIN_PATTERNS["search"].search(user_text)
and DOMAIN_TOOL_NAMES["search"] & available_tool_names
):
selected.append("search")
return tuple(selected)


Expand All @@ -258,6 +292,8 @@ def build_domain_instruction(
blocks.append(workout_policy)
elif domain == "reminder":
blocks.append(REMINDER_POLICY)
elif domain == "routes":
blocks.append(ROUTES_POLICY)
elif domain == "search": # pragma: no branch - search is the final domain
blocks.append(_build_search_policy(available_tool_names))
return "\n\n".join(blocks)
Expand Down Expand Up @@ -308,15 +344,18 @@ async def before_model_callback(
if not user_text:
return

instruction = build_domain_instruction(
user_text, frozenset(llm_request.tools_dict)
)
available_tools = frozenset(llm_request.tools_dict)
selected_domains = select_domain_policy_names(user_text, available_tools)
instruction = build_domain_instruction(user_text, available_tools)
if instruction:
llm_request.append_instructions([instruction])

if "search" in select_domain_policy_names(
user_text, frozenset(llm_request.tools_dict)
):
if "routes" in selected_domains:
_hide_tools(
llm_request,
set(DOMAIN_TOOL_NAMES["search"] & available_tools),
)
elif "search" in selected_domains:
_apply_search_tool_budget(callback_context, llm_request)

async def before_tool_callback(
Expand Down
Loading
Loading