Skip to content

Commit e60b854

Browse files
committed
feat: inline Google Sheets data source integration
1 parent 81d67b9 commit e60b854

20 files changed

Lines changed: 1296 additions & 16 deletions

File tree

app/ai/voice/agents/breeze_buddy/dispatch/worker.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
_release_number,
5353
_run_pre_checks_for_lead,
5454
)
55+
from app.ai.voice.agents.breeze_buddy.managers.data_source_prefetch import (
56+
prefetch_data_sources,
57+
)
5558
from app.ai.voice.agents.breeze_buddy.managers.utils import (
5659
prepare_and_store_initial_greeting,
5760
)
@@ -339,6 +342,8 @@ async def _dispatch(
339342

340343
if template:
341344
template = apply_playground_overrides(locked, template)
345+
if template.data_sources:
346+
asyncio.create_task(prefetch_data_sources(template=template))
342347
await prepare_and_store_initial_greeting(
343348
lead_id=locked.id,
344349
payload=locked.payload or {},
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""
2+
Data Source Prefetch Manager
3+
4+
Pre-warms Redis with Google Sheets content for all DataSourceRefs attached to a
5+
template at dispatch time. Runs concurrently with greeting TTS synthesis.
6+
7+
Cache key is scoped to a content hash so concurrent calls sharing the same sheet
8+
use a single cached copy.
9+
10+
Cache key : ``datasource:{sheet_id}:{sheet_name}:{cols_hash}``
11+
TTL : 60 s
12+
"""
13+
14+
import asyncio
15+
import hashlib
16+
import json
17+
from typing import Optional
18+
19+
from app.ai.voice.agents.breeze_buddy.template.types import DataSourceRef, TemplateModel
20+
from app.core.logger import logger
21+
from app.services.data_sources import DATA_SOURCE_UNAVAILABLE
22+
from app.services.google.sheets import extract_spreadsheet_id, fetch_formatted
23+
from app.services.redis import get_redis_service
24+
25+
_CACHE_TTL = 60 # seconds — shared across leads; short to keep data fresh
26+
_FETCH_TIMEOUT = 5.0
27+
28+
29+
def _cache_key(ref: DataSourceRef) -> str:
30+
"""Derive a stable cache key from the sheet config."""
31+
spreadsheet_id = extract_spreadsheet_id(ref.spreadsheet_url)
32+
if not spreadsheet_id:
33+
spreadsheet_id = "invalid_url"
34+
sheet = ref.sheet_name or "_first_"
35+
cols = json.dumps(sorted(ref.columns or []))
36+
cols_digest = hashlib.md5(cols.encode()).hexdigest()[:8]
37+
return f"datasource:{spreadsheet_id}:{sheet}:{cols_digest}:{ref.format.value}"
38+
39+
40+
async def _prefetch_one(ref: DataSourceRef) -> None:
41+
if not ref.is_active:
42+
return
43+
44+
spreadsheet_id = extract_spreadsheet_id(ref.spreadsheet_url)
45+
if not spreadsheet_id:
46+
logger.warning("Prefetch: invalid spreadsheet_url for '%s'", ref.name)
47+
return
48+
49+
try:
50+
content = await asyncio.wait_for(
51+
fetch_formatted(
52+
spreadsheet_id=spreadsheet_id,
53+
sheet_name=ref.sheet_name,
54+
columns=ref.columns,
55+
format=ref.format.value,
56+
),
57+
timeout=_FETCH_TIMEOUT,
58+
)
59+
except asyncio.TimeoutError:
60+
logger.warning("Prefetch timeout for data source '%s'", ref.name)
61+
return
62+
except Exception as exc:
63+
logger.error(
64+
"Prefetch error for data source '%s': %s", ref.name, exc, exc_info=True
65+
)
66+
return
67+
68+
if not content or content == DATA_SOURCE_UNAVAILABLE:
69+
return
70+
71+
cache_key = _cache_key(ref)
72+
try:
73+
redis = await get_redis_service()
74+
await redis.setex(cache_key, content, ttl_seconds=_CACHE_TTL)
75+
logger.info(
76+
"Prefetched data source '%s' (%d chars, TTL=%ds)",
77+
ref.name,
78+
len(content),
79+
_CACHE_TTL,
80+
)
81+
except Exception as exc:
82+
logger.error("Failed to cache data source '%s': %s", ref.name, exc)
83+
84+
85+
async def prefetch_data_sources(
86+
template: Optional[TemplateModel],
87+
) -> None:
88+
if not template or not template.data_sources:
89+
return
90+
await asyncio.gather(
91+
*[_prefetch_one(ref) for ref in template.data_sources],
92+
return_exceptions=True,
93+
)

app/ai/voice/agents/breeze_buddy/template/field_reference.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"secrets": { "description": "Flat dict of credential key-value pairs injected as template vars, overriding reseller-level credentials for the same keys. Values substitute into {secret_key} placeholders in HTTP URLs, headers, and bodies.", "example": { "api_token": "Bearer sk-prod-abc123", "webhook_secret": "wh_live_xyz789" } },
1818
"outbound_number_id": { "description": "UUID of the outbound phone number record to use for calls from this template. Must belong to the reseller. Null to use the reseller-default number.", "example": "eb0df4e9-a438-4ce4-a828-addf9db5ce3a" },
1919
"is_active": { "description": "When false, the template is soft-disabled: dispatch will not enqueue new leads for it. Set to false (not delete) when retiring a template.", "example": true },
20-
"supported_channels": { "description": "Channels this template may be served on. Default ['voice']. Add 'chat' to enable the Buddy Assist web-chat mode. Must have at least one entry.", "example": ["voice"] }
20+
"supported_channels": { "description": "Channels this template may be served on. Default ['voice']. Add 'chat' to enable the Buddy Assist web-chat mode. Must have at least one entry.", "example": ["voice"] },
21+
"data_sources": { "description": "Google Sheets data sources attached to this template. Each ref has name, spreadsheet_url, sheet_name, columns filter, output format (markdown_table/csv/json), and is_active flag. Fetched at call dispatch and injected as {datasource_<name>} template vars into LLM context. For example, a ref named 'customer_list' is referenced as {datasource_customer_list} in prompts.", "example": [{ "name": "customer_list", "spreadsheet_url": "https://docs.google.com/spreadsheets/d/abc123", "sheet_name": "Sheet1", "columns": ["Name", "Status"], "format": "markdown_table", "is_active": true }] }
2122
},
2223

2324
"ConfigurationModel": {

app/ai/voice/agents/breeze_buddy/template/loader.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
This module provides functionality to load templates from the database.
55
"""
66

7+
import asyncio
8+
import hashlib
9+
import json
710
from typing import Dict, Optional, Tuple
811

912
from app.ai.voice.agents.breeze_buddy.template.transformation_function import (
1013
TEMPLATE_FUNCTION_REGISTRY,
1114
)
1215
from app.ai.voice.agents.breeze_buddy.template.types import (
16+
DataSourceRef,
1317
FlowMode,
1418
TemplateModel,
1519
)
@@ -21,6 +25,9 @@
2125
from app.database.accessor.breeze_buddy.template import (
2226
get_template_by_id_with_fallback,
2327
)
28+
from app.services.data_sources import DATA_SOURCE_UNAVAILABLE
29+
from app.services.google.sheets import extract_spreadsheet_id, fetch_formatted
30+
from app.services.redis import get_redis_service
2431

2532

2633
class FlowConfigLoader:
@@ -205,6 +212,23 @@ async def load_template(
205212
f"Injected extra payload field '{field_name}' into template_vars"
206213
)
207214

215+
# 5. data sources — inject as {datasource_<name>} variables
216+
if template_obj.data_sources:
217+
contents = await asyncio.gather(
218+
*[
219+
self._fetch_one_data_source(ref)
220+
for ref in template_obj.data_sources
221+
],
222+
return_exceptions=True,
223+
)
224+
for ref, result in zip(template_obj.data_sources, contents):
225+
if not isinstance(result, Exception) and result is not None:
226+
var_key = f"datasource_{ref.name}"
227+
if var_key not in template_vars:
228+
template_vars[var_key] = result
229+
logger.info(
230+
f"Injected data source '{ref.name}' as variable '{var_key}'"
231+
)
208232
# Direct mode has a flat structure (system_prompt + flat function list)
209233
# rather than a nodes array, so render the top-level message fields and
210234
# skip the per-node loop entirely.
@@ -275,3 +299,59 @@ async def load_template(
275299

276300
logger.info(f"Rendered task messages for template {template_obj.name}")
277301
return template_obj, template_vars
302+
303+
async def _fetch_one_data_source(self, ref: "DataSourceRef") -> str:
304+
if not ref.is_active:
305+
return DATA_SOURCE_UNAVAILABLE
306+
307+
spreadsheet_id = extract_spreadsheet_id(ref.spreadsheet_url)
308+
if not spreadsheet_id:
309+
logger.warning(
310+
"Data source '%s' has malformed spreadsheet_url: %s",
311+
ref.name,
312+
ref.spreadsheet_url,
313+
)
314+
return DATA_SOURCE_UNAVAILABLE
315+
316+
sheet = ref.sheet_name or "_first_"
317+
cols = json.dumps(sorted(ref.columns or []))
318+
cols_digest = hashlib.md5(cols.encode()).hexdigest()[:8]
319+
cache_key = (
320+
f"datasource:{spreadsheet_id}:{sheet}:{cols_digest}:{ref.format.value}"
321+
)
322+
323+
try:
324+
redis = await get_redis_service()
325+
cached = await redis.get(cache_key)
326+
if cached:
327+
return cached
328+
except Exception:
329+
pass
330+
331+
try:
332+
content = await asyncio.wait_for(
333+
fetch_formatted(
334+
spreadsheet_id=spreadsheet_id,
335+
sheet_name=ref.sheet_name,
336+
columns=ref.columns,
337+
format=ref.format.value,
338+
),
339+
timeout=5.0,
340+
)
341+
except asyncio.TimeoutError:
342+
logger.warning("Data source '%s' fetch timed out", ref.name)
343+
return DATA_SOURCE_UNAVAILABLE
344+
except Exception as exc:
345+
logger.error(
346+
"Data source '%s' fetch failed: %s", ref.name, exc, exc_info=True
347+
)
348+
return DATA_SOURCE_UNAVAILABLE
349+
350+
if content != DATA_SOURCE_UNAVAILABLE:
351+
try:
352+
redis = await get_redis_service()
353+
await redis.setex(cache_key, content, ttl_seconds=60)
354+
except Exception:
355+
pass
356+
357+
return content

app/ai/voice/agents/breeze_buddy/template/types.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,6 +2062,40 @@ def _default_supported_channels() -> List[Literal["voice", "chat"]]:
20622062
return ["voice"]
20632063

20642064

2065+
class DataSourceFormat(str, Enum):
2066+
MARKDOWN_TABLE = "markdown_table"
2067+
CSV = "csv"
2068+
JSON = "json"
2069+
2070+
2071+
class DataSourceRef(BaseModel):
2072+
"""Inline config for a data source attached to a template.
2073+
2074+
Content is fetched at call time and injected as ``template_vars[datasource_<name>]``.
2075+
The merchant references it in prompts with ``{datasource_<name>}`` placeholder syntax
2076+
(e.g. name='products' → use ``{datasource_products}`` in prompts).
2077+
The ``datasource_`` prefix prevents collision with payload credentials.
2078+
"""
2079+
2080+
name: str = Field(
2081+
description="Variable name. Referenced in prompts as {datasource_<name>} (e.g. name='products' → {datasource_products})"
2082+
)
2083+
spreadsheet_url: str = Field(
2084+
description="Full Google Sheets URL (shared with platform SA)"
2085+
)
2086+
sheet_name: Optional[str] = Field(
2087+
default=None, description="Tab name. NULL = first tab"
2088+
)
2089+
columns: Optional[List[str]] = Field(
2090+
default=None, description="Columns to include. NULL = all columns"
2091+
)
2092+
format: DataSourceFormat = Field(
2093+
default=DataSourceFormat.MARKDOWN_TABLE,
2094+
description="Output format: 'markdown_table' | 'csv' | 'json'",
2095+
)
2096+
is_active: bool = Field(default=True, description="Skip if false at call time")
2097+
2098+
20652099
class TemplateModel(BaseModel):
20662100
# Read-only fields (set by server, not editable via API).
20672101
# These are intentionally excluded from ReplaceTemplateRequest so that
@@ -2081,6 +2115,10 @@ class TemplateModel(BaseModel):
20812115
secrets: Optional[Dict[str, Any]] = None
20822116
outbound_number_id: Optional[str] = None
20832117
is_active: bool = True
2118+
data_sources: Optional[List[DataSourceRef]] = Field(
2119+
default=None,
2120+
description="Inline data source configs attached to this template",
2121+
)
20842122
# Channels this template is allowed to be served on. Defaults to
20852123
# voice-only so existing templates are unaffected. Add "chat" to
20862124
# opt the template into the chat (text) mode flow build path
@@ -2129,6 +2167,7 @@ class CreateTemplateRequest(BaseModel):
21292167
expected_callback_response_schema: Optional[Dict[str, Any]] = None
21302168
configurations: Optional[ConfigurationModel] = None
21312169
secrets: Optional[Dict[str, Any]] = None
2170+
data_sources: Optional[List[DataSourceRef]] = None
21322171
supported_channels: List[Literal["voice", "chat"]] = Field(
21332172
default_factory=_default_supported_channels,
21342173
min_length=1,
@@ -2177,6 +2216,7 @@ class ReplaceTemplateRequest(BaseModel):
21772216
expected_callback_response_schema: Optional[Dict[str, Any]] = None
21782217
configurations: Optional[ConfigurationModel] = None
21792218
secrets: Optional[Dict[str, Any]] = None
2219+
data_sources: Optional[List[DataSourceRef]] = None
21802220
supported_channels: Optional[List[Literal["voice", "chat"]]] = Field(
21812221
default=None,
21822222
min_length=1,

app/api/routers/breeze_buddy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
# Daily transport (web/mobile clients via Daily.co)
1717
from app.api.routers.breeze_buddy.daily import router as daily_router
18+
from app.api.routers.breeze_buddy.data_sources import router as data_sources_router
1819
from app.api.routers.breeze_buddy.demo import router as demo_router
1920
from app.api.routers.breeze_buddy.leads import router as leads_router
2021
from app.api.routers.breeze_buddy.merchants import router as merchants_router
@@ -44,6 +45,7 @@
4445
# ============================================================================
4546

4647
# Public demo (unauthenticated)
48+
router.include_router(data_sources_router, prefix="", tags=["data-sources"])
4749
router.include_router(demo_router, prefix="", tags=["demo"])
4850

4951
# Authentication (JWT & S2S tokens)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Sheet discovery endpoints (admin-only utility).
3+
4+
Discovery routes declared BEFORE /{id} to avoid path conflict if CRUD endpoints
5+
are added later.
6+
"""
7+
8+
from typing import List, Optional
9+
10+
from fastapi import APIRouter, Depends, Query
11+
12+
from app.api.security.breeze_buddy.rbac_token import get_current_user_with_rbac
13+
from app.core.security.authorization import require_admin
14+
from app.schemas import UserInfo
15+
from app.schemas.breeze_buddy.data_source import (
16+
ColumnsResponse,
17+
PreviewResponse,
18+
TabsResponse,
19+
)
20+
21+
from .handlers import (
22+
list_columns_handler,
23+
list_tabs_handler,
24+
preview_handler,
25+
)
26+
27+
router = APIRouter()
28+
29+
30+
@router.get("/data-sources/sheets/tabs", response_model=TabsResponse)
31+
async def get_sheet_tabs(
32+
spreadsheet_url: str = Query(..., description="Full Google Sheets URL"),
33+
current_user: UserInfo = Depends(get_current_user_with_rbac),
34+
):
35+
"""List all tab names in a Google Spreadsheet (admin-only)."""
36+
require_admin(current_user)
37+
return await list_tabs_handler(spreadsheet_url, current_user)
38+
39+
40+
@router.get("/data-sources/sheets/columns", response_model=ColumnsResponse)
41+
async def get_sheet_columns(
42+
spreadsheet_url: str = Query(..., description="Full Google Sheets URL"),
43+
sheet_name: Optional[str] = Query(
44+
None, description="Tab name (default: first tab)"
45+
),
46+
current_user: UserInfo = Depends(get_current_user_with_rbac),
47+
):
48+
"""List column headers for a sheet tab (admin-only)."""
49+
require_admin(current_user)
50+
return await list_columns_handler(spreadsheet_url, sheet_name)
51+
52+
53+
@router.get("/data-sources/sheets/preview", response_model=PreviewResponse)
54+
async def preview_sheet(
55+
spreadsheet_url: str = Query(..., description="Full Google Sheets URL"),
56+
sheet_name: Optional[str] = Query(
57+
None, description="Tab name (default: first tab)"
58+
),
59+
columns: Optional[List[str]] = Query(None, description="Columns to include"),
60+
max_rows: int = Query(10, ge=1, le=100, description="Max rows to return"),
61+
current_user: UserInfo = Depends(get_current_user_with_rbac),
62+
):
63+
"""Preview up to N rows from a sheet (admin-only)."""
64+
require_admin(current_user)
65+
return await preview_handler(spreadsheet_url, sheet_name, columns, max_rows)

0 commit comments

Comments
 (0)