Skip to content

Commit 8a93ba7

Browse files
committed
feat(chat): add mobile city context
Verification: backend prompt-cache, async-offload, and geocoding unit tests; backend typecheck.
1 parent 8e018b7 commit 8a93ba7

5 files changed

Lines changed: 96 additions & 4 deletions

File tree

backend/tests/unit/test_chat_async_offload.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ async def _collect_agentic_chunks(producer, callback_data=None):
4949
stack.enter_context(patch.object(agentic, 'get_user_timezone', lambda _uid: 'UTC'))
5050
stack.enter_context(patch.object(agentic, '_get_agentic_qa_prompt', lambda *_args, **_kwargs: 'SYSTEM'))
5151
stack.enter_context(patch.object(agentic, 'load_app_tools', lambda _uid: []))
52-
stack.enter_context(patch.object(agentic, 'get_current_datetime_block', lambda _uid, tz=None: ''))
52+
stack.enter_context(
53+
patch.object(agentic, 'get_current_datetime_block', lambda _uid, tz=None, location=None: '')
54+
)
5355
stack.enter_context(patch.object(agentic, '_convert_tools', lambda _core, _app: ([], {})))
5456
stack.enter_context(patch.object(agentic, '_messages_to_anthropic', lambda _messages: []))
5557
stack.enter_context(patch.object(agentic, '_inject_current_datetime', lambda messages, _block: messages))
@@ -84,6 +86,21 @@ def fake_is_file_question(question):
8486
assert ran_on['thread'] is not loop_thread, "retrieve_is_file_question must run off the event-loop thread"
8587

8688

89+
async def test_mobile_city_context_uses_only_mobile_platforms():
90+
async def fake_run_blocking(_executor, _function, _uid):
91+
return {'latitude': 40.7128, 'longitude': -74.006}
92+
93+
async def fake_city(latitude, longitude):
94+
assert (latitude, longitude) == (40.7128, -74.006)
95+
return 'New York, New York, United States'
96+
97+
with patch.object(agentic, 'run_blocking', fake_run_blocking), patch(
98+
'utils.conversations.location.async_get_google_maps_city', fake_city
99+
):
100+
assert await agentic._get_mobile_city('uid1', 'ios') == 'New York, New York, United States'
101+
assert await agentic._get_mobile_city('uid1', 'macos') is None
102+
103+
87104
def _file_chat_tool_for_stream_test():
88105
"""Create a FileChatTool without its production Firestore constructor for a hermetic stream test."""
89106
tool = object.__new__(chat_file.FileChatTool)
@@ -226,7 +243,7 @@ async def fake_agent_stream(
226243
with patch.object(agentic, 'get_user_timezone', rec('tz', 'UTC')), patch.object(
227244
agentic, '_get_agentic_qa_prompt', rec('prompt', 'SYSTEM')
228245
), patch.object(agentic, 'load_app_tools', rec('app_tools', [])), patch.object(
229-
agentic, 'get_current_datetime_block', lambda uid, tz=None: ''
246+
agentic, 'get_current_datetime_block', lambda uid, tz=None, location=None: ''
230247
), patch.object(
231248
agentic, '_convert_tools', lambda core, app: ([], {})
232249
), patch.object(

backend/tests/unit/test_prompt_cache_integration.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,19 @@ def test_current_datetime_block_carries_live_time():
991991
assert "2024-01-19" in block, "Datetime block should contain the live date"
992992

993993

994+
def test_current_datetime_block_includes_city_without_coordinates():
995+
chat_mod = _get_chat_module()
996+
_set_user(chat_mod, "Alice", "America/New_York")
997+
998+
block = chat_mod.get_current_datetime_block(
999+
"uid_alice", tz="America/New_York", location="New York, New York, United States"
1000+
)
1001+
1002+
assert "Current city-level location: New York, New York, United States" in block
1003+
assert "latitude" not in block.lower()
1004+
assert "longitude" not in block.lower()
1005+
1006+
9941007
def test_datetime_injected_into_user_turn_not_system():
9951008
"""
9961009
_inject_current_datetime must attach the datetime block to the latest user turn so the

backend/utils/conversations/location.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,42 @@ async def async_resolve_geolocation(geolocation: Optional[Geolocation]) -> Optio
142142
logger.error('async_resolve_geolocation enrichment failed: %s', e)
143143
return geolocation
144144
return enriched or geolocation
145+
146+
147+
async def async_get_google_maps_city(latitude: float, longitude: float) -> Optional[str]:
148+
cache_key = f"geocode-city:{latitude:.3f},{longitude:.3f}"
149+
try:
150+
cached = r.get(cache_key)
151+
if cached:
152+
return cached.decode() if isinstance(cached, bytes) else str(cached)
153+
except Exception as error:
154+
logger.warning('Failed to read city geocode cache error_type=%s', type(error).__name__)
155+
156+
key = os.getenv('GOOGLE_MAPS_API_KEY')
157+
try:
158+
async with get_maps_semaphore():
159+
response = await get_maps_client().get(
160+
"https://maps.googleapis.com/maps/api/geocode/json",
161+
params={"latlng": f"{latitude},{longitude}", "key": key},
162+
)
163+
data = response.json()
164+
except Exception as error:
165+
logger.error('City geocoding failed error_type=%s', type(error).__name__)
166+
return None
167+
168+
if data.get('status') != 'OK' or not data.get('results'):
169+
return None
170+
parts = {}
171+
for component in data['results'][0].get('address_components') or []:
172+
for component_type in component.get('types') or []:
173+
if component_type in {'locality', 'postal_town', 'administrative_area_level_1', 'country'}:
174+
parts.setdefault(component_type, component.get('long_name'))
175+
city = parts.get('locality') or parts.get('postal_town')
176+
if not city:
177+
return None
178+
result = ', '.join(part for part in (city, parts.get('administrative_area_level_1'), parts.get('country')) if part)
179+
try:
180+
r.set(cache_key, result, ex=172800)
181+
except Exception as error:
182+
logger.warning('Failed to cache city geocode error_type=%s', type(error).__name__)
183+
return result

backend/utils/llm/chat.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ def get_user_timezone(uid: str) -> str:
480480
return "UTC"
481481

482482

483-
def get_current_datetime_block(uid: str, tz: Optional[str] = None) -> str:
483+
def get_current_datetime_block(uid: str, tz: Optional[str] = None, location: Optional[str] = None) -> str:
484484
"""Build the current-datetime block injected into the user turn.
485485
486486
Kept out of the cached system prefix so the cached bytes stay stable across requests
@@ -497,10 +497,12 @@ def get_current_datetime_block(uid: str, tz: Optional[str] = None) -> str:
497497
tz = "UTC"
498498
current_datetime_str = current_datetime_user.strftime('%Y-%m-%d %H:%M:%S')
499499
current_datetime_iso = current_datetime_user.isoformat()
500+
location_line = f"Current city-level location: {location}\n" if location else ""
500501
return (
501502
"<current_datetime>\n"
502503
f"Current date time in {tz}: {current_datetime_str}\n"
503504
f"Current date time ISO format: {current_datetime_iso}\n"
505+
f"{location_line}"
504506
"</current_datetime>"
505507
)
506508

backend/utils/retrieval/agentic.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,24 @@ def _inject_current_datetime(anthropic_messages: list, datetime_block: str) -> l
425425
return anthropic_messages
426426

427427

428+
async def _get_mobile_city(uid: str, platform: Optional[str]) -> Optional[str]:
429+
if platform is None or platform.strip().lower() not in {'ios', 'android'}:
430+
return None
431+
try:
432+
from database.redis_db import get_cached_user_geolocation
433+
from utils.conversations.location import async_get_google_maps_city
434+
435+
geolocation = await run_blocking(db_executor, get_cached_user_geolocation, uid)
436+
if not geolocation:
437+
return None
438+
return await async_get_google_maps_city(float(geolocation['latitude']), float(geolocation['longitude']))
439+
except (KeyError, TypeError, ValueError):
440+
return None
441+
except Exception as error:
442+
logger.warning('Mobile city context unavailable error_type=%s', type(error).__name__)
443+
return None
444+
445+
428446
# ---------------------------------------------------------------------------
429447
# Core Anthropic agent streaming loop
430448
# ---------------------------------------------------------------------------
@@ -685,6 +703,7 @@ async def execute_agentic_chat_stream(
685703
# so they share the first-event deadline instead of leaving the SSE body silent.
686704
async with asyncio.timeout(AGENT_STREAM_FIRST_EVENT_TIMEOUT_SECONDS):
687705
tz = await run_blocking(db_executor, get_user_timezone, uid)
706+
city = await _get_mobile_city(uid, platform)
688707
system_prompt = await run_blocking(
689708
db_executor, _get_agentic_qa_prompt, uid, app, messages, context=context, tz=tz, platform=platform
690709
)
@@ -755,7 +774,9 @@ async def execute_agentic_chat_stream(
755774
# Convert messages to Anthropic format. The current datetime is injected into the user
756775
# turn (not the system prompt) so the cache_control system prefix stays byte-stable.
757776
anthropic_messages = _messages_to_anthropic(messages)
758-
anthropic_messages = _inject_current_datetime(anthropic_messages, get_current_datetime_block(uid, tz=tz))
777+
anthropic_messages = _inject_current_datetime(
778+
anthropic_messages, get_current_datetime_block(uid, tz=tz, location=city)
779+
)
759780

760781
callback = AsyncStreamingCallback()
761782

0 commit comments

Comments
 (0)