Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/scripts/check-release-process-guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,7 @@ def check_desktop_qualification_runner() -> list[str]:
"self-hosted",
"macos",
"omi-desktop-qualification",
"ref: ${{ inputs.release_tag }}",
'checkout --quiet --detach "refs/tags/$RELEASE_TAG"',
"check-desktop-auto-beta-candidate.py",
"--automatic",
"actions/create-github-app-token@v3",
Expand Down
6 changes: 3 additions & 3 deletions backend/models/geolocation.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from typing import Optional

from pydantic import BaseModel
from pydantic import BaseModel, Field


class Geolocation(BaseModel):
google_place_id: Optional[str] = None
latitude: float
longitude: float
latitude: float = Field(ge=-90, le=90)
longitude: float = Field(ge=-180, le=180)
address: Optional[str] = None
location_type: Optional[str] = None
63 changes: 60 additions & 3 deletions backend/tests/unit/test_chat_async_offload.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import threading
from contextlib import ExitStack, nullcontext
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import AsyncMock, patch

# Hermetic config so importing the chat modules (which construct Typesense / OpenAI clients
# and require the encryption key) succeeds without network. Matches conftest defaults.
Expand All @@ -49,7 +49,9 @@ async def _collect_agentic_chunks(producer, callback_data=None):
stack.enter_context(patch.object(agentic, 'get_user_timezone', lambda _uid: 'UTC'))
stack.enter_context(patch.object(agentic, '_get_agentic_qa_prompt', lambda *_args, **_kwargs: 'SYSTEM'))
stack.enter_context(patch.object(agentic, 'load_app_tools', lambda _uid: []))
stack.enter_context(patch.object(agentic, 'get_current_datetime_block', lambda _uid, tz=None: ''))
stack.enter_context(
patch.object(agentic, 'get_current_datetime_block', lambda _uid, tz=None, location=None: '')
)
stack.enter_context(patch.object(agentic, '_convert_tools', lambda _core, _app: ([], {})))
stack.enter_context(patch.object(agentic, '_messages_to_anthropic', lambda _messages: []))
stack.enter_context(patch.object(agentic, '_inject_current_datetime', lambda messages, _block: messages))
Expand Down Expand Up @@ -84,6 +86,61 @@ def fake_is_file_question(question):
assert ran_on['thread'] is not loop_thread, "retrieve_is_file_question must run off the event-loop thread"


async def test_mobile_city_context_uses_only_mobile_platforms():
async def fake_run_blocking(_executor, _function, _uid):
return {'latitude': 40.7128, 'longitude': -74.006}

async def fake_city(latitude, longitude):
assert (latitude, longitude) == (40.7128, -74.006)
return 'New York, New York, United States'

with patch.object(agentic, 'run_blocking', fake_run_blocking), patch(
'utils.conversations.location.async_get_google_maps_city', fake_city
):
assert await agentic.get_mobile_city('uid1', 'ios') == 'New York, New York, United States'
assert await agentic.get_mobile_city('uid1', 'macos') is None


async def test_chat_router_passes_metadata_to_every_interactive_path():
message = SimpleNamespace(sender='human', text='What should I do?', files_id=['file1'])
session = SimpleNamespace(id='session1', file_ids=['file1'])
metadata = '<current_datetime>now</current_datetime>'
seen = []

async def stream(*_args, **kwargs):
seen.append(kwargs['current_datetime_block'])
yield None

with patch.object(graph, '_current_prompt_metadata', AsyncMock(return_value=(metadata, 'UTC'))):
persona = SimpleNamespace(id='persona1', is_a_persona=lambda: True)
with patch.object(graph, 'execute_persona_chat_stream', stream):
assert [chunk async for chunk in graph.execute_chat_stream('uid1', [message], app=persona)] == [None]
with patch.object(graph, '_has_file_context', AsyncMock(return_value=True)), patch.object(
graph, '_execute_file_chat_stream', stream
):
assert [chunk async for chunk in graph.execute_chat_stream('uid1', [message], chat_session=session)] == [
None
]
with patch.object(graph, 'execute_agentic_chat_stream', stream):
assert [chunk async for chunk in graph.execute_chat_stream('uid1', [message])] == [None]

assert seen == [metadata, metadata, metadata]


def test_prompt_metadata_is_prepended_to_the_live_user_turn():
assert graph._with_prompt_metadata('question', '<current_datetime>now</current_datetime>') == (
'<current_datetime>now</current_datetime>\n\nquestion'
)


async def test_prompt_metadata_falls_back_to_utc_when_context_lookup_fails():
with patch.object(graph, 'run_blocking', AsyncMock(side_effect=RuntimeError('offline'))):
metadata, tz = await graph._current_prompt_metadata('uid1', 'ios')

assert tz == 'UTC'
assert 'Current date time in UTC:' in metadata


def _file_chat_tool_for_stream_test():
"""Create a FileChatTool without its production Firestore constructor for a hermetic stream test."""
tool = object.__new__(chat_file.FileChatTool)
Expand Down Expand Up @@ -226,7 +283,7 @@ async def fake_agent_stream(
with patch.object(agentic, 'get_user_timezone', rec('tz', 'UTC')), patch.object(
agentic, '_get_agentic_qa_prompt', rec('prompt', 'SYSTEM')
), patch.object(agentic, 'load_app_tools', rec('app_tools', [])), patch.object(
agentic, 'get_current_datetime_block', lambda uid, tz=None: ''
agentic, 'get_current_datetime_block', lambda uid, tz=None, location=None: ''
), patch.object(
agentic, '_convert_tools', lambda core, app: ([], {})
), patch.object(
Expand Down
45 changes: 45 additions & 0 deletions backend/tests/unit/test_city_prompt_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import asyncio
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch

from utils.conversations.location import async_get_google_maps_city


def test_city_context_uses_cached_value_without_calling_maps():
with patch('utils.conversations.location.r') as redis, patch(
'utils.conversations.location.get_maps_client'
) as client:
redis.get.return_value = b'New York, New York, United States'

assert asyncio.run(async_get_google_maps_city(40.7128, -74.006)) == 'New York, New York, United States'
client.assert_not_called()


def test_city_context_uses_locality_and_never_returns_coordinates():
@asynccontextmanager
async def semaphore():
yield

response = MagicMock()
response.json.return_value = {
'status': 'OK',
'results': [
{
'address_components': [
{'long_name': 'New York', 'types': ['locality']},
{'long_name': 'New York', 'types': ['administrative_area_level_1']},
{'long_name': 'United States', 'types': ['country']},
]
}
],
}
client = MagicMock()
client.get = AsyncMock(return_value=response)
with patch('utils.conversations.location.r') as redis, patch(
'utils.conversations.location.get_maps_client', return_value=client
), patch('utils.conversations.location.get_maps_semaphore', return_value=semaphore()):
redis.get.return_value = None

assert asyncio.run(async_get_google_maps_city(40.7128, -74.006)) == 'New York, New York, United States'
assert '40.7128' not in redis.set.call_args.args[1]
assert redis.set.call_args.kwargs['ex'] == 172800
2 changes: 1 addition & 1 deletion backend/tests/unit/test_desktop_release_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ def test_qualification_workflow_binds_immutable_controls_and_candidate_identity(
codemagic = CODEMAGIC_CONFIG.read_text(encoding="utf-8")
qualification = QUALIFY_BETA_WORKFLOW.read_text(encoding="utf-8")
assert '-f release_tag="$CM_TAG" --ref "$CM_TAG"' in codemagic
assert "ref: ${{ inputs.release_tag }}" in qualification
assert 'checkout --quiet --detach "refs/tags/$RELEASE_TAG"' in qualification
# The release attachment is content-addressed from the exact checked-out
# candidate SHA and evidence digest, not a mutable tag-only filename.
assert 'asset="qualification-evidence-${TARGET_SHA}-${digest}.json"' in qualification
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/unit/test_prompt_cache_integration.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""

Check warning on line 1 in backend/tests/unit/test_prompt_cache_integration.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/tests/unit/test_prompt_cache_integration.py is 1193 lines; consider splitting files over 800 lines.
Integration tests for prompt cache optimization (#4676).

Unlike the source-level tests in test_prompt_cache_optimization.py, these tests
Expand Down Expand Up @@ -991,6 +991,19 @@
assert "2024-01-19" in block, "Datetime block should contain the live date"


def test_current_datetime_block_includes_city_without_coordinates():
chat_mod = _get_chat_module()
_set_user(chat_mod, "Alice", "America/New_York")

block = chat_mod.get_current_datetime_block(
"uid_alice", tz="America/New_York", location="New York, New York, United States"
)

assert "Current city-level location: New York, New York, United States" in block
assert "latitude" not in block.lower()
assert "longitude" not in block.lower()


def test_datetime_injected_into_user_turn_not_system():
"""
_inject_current_datetime must attach the datetime block to the latest user turn so the
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/unit/test_prompt_metadata_safety.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pydantic import ValidationError

from models.geolocation import Geolocation
from utils.llm.chat import get_current_datetime_block


def test_prompt_metadata_escapes_city_text():
metadata = get_current_datetime_block('uid1', tz='UTC', location='A < B & C')

assert 'Recent city-level location: A &lt; B &amp; C' in metadata


def test_geolocation_rejects_coordinates_outside_the_earth():
for latitude, longitude in ((90.1, 0), (0, 180.1), (float('nan'), 0)):
try:
Geolocation(latitude=latitude, longitude=longitude)
except ValidationError:
continue
raise AssertionError('invalid geolocation was accepted')
39 changes: 39 additions & 0 deletions backend/utils/conversations/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,42 @@ async def async_resolve_geolocation(geolocation: Optional[Geolocation]) -> Optio
logger.error('async_resolve_geolocation enrichment failed: %s', e)
return geolocation
return enriched or geolocation


async def async_get_google_maps_city(latitude: float, longitude: float) -> Optional[str]:
cache_key = f"geocode-city:{latitude:.3f},{longitude:.3f}"
try:
cached = r.get(cache_key)
if cached:
return cached.decode() if isinstance(cached, bytes) else str(cached)
except Exception as error:
logger.warning('Failed to read city geocode cache error_type=%s', type(error).__name__)

key = os.getenv('GOOGLE_MAPS_API_KEY')
try:
async with get_maps_semaphore():
response = await get_maps_client().get(
"https://maps.googleapis.com/maps/api/geocode/json",
params={"latlng": f"{latitude},{longitude}", "key": key},
)
data = response.json()
except Exception as error:
logger.error('City geocoding failed error_type=%s', type(error).__name__)
return None

if data.get('status') != 'OK' or not data.get('results'):
return None
parts = {}
for component in data['results'][0].get('address_components') or []:
for component_type in component.get('types') or []:
if component_type in {'locality', 'postal_town', 'administrative_area_level_1', 'country'}:
parts.setdefault(component_type, component.get('long_name'))
city = parts.get('locality') or parts.get('postal_town')
if not city:
return None
result = ', '.join(part for part in (city, parts.get('administrative_area_level_1'), parts.get('country')) if part)
try:
r.set(cache_key, result, ex=172800)
except Exception as error:
logger.warning('Failed to cache city geocode error_type=%s', type(error).__name__)
return result
5 changes: 4 additions & 1 deletion backend/utils/llm/chat.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json

Check warning on line 1 in backend/utils/llm/chat.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/utils/llm/chat.py is 1479 lines; consider splitting files over 800 lines.
import re
from datetime import datetime, timezone
from html import escape
from typing import Any, List, Optional, cast
from zoneinfo import ZoneInfo

Expand Down Expand Up @@ -480,7 +481,7 @@
return "UTC"


def get_current_datetime_block(uid: str, tz: Optional[str] = None) -> str:
def get_current_datetime_block(uid: str, tz: Optional[str] = None, location: Optional[str] = None) -> str:
"""Build the current-datetime block injected into the user turn.

Kept out of the cached system prefix so the cached bytes stay stable across requests
Expand All @@ -497,15 +498,17 @@
tz = "UTC"
current_datetime_str = current_datetime_user.strftime('%Y-%m-%d %H:%M:%S')
current_datetime_iso = current_datetime_user.isoformat()
location_line = f"Current city-level location: {escape(location, quote=False)}\n" if location else ""
return (
"<current_datetime>\n"
f"Current date time in {tz}: {current_datetime_str}\n"
f"Current date time ISO format: {current_datetime_iso}\n"
f"{location_line}"
"</current_datetime>"
)


def _get_agentic_qa_prompt( # type: ignore[reportUnusedFunction] # imported by retrieval/agentic.py

Check warning on line 511 in backend/utils/llm/chat.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

_get_agentic_qa_prompt is 358 lines; consider extracting focused helpers over 150 lines.
uid: str,
app: Optional[App] = None,
messages: Optional[List[Message]] = None,
Expand Down
27 changes: 25 additions & 2 deletions backend/utils/retrieval/agentic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""

Check warning on line 1 in backend/utils/retrieval/agentic.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/utils/retrieval/agentic.py is 925 lines; consider splitting files over 800 lines.
Agentic chat system using Anthropic native tool use.

This module implements a tool-calling agent that autonomously decides which tools
Expand Down Expand Up @@ -425,12 +425,30 @@
return anthropic_messages


async def get_mobile_city(uid: str, platform: Optional[str]) -> Optional[str]:
if platform is None or platform.strip().lower() not in {'ios', 'android'}:
return None
try:
from database.redis_db import get_cached_user_geolocation
from utils.conversations.location import async_get_google_maps_city

geolocation = await run_blocking(db_executor, get_cached_user_geolocation, uid)
if not geolocation:
return None
return await async_get_google_maps_city(float(geolocation['latitude']), float(geolocation['longitude']))
except (KeyError, TypeError, ValueError):
return None
except Exception as error:
logger.warning('Mobile city context unavailable error_type=%s', type(error).__name__)
return None


# ---------------------------------------------------------------------------
# Core Anthropic agent streaming loop
# ---------------------------------------------------------------------------


async def _run_anthropic_agent_stream(

Check warning on line 451 in backend/utils/retrieval/agentic.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

_run_anthropic_agent_stream is 165 lines; consider extracting focused helpers over 150 lines.
system_prompt: str,
messages: list,
tool_schemas: list,
Expand Down Expand Up @@ -664,7 +682,7 @@


@_traceable(name="chat.anthropic.stream", run_type="chain")
async def execute_agentic_chat_stream(

Check warning on line 685 in backend/utils/retrieval/agentic.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

execute_agentic_chat_stream is 241 lines; consider extracting focused helpers over 150 lines.
uid: str,
messages: List[Message],
app: Optional[App] = None,
Expand All @@ -672,6 +690,8 @@
chat_session: Optional[ChatSession] = None,
context: Optional[PageContext] = None,
platform: Optional[str] = None,
current_datetime_block: Optional[str] = None,
tz: Optional[str] = None,
) -> AsyncGenerator[str, None]:
"""Execute an agentic chat interaction with streaming.

Expand All @@ -684,7 +704,8 @@
# These helpers perform Firestore and LangSmith I/O before the producer task exists,
# so they share the first-event deadline instead of leaving the SSE body silent.
async with asyncio.timeout(AGENT_STREAM_FIRST_EVENT_TIMEOUT_SECONDS):
tz = await run_blocking(db_executor, get_user_timezone, uid)
tz = tz or await run_blocking(db_executor, get_user_timezone, uid)
city = await get_mobile_city(uid, platform) if current_datetime_block is None else None
system_prompt = await run_blocking(
db_executor, _get_agentic_qa_prompt, uid, app, messages, context=context, tz=tz, platform=platform
)
Expand Down Expand Up @@ -755,7 +776,9 @@
# Convert messages to Anthropic format. The current datetime is injected into the user
# turn (not the system prompt) so the cache_control system prefix stays byte-stable.
anthropic_messages = _messages_to_anthropic(messages)
anthropic_messages = _inject_current_datetime(anthropic_messages, get_current_datetime_block(uid, tz=tz))
anthropic_messages = _inject_current_datetime(
anthropic_messages, current_datetime_block or get_current_datetime_block(uid, tz=tz, location=city)
)

callback = AsyncStreamingCallback()

Expand Down
Loading
Loading