|
4 | 4 | This module provides functionality to load templates from the database. |
5 | 5 | """ |
6 | 6 |
|
7 | | -from typing import Dict, Optional, Tuple |
| 7 | +import asyncio |
| 8 | +import hashlib |
| 9 | +import json |
| 10 | +from typing import Dict, List, Optional, Tuple |
8 | 11 |
|
9 | 12 | from app.ai.voice.agents.breeze_buddy.template.transformation_function import ( |
10 | 13 | TEMPLATE_FUNCTION_REGISTRY, |
11 | 14 | ) |
12 | 15 | from app.ai.voice.agents.breeze_buddy.template.types import ( |
| 16 | + DataSourceRef, |
13 | 17 | FlowMode, |
14 | 18 | TemplateModel, |
15 | 19 | ) |
|
21 | 25 | from app.database.accessor.breeze_buddy.template import ( |
22 | 26 | get_template_by_id_with_fallback, |
23 | 27 | ) |
| 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 |
24 | 31 |
|
25 | 32 |
|
26 | 33 | class FlowConfigLoader: |
@@ -94,7 +101,7 @@ async def load_template( |
94 | 101 | merchant_id: Optional[str] = None, |
95 | 102 | call_payload: Optional[Dict[str, str]] = None, |
96 | 103 | template_id: Optional[str] = None, |
97 | | - ) -> Tuple[TemplateModel, Dict[str, str]]: |
| 104 | + ) -> Tuple[TemplateModel, Dict[str, str], List[Dict]]: |
98 | 105 | """ |
99 | 106 | Load template and render task messages with variables. |
100 | 107 |
|
@@ -123,6 +130,7 @@ async def load_template( |
123 | 130 | ) |
124 | 131 |
|
125 | 132 | template_vars = {} |
| 133 | + _ds_messages: List[Dict] = [] |
126 | 134 |
|
127 | 135 | # 1. Load credentials from credentials table (global + merchant-specific) |
128 | 136 | try: |
@@ -205,13 +213,30 @@ async def load_template( |
205 | 213 | f"Injected extra payload field '{field_name}' into template_vars" |
206 | 214 | ) |
207 | 215 |
|
| 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 | + |
208 | 233 | # Direct mode has a flat structure (system_prompt + flat function list) |
209 | 234 | # rather than a nodes array, so render the top-level message fields and |
210 | 235 | # skip the per-node loop entirely. |
211 | 236 | if template_obj.flow.get("mode") == FlowMode.DIRECT.value: |
212 | 237 | self._render_direct_mode_flow(template_obj.flow, template_vars) |
213 | 238 | 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 |
215 | 240 |
|
216 | 241 | # Get nodes from flow structure |
217 | 242 | nodes = template_obj.flow.get("nodes", []) |
@@ -274,4 +299,57 @@ async def load_template( |
274 | 299 | node["role_messages"] = rendered_role_dicts |
275 | 300 |
|
276 | 301 | 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 |
0 commit comments