Skip to content

Commit 974542d

Browse files
committed
feat: inline Google Sheets data source integration
- Inline DataSourceRef with full config (url, tab, columns, format, inject_as) - Single migration: ALTER TABLE template ADD data_sources JSONB - Discovery routes (admin-only) for sheet tab/column/preview - Prefetch at dispatch warms Redis (60s TTL, content-hash key) - Layer 5 injection: var -> template_vars, message -> ds_messages - Cache miss writes back to Redis; defensive URL parsing; session close
1 parent 81d67b9 commit 974542d

20 files changed

Lines changed: 755 additions & 25 deletions

File tree

app/ai/voice/agents/breeze_buddy/agent/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def __init__(
159159
self.greeting_text: Optional[str] = (
160160
None # Resolved greeting text for LLM context
161161
)
162+
self.ds_messages: List[Dict] = []
162163
self.default_vad_params: Optional[VADParams] = None
163164
self.default_interruption_config: Optional[InterruptionConfig] = None
164165

@@ -356,6 +357,7 @@ async def _setup_daily_transport(self, runner_args: RunnerArguments) -> None:
356357
self.template,
357358
self.configurations,
358359
self.template_vars,
360+
self.ds_messages,
359361
) = await load_template_config(self.lead)
360362
except ValueError as e:
361363
logger.error(f"Failed to load template config for Daily mode: {e}")
@@ -553,6 +555,7 @@ async def _setup_telephony_transport(self) -> bool:
553555
self.template,
554556
self.configurations,
555557
self.template_vars,
558+
self.ds_messages,
556559
) = await load_template_config(self.lead)
557560
except ValueError as e:
558561
error_msg = f"Template loading failed: {str(e)}"
@@ -908,7 +911,9 @@ async def _handle_client_connected(self) -> None:
908911
self.flow_config,
909912
self.end_conversation_callbacks,
910913
self.expected_callback_response_schema,
911-
) = build_flow_config(self.flow_builder, self.template)
914+
) = build_flow_config(
915+
self.flow_builder, self.template, ds_messages=self.ds_messages
916+
)
912917

913918
lead_payload = self.lead.payload or {}
914919

@@ -1012,7 +1017,6 @@ async def run(self, runner_args: Optional[RunnerArguments] = None) -> None:
10121017
else:
10131018
if not await self._setup_telephony_transport():
10141019
if self.completion_function and self.call_sid:
1015-
10161020
lead = await get_lead_by_call_id(self.call_sid)
10171021
# If lead is None (not found), or it doesn't have an outcome,
10181022
# or the outcome is not a BLOCKED_ outcome, then it's an early hangup.

app/ai/voice/agents/breeze_buddy/agent/flow.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,18 @@
2727

2828
async def load_template_config(
2929
lead: LeadCallTracker,
30-
) -> tuple[TemplateModel, Optional[ConfigurationModel], Dict[str, str]]:
30+
) -> tuple[TemplateModel, Optional[ConfigurationModel], Dict[str, str], List[Dict]]:
3131
"""Load template configuration from database.
3232
3333
Args:
3434
lead: The lead instance
3535
3636
Returns:
37-
Tuple of (template, configurations, template_vars)
37+
Tuple of (template, configurations, template_vars, ds_messages)
3838
"""
3939
flow_loader = FlowConfigLoader()
4040

41-
template, template_vars = await flow_loader.load_template(
41+
template, template_vars, ds_messages = await flow_loader.load_template(
4242
reseller_id=lead.reseller_id,
4343
template=lead.template,
4444
merchant_id=lead.merchant_id if lead else None,
@@ -58,7 +58,7 @@ async def load_template_config(
5858
if getattr(lead, "execution_mode", None) != ExecutionMode.DAILY_STREAM:
5959
validate_template_compat(template)
6060

61-
return template, template.configurations, template_vars
61+
return template, template.configurations, template_vars, ds_messages
6262

6363

6464
def setup_flow_manager(
@@ -127,12 +127,14 @@ def setup_flow_manager(
127127
def build_flow_config(
128128
flow_builder: FlowConfigBuilder,
129129
template: TemplateModel,
130+
ds_messages: Optional[List[Dict]] = None,
130131
) -> tuple[Dict[str, Any], List, Any]:
131132
"""Build flow configuration from template.
132133
133134
Args:
134135
flow_builder: Flow config builder
135136
template: Template model
137+
ds_messages: Optional data source system messages to inject
136138
137139
Returns:
138140
Tuple of (flow_config, end_conversation_callbacks, expected_callback_response_schema)
@@ -143,6 +145,9 @@ def build_flow_config(
143145
"expected_callback_response_schema", None
144146
)
145147

148+
if ds_messages:
149+
flow_config["_data_source_messages"] = ds_messages
150+
146151
logger.info(
147152
f"Built flow config with {len(flow_config['nodes'])} nodes, "
148153
f"initial: {flow_config['initial_node']}, "

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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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.google.sheets import fetch_formatted
22+
from app.services.redis import get_redis_service
23+
24+
_CACHE_TTL = 60 # seconds — shared across leads; short to keep data fresh
25+
_FETCH_TIMEOUT = 5.0
26+
27+
28+
def _cache_key(ref: DataSourceRef) -> str:
29+
"""Derive a stable cache key from the sheet config."""
30+
spreadsheet_id = ref.spreadsheet_url.split("/d/")[1].split("/")[0]
31+
sheet = ref.sheet_name or "_first_"
32+
cols = json.dumps(sorted(ref.columns or []))
33+
cols_digest = hashlib.md5(cols.encode()).hexdigest()[:8]
34+
return f"datasource:{spreadsheet_id}:{sheet}:{cols_digest}:{ref.format.value}"
35+
36+
37+
async def _prefetch_one(ref: DataSourceRef) -> None:
38+
if not ref.is_active:
39+
return
40+
try:
41+
content = await asyncio.wait_for(
42+
fetch_formatted(
43+
spreadsheet_id=ref.spreadsheet_url.split("/d/")[1].split("/")[0],
44+
sheet_name=ref.sheet_name,
45+
columns=ref.columns,
46+
format=ref.format.value,
47+
),
48+
timeout=_FETCH_TIMEOUT,
49+
)
50+
cache_key = _cache_key(ref)
51+
redis = await get_redis_service()
52+
await redis.setex(cache_key, content, ttl_seconds=_CACHE_TTL)
53+
logger.info(
54+
"Prefetched data source '%s' (%d chars, TTL=%ds)",
55+
ref.name,
56+
len(content),
57+
_CACHE_TTL,
58+
)
59+
except asyncio.TimeoutError:
60+
logger.warning("Prefetch timeout for data source '%s'", ref.name)
61+
except Exception as exc:
62+
logger.error(
63+
"Prefetch error for data source '%s': %s", ref.name, exc, exc_info=True
64+
)
65+
66+
67+
async def prefetch_data_sources(
68+
template: Optional[TemplateModel],
69+
) -> None:
70+
if not template or not template.data_sources:
71+
return
72+
await asyncio.gather(
73+
*[_prefetch_one(ref) for ref in template.data_sources],
74+
return_exceptions=True,
75+
)

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

Lines changed: 82 additions & 4 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-
from typing import Dict, Optional, Tuple
7+
import asyncio
8+
import hashlib
9+
import json
10+
from typing import Dict, List, 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 fetch_formatted
30+
from app.services.redis import get_redis_service
2431

2532

2633
class FlowConfigLoader:
@@ -94,7 +101,7 @@ async def load_template(
94101
merchant_id: Optional[str] = None,
95102
call_payload: Optional[Dict[str, str]] = None,
96103
template_id: Optional[str] = None,
97-
) -> Tuple[TemplateModel, Dict[str, str]]:
104+
) -> Tuple[TemplateModel, Dict[str, str], List[Dict]]:
98105
"""
99106
Load template and render task messages with variables.
100107
@@ -123,6 +130,7 @@ async def load_template(
123130
)
124131

125132
template_vars = {}
133+
_ds_messages: List[Dict] = []
126134

127135
# 1. Load credentials from credentials table (global + merchant-specific)
128136
try:
@@ -205,13 +213,30 @@ async def load_template(
205213
f"Injected extra payload field '{field_name}' into template_vars"
206214
)
207215

216+
# 5. Inject data_source content.
217+
if template_obj.data_sources:
218+
contents = await asyncio.gather(
219+
*[
220+
self._fetch_one_data_source(ref)
221+
for ref in template_obj.data_sources
222+
],
223+
return_exceptions=True,
224+
)
225+
for ref, result in zip(template_obj.data_sources, contents):
226+
if isinstance(result, Exception) or result is None:
227+
continue
228+
if ref.inject_as == "message":
229+
_ds_messages.append({"role": "system", "content": result})
230+
else:
231+
template_vars[ref.name] = result
232+
208233
# Direct mode has a flat structure (system_prompt + flat function list)
209234
# rather than a nodes array, so render the top-level message fields and
210235
# skip the per-node loop entirely.
211236
if template_obj.flow.get("mode") == FlowMode.DIRECT.value:
212237
self._render_direct_mode_flow(template_obj.flow, template_vars)
213238
logger.info(f"Rendered direct-mode flow for template {template_obj.name}")
214-
return template_obj, template_vars
239+
return template_obj, template_vars, _ds_messages
215240

216241
# Get nodes from flow structure
217242
nodes = template_obj.flow.get("nodes", [])
@@ -274,4 +299,57 @@ async def load_template(
274299
node["role_messages"] = rendered_role_dicts
275300

276301
logger.info(f"Rendered task messages for template {template_obj.name}")
277-
return template_obj, template_vars
302+
return template_obj, template_vars, _ds_messages
303+
304+
async def _fetch_one_data_source(self, ref: "DataSourceRef") -> str:
305+
if not ref.is_active:
306+
return DATA_SOURCE_UNAVAILABLE
307+
308+
try:
309+
spreadsheet_id = ref.spreadsheet_url.split("/d/")[1].split("/")[0]
310+
except (IndexError, AttributeError):
311+
logger.warning(
312+
"Data source '%s' has malformed spreadsheet_url: %s",
313+
ref.name,
314+
ref.spreadsheet_url,
315+
)
316+
return DATA_SOURCE_UNAVAILABLE
317+
318+
sheet = ref.sheet_name or "_first_"
319+
cols = json.dumps(sorted(ref.columns or []))
320+
cols_digest = hashlib.md5(cols.encode()).hexdigest()[:8]
321+
cache_key = (
322+
f"datasource:{spreadsheet_id}:{sheet}:{cols_digest}:{ref.format.value}"
323+
)
324+
325+
try:
326+
redis = await get_redis_service()
327+
cached = await redis.get(cache_key)
328+
if cached:
329+
return cached
330+
except Exception:
331+
pass
332+
333+
try:
334+
content = await asyncio.wait_for(
335+
fetch_formatted(
336+
spreadsheet_id=spreadsheet_id,
337+
sheet_name=ref.sheet_name,
338+
columns=ref.columns,
339+
format=ref.format.value,
340+
),
341+
timeout=0.8,
342+
)
343+
except asyncio.TimeoutError:
344+
return DATA_SOURCE_UNAVAILABLE
345+
except Exception:
346+
return DATA_SOURCE_UNAVAILABLE
347+
348+
if content != DATA_SOURCE_UNAVAILABLE:
349+
try:
350+
redis = await get_redis_service()
351+
await redis.setex(cache_key, content, ttl_seconds=60)
352+
except Exception:
353+
pass
354+
355+
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+
All sheet configuration lives inline — no separate ``data_source`` table.
2075+
Two templates sharing the same sheet each store the URL independently.
2076+
"""
2077+
2078+
name: str = Field(description="Variable name used as {name} placeholder in prompts")
2079+
inject_as: Literal["var", "message"] = Field(
2080+
default="var",
2081+
description="'var' → template_vars[{name}] = content; 'message' → prepend system message",
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,

0 commit comments

Comments
 (0)